Complete Implemenation of tenancy
This commit is contained in:
446
frontend/src/components/auth/SignUpFormEnhanced.tsx
Normal file
446
frontend/src/components/auth/SignUpFormEnhanced.tsx
Normal file
@@ -0,0 +1,446 @@
|
||||
/**
|
||||
* Enhanced Multi-Step Signup Form
|
||||
* Handles paid plan signups with billing information collection
|
||||
* Step 1: Basic user info
|
||||
* Step 2: Billing info (for paid plans only)
|
||||
* Step 3: Payment method selection (for paid plans only)
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { ChevronLeftIcon, EyeCloseIcon, EyeIcon } from '../../icons';
|
||||
import { ChevronRight, Check } from 'lucide-react';
|
||||
import Label from '../form/Label';
|
||||
import Input from '../form/input/InputField';
|
||||
import Checkbox from '../form/input/Checkbox';
|
||||
import Button from '../ui/button/Button';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import BillingFormStep, { BillingFormData } from '../billing/BillingFormStep';
|
||||
import PaymentMethodSelect, { PaymentMethodConfig } from '../billing/PaymentMethodSelect';
|
||||
|
||||
interface SignUpFormEnhancedProps {
|
||||
planDetails?: any;
|
||||
planLoading?: boolean;
|
||||
}
|
||||
|
||||
export default function SignUpFormEnhanced({ planDetails: planDetailsProp, planLoading: planLoadingProp }: SignUpFormEnhancedProps) {
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isChecked, setIsChecked] = useState(false);
|
||||
|
||||
// Step 1: Basic user info
|
||||
const [formData, setFormData] = useState({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
password: '',
|
||||
username: '',
|
||||
accountName: '',
|
||||
});
|
||||
|
||||
// Step 2: Billing info (for paid plans)
|
||||
const [billingData, setBillingData] = useState<BillingFormData>({
|
||||
billing_email: '',
|
||||
billing_address_line1: '',
|
||||
billing_address_line2: '',
|
||||
billing_city: '',
|
||||
billing_state: '',
|
||||
billing_postal_code: '',
|
||||
billing_country: '',
|
||||
tax_id: '',
|
||||
});
|
||||
|
||||
// Step 3: Payment method
|
||||
const [selectedPaymentMethod, setSelectedPaymentMethod] = useState<PaymentMethodConfig | null>(null);
|
||||
|
||||
const [error, setError] = useState('');
|
||||
const [planDetails, setPlanDetails] = useState<any | null>(planDetailsProp || null);
|
||||
const [planLoading, setPlanLoading] = useState(planLoadingProp || false);
|
||||
const [planError, setPlanError] = useState('');
|
||||
const navigate = useNavigate();
|
||||
const { register, loading } = useAuthStore();
|
||||
|
||||
const planSlug = new URLSearchParams(window.location.search).get('plan') || '';
|
||||
const paidPlans = ['starter', 'growth', 'scale'];
|
||||
const isPaidPlan = planSlug && paidPlans.includes(planSlug);
|
||||
const totalSteps = isPaidPlan ? 3 : 1;
|
||||
|
||||
useEffect(() => {
|
||||
if (planDetailsProp) {
|
||||
setPlanDetails(planDetailsProp);
|
||||
setPlanLoading(!!planLoadingProp);
|
||||
setPlanError('');
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchPlan = async () => {
|
||||
if (!planSlug) return;
|
||||
setPlanLoading(true);
|
||||
setPlanError('');
|
||||
try {
|
||||
const API_BASE_URL = import.meta.env.VITE_BACKEND_URL || 'https://api.igny8.com/api';
|
||||
const res = await fetch(`${API_BASE_URL}/v1/auth/plans/?slug=${planSlug}`);
|
||||
const data = await res.json();
|
||||
const plan = data?.results?.[0];
|
||||
if (!plan) {
|
||||
setPlanError('Plan not found or inactive.');
|
||||
} else {
|
||||
const features = Array.isArray(plan.features)
|
||||
? plan.features.map((f: string) => f.charAt(0).toUpperCase() + f.slice(1))
|
||||
: [];
|
||||
setPlanDetails({ ...plan, features });
|
||||
}
|
||||
} catch (e: any) {
|
||||
setPlanError('Unable to load plan details right now.');
|
||||
} finally {
|
||||
setPlanLoading(false);
|
||||
}
|
||||
};
|
||||
fetchPlan();
|
||||
}, [planSlug, planDetailsProp, planLoadingProp]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleBillingChange = (field: keyof BillingFormData, value: string) => {
|
||||
setBillingData((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleNextStep = () => {
|
||||
setError('');
|
||||
|
||||
if (currentStep === 1) {
|
||||
// Validate step 1
|
||||
if (!formData.email || !formData.password || !formData.firstName || !formData.lastName) {
|
||||
setError('Please fill in all required fields');
|
||||
return;
|
||||
}
|
||||
if (!isChecked) {
|
||||
setError('Please agree to the Terms and Conditions');
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-fill billing email if not set
|
||||
if (isPaidPlan && !billingData.billing_email) {
|
||||
setBillingData((prev) => ({ ...prev, billing_email: formData.email }));
|
||||
}
|
||||
|
||||
setCurrentStep(2);
|
||||
} else if (currentStep === 2) {
|
||||
// Validate step 2 (billing)
|
||||
if (!billingData.billing_email || !billingData.billing_address_line1 ||
|
||||
!billingData.billing_city || !billingData.billing_state ||
|
||||
!billingData.billing_postal_code || !billingData.billing_country) {
|
||||
setError('Please fill in all required billing fields');
|
||||
return;
|
||||
}
|
||||
setCurrentStep(3);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrevStep = () => {
|
||||
setError('');
|
||||
setCurrentStep((prev) => Math.max(1, prev - 1));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
// Final validation
|
||||
if (!formData.email || !formData.password || !formData.firstName || !formData.lastName) {
|
||||
setError('Please fill in all required fields');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isChecked) {
|
||||
setError('Please agree to the Terms and Conditions');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate billing for paid plans
|
||||
if (isPaidPlan) {
|
||||
if (!billingData.billing_email || !billingData.billing_address_line1 ||
|
||||
!billingData.billing_city || !billingData.billing_state ||
|
||||
!billingData.billing_postal_code || !billingData.billing_country) {
|
||||
setError('Please fill in all required billing fields');
|
||||
setCurrentStep(2);
|
||||
return;
|
||||
}
|
||||
if (!selectedPaymentMethod) {
|
||||
setError('Please select a payment method');
|
||||
setCurrentStep(3);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const username = formData.username || formData.email.split('@')[0];
|
||||
|
||||
const registerPayload: any = {
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
username: username,
|
||||
first_name: formData.firstName,
|
||||
last_name: formData.lastName,
|
||||
account_name: formData.accountName,
|
||||
plan_slug: planSlug || undefined,
|
||||
};
|
||||
|
||||
// Add billing fields for paid plans
|
||||
if (isPaidPlan) {
|
||||
registerPayload.billing_email = billingData.billing_email;
|
||||
registerPayload.billing_address_line1 = billingData.billing_address_line1;
|
||||
registerPayload.billing_address_line2 = billingData.billing_address_line2 || undefined;
|
||||
registerPayload.billing_city = billingData.billing_city;
|
||||
registerPayload.billing_state = billingData.billing_state;
|
||||
registerPayload.billing_postal_code = billingData.billing_postal_code;
|
||||
registerPayload.billing_country = billingData.billing_country;
|
||||
registerPayload.tax_id = billingData.tax_id || undefined;
|
||||
registerPayload.payment_method = selectedPaymentMethod?.payment_method;
|
||||
}
|
||||
|
||||
const user = await register(registerPayload) as any;
|
||||
|
||||
const status = user?.account?.status;
|
||||
if (status === 'pending_payment') {
|
||||
navigate('/account/plans', { replace: true });
|
||||
} else {
|
||||
navigate('/sites', { replace: true });
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Registration failed. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
// Render step indicator
|
||||
const renderStepIndicator = () => {
|
||||
if (!isPaidPlan) return null;
|
||||
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between">
|
||||
{[1, 2, 3].map((step) => (
|
||||
<div key={step} className="flex items-center flex-1">
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className={`
|
||||
flex items-center justify-center w-10 h-10 rounded-full font-semibold
|
||||
${step === currentStep
|
||||
? 'bg-brand-500 text-white'
|
||||
: step < currentStep
|
||||
? 'bg-green-500 text-white'
|
||||
: 'bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-400'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{step < currentStep ? <Check className="w-5 h-5" /> : step}
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<div className={`text-sm font-medium ${step === currentStep ? 'text-gray-900 dark:text-white' : 'text-gray-500 dark:text-gray-400'}`}>
|
||||
{step === 1 ? 'Account' : step === 2 ? 'Billing' : 'Payment'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{step < 3 && (
|
||||
<div className={`flex-1 h-0.5 mx-4 ${step < currentStep ? 'bg-green-500' : 'bg-gray-200 dark:bg-gray-700'}`} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 w-full overflow-y-auto lg:w-1/2 no-scrollbar">
|
||||
<div className="w-full max-w-md mx-auto mb-5 sm:pt-10">
|
||||
<Link
|
||||
to="/"
|
||||
className="inline-flex items-center text-sm text-gray-500 transition-colors hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
|
||||
>
|
||||
<ChevronLeftIcon className="size-5" />
|
||||
Back to dashboard
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col justify-center flex-1 w-full max-w-md mx-auto">
|
||||
<div>
|
||||
<div className="mb-5 sm:mb-8">
|
||||
<h1 className="mb-2 font-semibold text-gray-800 text-title-sm dark:text-white/90 sm:text-title-md">
|
||||
{isPaidPlan ? `Sign Up for ${planDetails?.name || 'Paid'} Plan` : 'Start Your Free Trial'}
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{isPaidPlan
|
||||
? `Complete the ${totalSteps}-step process to activate your subscription.`
|
||||
: 'No credit card required. 1000 AI credits to get started.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{renderStepIndicator()}
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded-lg dark:bg-red-900/20 dark:text-red-400 dark:border-red-800">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
{/* Step 1: Basic Info */}
|
||||
{currentStep === 1 && (
|
||||
<div className="space-y-5">
|
||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label>
|
||||
First Name<span className="text-error-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
type="text"
|
||||
name="firstName"
|
||||
value={formData.firstName}
|
||||
onChange={handleChange}
|
||||
placeholder="Enter your first name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>
|
||||
Last Name<span className="text-error-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
type="text"
|
||||
name="lastName"
|
||||
value={formData.lastName}
|
||||
onChange={handleChange}
|
||||
placeholder="Enter your last name"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>
|
||||
Email<span className="text-error-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
type="email"
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
placeholder="Enter your email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Account Name (optional)</Label>
|
||||
<Input
|
||||
type="text"
|
||||
name="accountName"
|
||||
value={formData.accountName}
|
||||
onChange={handleChange}
|
||||
placeholder="Workspace / Company name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>
|
||||
Password<span className="text-error-500">*</span>
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
placeholder="Enter your password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
name="password"
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<span
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute z-30 -translate-y-1/2 cursor-pointer right-4 top-1/2"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeIcon className="fill-gray-500 dark:fill-gray-400 size-5" />
|
||||
) : (
|
||||
<EyeCloseIcon className="fill-gray-500 dark:fill-gray-400 size-5" />
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox className="w-5 h-5" checked={isChecked} onChange={setIsChecked} />
|
||||
<p className="inline-block font-normal text-gray-500 dark:text-gray-400">
|
||||
By creating an account means you agree to the{' '}
|
||||
<span className="text-gray-800 dark:text-white/90">Terms and Conditions,</span> and
|
||||
our <span className="text-gray-800 dark:text-white">Privacy Policy</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isPaidPlan ? (
|
||||
<Button type="button" variant="primary" onClick={handleNextStep} className="w-full">
|
||||
Continue to Billing
|
||||
<ChevronRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="submit" variant="primary" disabled={loading} className="w-full">
|
||||
{loading ? 'Creating your account...' : 'Start Free Trial'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Billing Info */}
|
||||
{currentStep === 2 && isPaidPlan && (
|
||||
<div className="space-y-5">
|
||||
<BillingFormStep
|
||||
formData={billingData}
|
||||
onChange={handleBillingChange}
|
||||
error={error}
|
||||
userEmail={formData.email}
|
||||
/>
|
||||
<div className="flex gap-3 pt-4">
|
||||
<Button type="button" variant="outline" onClick={handlePrevStep} className="flex-1">
|
||||
Back
|
||||
</Button>
|
||||
<Button type="button" variant="primary" onClick={handleNextStep} className="flex-1">
|
||||
Continue to Payment
|
||||
<ChevronRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Payment Method */}
|
||||
{currentStep === 3 && isPaidPlan && (
|
||||
<div className="space-y-5">
|
||||
<PaymentMethodSelect
|
||||
countryCode={billingData.billing_country}
|
||||
selectedMethod={selectedPaymentMethod?.payment_method || null}
|
||||
onSelectMethod={setSelectedPaymentMethod}
|
||||
error={error}
|
||||
/>
|
||||
<div className="flex gap-3 pt-4">
|
||||
<Button type="button" variant="outline" onClick={handlePrevStep} className="flex-1">
|
||||
Back
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" disabled={loading} className="flex-1">
|
||||
{loading ? 'Creating account...' : 'Complete Registration'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<div className="mt-5">
|
||||
<p className="text-sm font-normal text-center text-gray-700 dark:text-gray-400 sm:text-start">
|
||||
Already have an account?{' '}
|
||||
<Link to="/signin" className="text-brand-500 hover:text-brand-600 dark:text-brand-400">
|
||||
Sign In
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
229
frontend/src/components/billing/BillingFormStep.tsx
Normal file
229
frontend/src/components/billing/BillingFormStep.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* Billing Form Step Component
|
||||
* Collects billing information during paid signup flow
|
||||
* Integrates with RegisterSerializer backend fields
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import Label from '../form/Label';
|
||||
import Input from '../form/input/InputField';
|
||||
import SelectDropdown from '../form/SelectDropdown';
|
||||
|
||||
export interface BillingFormData {
|
||||
billing_email: string;
|
||||
billing_address_line1: string;
|
||||
billing_address_line2?: string;
|
||||
billing_city: string;
|
||||
billing_state: string;
|
||||
billing_postal_code: string;
|
||||
billing_country: string;
|
||||
tax_id?: string;
|
||||
}
|
||||
|
||||
interface BillingFormStepProps {
|
||||
formData: BillingFormData;
|
||||
onChange: (field: keyof BillingFormData, value: string) => void;
|
||||
error?: string;
|
||||
userEmail?: string; // Pre-fill billing email from user email
|
||||
}
|
||||
|
||||
// ISO 3166-1 alpha-2 country codes (subset of common countries)
|
||||
const COUNTRIES = [
|
||||
{ value: 'US', label: 'United States' },
|
||||
{ value: 'GB', label: 'United Kingdom' },
|
||||
{ value: 'CA', label: 'Canada' },
|
||||
{ value: 'AU', label: 'Australia' },
|
||||
{ value: 'IN', label: 'India' },
|
||||
{ value: 'PK', label: 'Pakistan' },
|
||||
{ value: 'DE', label: 'Germany' },
|
||||
{ value: 'FR', label: 'France' },
|
||||
{ value: 'ES', label: 'Spain' },
|
||||
{ value: 'IT', label: 'Italy' },
|
||||
{ value: 'NL', label: 'Netherlands' },
|
||||
{ value: 'SE', label: 'Sweden' },
|
||||
{ value: 'NO', label: 'Norway' },
|
||||
{ value: 'DK', label: 'Denmark' },
|
||||
{ value: 'FI', label: 'Finland' },
|
||||
{ value: 'BE', label: 'Belgium' },
|
||||
{ value: 'AT', label: 'Austria' },
|
||||
{ value: 'CH', label: 'Switzerland' },
|
||||
{ value: 'IE', label: 'Ireland' },
|
||||
{ value: 'NZ', label: 'New Zealand' },
|
||||
{ value: 'SG', label: 'Singapore' },
|
||||
{ value: 'AE', label: 'United Arab Emirates' },
|
||||
{ value: 'SA', label: 'Saudi Arabia' },
|
||||
{ value: 'ZA', label: 'South Africa' },
|
||||
{ value: 'BR', label: 'Brazil' },
|
||||
{ value: 'MX', label: 'Mexico' },
|
||||
{ value: 'AR', label: 'Argentina' },
|
||||
{ value: 'CL', label: 'Chile' },
|
||||
{ value: 'CO', label: 'Colombia' },
|
||||
{ value: 'JP', label: 'Japan' },
|
||||
{ value: 'KR', label: 'South Korea' },
|
||||
{ value: 'CN', label: 'China' },
|
||||
{ value: 'TH', label: 'Thailand' },
|
||||
{ value: 'MY', label: 'Malaysia' },
|
||||
{ value: 'ID', label: 'Indonesia' },
|
||||
{ value: 'PH', label: 'Philippines' },
|
||||
{ value: 'VN', label: 'Vietnam' },
|
||||
{ value: 'BD', label: 'Bangladesh' },
|
||||
{ value: 'LK', label: 'Sri Lanka' },
|
||||
{ value: 'EG', label: 'Egypt' },
|
||||
{ value: 'NG', label: 'Nigeria' },
|
||||
{ value: 'KE', label: 'Kenya' },
|
||||
{ value: 'GH', label: 'Ghana' },
|
||||
];
|
||||
|
||||
export default function BillingFormStep({
|
||||
formData,
|
||||
onChange,
|
||||
error,
|
||||
userEmail,
|
||||
}: BillingFormStepProps) {
|
||||
// Auto-fill billing email from user email if not already set
|
||||
if (userEmail && !formData.billing_email) {
|
||||
onChange('billing_email', userEmail);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{error && (
|
||||
<div className="p-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded-lg dark:bg-red-900/20 dark:text-red-400 dark:border-red-800">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-5">
|
||||
<h3 className="text-lg font-semibold text-gray-800 dark:text-white mb-2">
|
||||
Billing Information
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Enter your billing details to complete your subscription setup. This information will be used for invoicing.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Billing Email */}
|
||||
<div>
|
||||
<Label>
|
||||
Billing Email<span className="text-error-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
type="email"
|
||||
id="billing_email"
|
||||
name="billing_email"
|
||||
value={formData.billing_email}
|
||||
onChange={(e) => onChange('billing_email', e.target.value)}
|
||||
placeholder="billing@company.com"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Invoices will be sent to this email
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Address Line 1 */}
|
||||
<div>
|
||||
<Label>
|
||||
Street Address<span className="text-error-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
type="text"
|
||||
id="billing_address_line1"
|
||||
name="billing_address_line1"
|
||||
value={formData.billing_address_line1}
|
||||
onChange={(e) => onChange('billing_address_line1', e.target.value)}
|
||||
placeholder="123 Main Street"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Address Line 2 */}
|
||||
<div>
|
||||
<Label>Address Line 2 (Optional)</Label>
|
||||
<Input
|
||||
type="text"
|
||||
id="billing_address_line2"
|
||||
name="billing_address_line2"
|
||||
value={formData.billing_address_line2 || ''}
|
||||
onChange={(e) => onChange('billing_address_line2', e.target.value)}
|
||||
placeholder="Apartment, suite, etc."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* City, State, Postal Code */}
|
||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-3">
|
||||
<div>
|
||||
<Label>
|
||||
City<span className="text-error-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
type="text"
|
||||
id="billing_city"
|
||||
name="billing_city"
|
||||
value={formData.billing_city}
|
||||
onChange={(e) => onChange('billing_city', e.target.value)}
|
||||
placeholder="New York"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>
|
||||
State/Province<span className="text-error-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
type="text"
|
||||
id="billing_state"
|
||||
name="billing_state"
|
||||
value={formData.billing_state}
|
||||
onChange={(e) => onChange('billing_state', e.target.value)}
|
||||
placeholder="NY"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>
|
||||
Postal Code<span className="text-error-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
type="text"
|
||||
id="billing_postal_code"
|
||||
name="billing_postal_code"
|
||||
value={formData.billing_postal_code}
|
||||
onChange={(e) => onChange('billing_postal_code', e.target.value)}
|
||||
placeholder="10001"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Country */}
|
||||
<div>
|
||||
<Label>
|
||||
Country<span className="text-error-500">*</span>
|
||||
</Label>
|
||||
<SelectDropdown
|
||||
value={formData.billing_country}
|
||||
onChange={(value) => onChange('billing_country', value)}
|
||||
options={COUNTRIES}
|
||||
placeholder="Select country"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
2-letter ISO country code (e.g., US, GB, PK)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tax ID */}
|
||||
<div>
|
||||
<Label>Tax ID / VAT Number (Optional)</Label>
|
||||
<Input
|
||||
type="text"
|
||||
id="tax_id"
|
||||
name="tax_id"
|
||||
value={formData.tax_id || ''}
|
||||
onChange={(e) => onChange('tax_id', e.target.value)}
|
||||
placeholder="GB123456789"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Enter your VAT number, GST number, or other tax identifier if applicable
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
334
frontend/src/components/billing/PaymentConfirmationModal.tsx
Normal file
334
frontend/src/components/billing/PaymentConfirmationModal.tsx
Normal file
@@ -0,0 +1,334 @@
|
||||
/**
|
||||
* Payment Confirmation Modal
|
||||
* Allows users to submit manual payment confirmation details
|
||||
* Uploads proof of payment and submits to backend for admin approval
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Modal } from '../ui/modal';
|
||||
import Button from '../ui/button/Button';
|
||||
import Label from '../form/Label';
|
||||
import Input from '../form/input/InputField';
|
||||
import { Loader2, Upload, X, CheckCircle } from 'lucide-react';
|
||||
import { API_BASE_URL } from '../../services/api';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
|
||||
export interface PaymentConfirmationData {
|
||||
invoice_id: number;
|
||||
payment_method: string;
|
||||
amount: string;
|
||||
manual_reference: string;
|
||||
manual_notes?: string;
|
||||
proof_url?: string;
|
||||
}
|
||||
|
||||
interface PaymentConfirmationModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess?: () => void;
|
||||
invoice: {
|
||||
id: number;
|
||||
invoice_number: string;
|
||||
total_amount: string;
|
||||
currency?: string;
|
||||
};
|
||||
paymentMethod: {
|
||||
payment_method: string;
|
||||
display_name: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default function PaymentConfirmationModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess,
|
||||
invoice,
|
||||
paymentMethod,
|
||||
}: PaymentConfirmationModalProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
manual_reference: '',
|
||||
manual_notes: '',
|
||||
proof_url: '',
|
||||
});
|
||||
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
|
||||
const [uploadedFileName, setUploadedFileName] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState(false);
|
||||
const { user } = useAuthStore();
|
||||
|
||||
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// Validate file size (max 5MB)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
setError('File size must be less than 5MB');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
const allowedTypes = ['image/jpeg', 'image/png', 'image/jpg', 'application/pdf'];
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
setError('Only JPEG, PNG, and PDF files are allowed');
|
||||
return;
|
||||
}
|
||||
|
||||
setUploadedFile(file);
|
||||
setUploadedFileName(file.name);
|
||||
setError('');
|
||||
|
||||
// Simulate file upload to S3 (replace with actual S3 upload)
|
||||
// For now, we'll just store the file locally and pass a placeholder URL
|
||||
setUploading(true);
|
||||
try {
|
||||
// TODO: Implement actual S3 upload here
|
||||
// const uploadedUrl = await uploadToS3(file);
|
||||
// For demo, use a placeholder
|
||||
const placeholderUrl = `https://s3.amazonaws.com/igny8-payments/${Date.now()}-${file.name}`;
|
||||
setFormData({ ...formData, proof_url: placeholderUrl });
|
||||
} catch (err) {
|
||||
setError('Failed to upload file. Please try again.');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveFile = () => {
|
||||
setUploadedFile(null);
|
||||
setUploadedFileName('');
|
||||
setFormData({ ...formData, proof_url: '' });
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (!formData.manual_reference.trim()) {
|
||||
setError('Transaction reference is required');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const token = useAuthStore.getState().token;
|
||||
const response = await fetch(`${API_BASE_URL}/v1/billing/admin/payments/confirm/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token && { Authorization: `Bearer ${token}` }),
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
invoice_id: invoice.id,
|
||||
payment_method: paymentMethod.payment_method,
|
||||
amount: invoice.total_amount,
|
||||
manual_reference: formData.manual_reference.trim(),
|
||||
manual_notes: formData.manual_notes.trim() || undefined,
|
||||
proof_url: formData.proof_url || undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok || !data.success) {
|
||||
throw new Error(data.error || data.message || 'Failed to submit payment confirmation');
|
||||
}
|
||||
|
||||
setSuccess(true);
|
||||
|
||||
// Show success message for 2 seconds, then close and call onSuccess
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
onSuccess?.();
|
||||
// Reset form
|
||||
setFormData({ manual_reference: '', manual_notes: '', proof_url: '' });
|
||||
setUploadedFile(null);
|
||||
setUploadedFileName('');
|
||||
setSuccess(false);
|
||||
}, 2000);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to submit payment confirmation');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} className="max-w-2xl p-0">
|
||||
<div className="p-6 sm:p-8">
|
||||
{success ? (
|
||||
<div className="text-center py-8">
|
||||
<CheckCircle className="w-16 h-16 text-green-500 mx-auto mb-4" />
|
||||
<h3 className="text-2xl font-semibold text-gray-900 dark:text-white mb-2">
|
||||
Payment Submitted!
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Your payment confirmation has been submitted for admin approval.
|
||||
You'll receive an email once it's processed.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="text-2xl font-semibold text-gray-900 dark:text-white mb-2">
|
||||
Confirm Payment
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
|
||||
Submit proof of payment for invoice #{invoice.invoice_number}
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded-lg dark:bg-red-900/20 dark:text-red-400 dark:border-red-800">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Invoice Details */}
|
||||
<div className="mb-6 p-4 rounded-lg bg-gray-50 dark:bg-gray-800">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-600 dark:text-gray-400">Invoice Number:</span>
|
||||
<p className="font-semibold text-gray-900 dark:text-white">
|
||||
#{invoice.invoice_number}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-600 dark:text-gray-400">Amount:</span>
|
||||
<p className="font-semibold text-gray-900 dark:text-white">
|
||||
{invoice.currency || 'USD'} {invoice.total_amount}
|
||||
</p>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<span className="text-gray-600 dark:text-gray-400">Payment Method:</span>
|
||||
<p className="font-semibold text-gray-900 dark:text-white">
|
||||
{paymentMethod.display_name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{/* Transaction Reference */}
|
||||
<div>
|
||||
<Label>
|
||||
Transaction Reference / ID<span className="text-error-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
type="text"
|
||||
id="manual_reference"
|
||||
name="manual_reference"
|
||||
value={formData.manual_reference}
|
||||
onChange={(e) => setFormData({ ...formData, manual_reference: e.target.value })}
|
||||
placeholder="TXN123456789 or Bank Reference Number"
|
||||
disabled={loading}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Enter the transaction ID from your bank or payment receipt
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Additional Notes */}
|
||||
<div>
|
||||
<Label>Additional Notes (Optional)</Label>
|
||||
<textarea
|
||||
id="manual_notes"
|
||||
name="manual_notes"
|
||||
value={formData.manual_notes}
|
||||
onChange={(e) => setFormData({ ...formData, manual_notes: e.target.value })}
|
||||
placeholder="Any additional information about the payment..."
|
||||
rows={3}
|
||||
disabled={loading}
|
||||
className="w-full px-4 py-3 text-sm border border-gray-300 rounded-lg focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:focus:border-brand-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Proof Upload */}
|
||||
<div>
|
||||
<Label>Upload Proof of Payment (Optional)</Label>
|
||||
{!uploadedFileName ? (
|
||||
<div className="mt-2">
|
||||
<label
|
||||
htmlFor="proof-upload"
|
||||
className="flex flex-col items-center justify-center w-full h-32 border-2 border-dashed border-gray-300 rounded-lg cursor-pointer hover:border-brand-500 hover:bg-brand-50 dark:border-gray-700 dark:hover:border-brand-400 dark:hover:bg-brand-500/10 transition-colors"
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center pt-5 pb-6">
|
||||
<Upload className="w-10 h-10 mb-3 text-gray-400" />
|
||||
<p className="mb-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
<span className="font-semibold">Click to upload</span> or drag and drop
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
PNG, JPG or PDF (max. 5MB)
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
id="proof-upload"
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={handleFileSelect}
|
||||
accept=".jpg,.jpeg,.png,.pdf"
|
||||
disabled={loading || uploading}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 flex items-center justify-between p-3 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-800 dark:border-gray-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="w-5 h-5 text-green-500" />
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">
|
||||
{uploadedFileName}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRemoveFile}
|
||||
disabled={loading}
|
||||
className="p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{uploading && (
|
||||
<div className="mt-2 flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span>Uploading...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-3 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={loading}
|
||||
className="flex-1"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={loading || uploading}
|
||||
className="flex-1"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Submitting...
|
||||
</>
|
||||
) : (
|
||||
'Submit Payment Confirmation'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
209
frontend/src/components/billing/PaymentMethodSelect.tsx
Normal file
209
frontend/src/components/billing/PaymentMethodSelect.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Payment Method Select Component
|
||||
* Fetches and displays available payment methods based on country
|
||||
* Shows instructions for manual payment methods (bank transfer, wallets)
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { CheckCircle, Loader2, AlertCircle } from 'lucide-react';
|
||||
import { API_BASE_URL } from '../../services/api';
|
||||
|
||||
export interface PaymentMethodConfig {
|
||||
id: number;
|
||||
payment_method: string;
|
||||
display_name: string;
|
||||
instructions: string | null;
|
||||
country_code: string;
|
||||
is_enabled: boolean;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
interface PaymentMethodSelectProps {
|
||||
countryCode: string;
|
||||
selectedMethod: string | null;
|
||||
onSelectMethod: (method: PaymentMethodConfig) => void;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export default function PaymentMethodSelect({
|
||||
countryCode,
|
||||
selectedMethod,
|
||||
onSelectMethod,
|
||||
error,
|
||||
}: PaymentMethodSelectProps) {
|
||||
const [methods, setMethods] = useState<PaymentMethodConfig[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [fetchError, setFetchError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
loadPaymentMethods();
|
||||
}, [countryCode]);
|
||||
|
||||
const loadPaymentMethods = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setFetchError('');
|
||||
|
||||
const params = countryCode ? `?country=${countryCode}` : '';
|
||||
const response = await fetch(`${API_BASE_URL}/v1/billing/admin/payment-methods/${params}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok || !data.success) {
|
||||
throw new Error(data.error || 'Failed to load payment methods');
|
||||
}
|
||||
|
||||
const methodsList = data.results || data.data || [];
|
||||
setMethods(methodsList.filter((m: PaymentMethodConfig) => m.is_enabled));
|
||||
|
||||
// Auto-select first method if none selected
|
||||
if (methodsList.length > 0 && !selectedMethod) {
|
||||
onSelectMethod(methodsList[0]);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setFetchError(err.message || 'Failed to load payment methods');
|
||||
console.error('Payment methods fetch error:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-brand-500" />
|
||||
<span className="ml-3 text-gray-600 dark:text-gray-400">Loading payment methods...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (fetchError) {
|
||||
return (
|
||||
<div className="p-4 rounded-lg border border-red-200 bg-red-50 text-red-800 dark:border-red-800 dark:bg-red-900/20 dark:text-red-200">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium">Failed to load payment methods</p>
|
||||
<p className="text-sm mt-1">{fetchError}</p>
|
||||
<button
|
||||
onClick={loadPaymentMethods}
|
||||
className="mt-3 text-sm font-medium underline hover:no-underline"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (methods.length === 0) {
|
||||
return (
|
||||
<div className="p-4 rounded-lg border border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-800 dark:bg-amber-900/20 dark:text-amber-200">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium">No payment methods available</p>
|
||||
<p className="text-sm mt-1">
|
||||
{countryCode
|
||||
? `No payment methods configured for ${countryCode}. Global methods may be available.`
|
||||
: 'Please select a billing country to see available payment options.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded-lg dark:bg-red-900/20 dark:text-red-400 dark:border-red-800">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-800 dark:text-white mb-2">
|
||||
Select Payment Method
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{countryCode
|
||||
? `Available payment methods for ${countryCode} (${methods.length} option${methods.length !== 1 ? 's' : ''})`
|
||||
: `${methods.length} payment method${methods.length !== 1 ? 's' : ''} available`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{methods.map((method) => (
|
||||
<div
|
||||
key={method.id}
|
||||
onClick={() => onSelectMethod(method)}
|
||||
className={`
|
||||
relative p-4 rounded-lg border-2 cursor-pointer transition-all
|
||||
${
|
||||
selectedMethod === method.payment_method
|
||||
? 'border-brand-500 bg-brand-50 dark:bg-brand-500/10 dark:border-brand-400'
|
||||
: 'border-gray-200 bg-white hover:border-gray-300 dark:border-gray-700 dark:bg-gray-800 dark:hover:border-gray-600'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Radio indicator */}
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
{selectedMethod === method.payment_method ? (
|
||||
<CheckCircle className="w-5 h-5 text-brand-500 dark:text-brand-400" />
|
||||
) : (
|
||||
<div className="w-5 h-5 rounded-full border-2 border-gray-300 dark:border-gray-600" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Method details */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="font-semibold text-gray-900 dark:text-white">
|
||||
{method.display_name}
|
||||
</h4>
|
||||
{method.country_code !== '*' && (
|
||||
<span className="px-2 py-0.5 text-xs font-medium rounded bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300">
|
||||
{method.country_code}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Instructions for manual methods */}
|
||||
{method.instructions && (
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<p className="font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Payment Instructions:
|
||||
</p>
|
||||
<div className="whitespace-pre-wrap">{method.instructions}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Method type badge */}
|
||||
<div className="mt-2">
|
||||
<span className="inline-flex px-2 py-1 text-xs font-medium rounded bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300">
|
||||
{method.payment_method.replace(/_/g, ' ').toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 p-4 rounded-lg bg-blue-50 border border-blue-200 dark:bg-blue-900/20 dark:border-blue-800">
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||
<span className="font-medium">Note:</span> For manual payment methods (bank transfer, local wallets),
|
||||
you'll need to complete the payment and then submit proof of payment for admin approval.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { useHeaderMetrics } from "../context/HeaderMetricsContext";
|
||||
import { useErrorHandler } from "../hooks/useErrorHandler";
|
||||
import { trackLoading } from "../components/common/LoadingStateMonitor";
|
||||
import ResourceDebugOverlay from "../components/debug/ResourceDebugOverlay";
|
||||
import PendingPaymentBanner from "../components/billing/PendingPaymentBanner";
|
||||
|
||||
const LayoutContent: React.FC = () => {
|
||||
const { isExpanded, isHovered, isMobileOpen } = useSidebar();
|
||||
@@ -266,6 +267,8 @@ const LayoutContent: React.FC = () => {
|
||||
} ${isMobileOpen ? "ml-0" : ""} w-full max-w-full min-[1440px]:max-w-[90%]`}
|
||||
>
|
||||
<AppHeader />
|
||||
{/* Pending Payment Banner - Shows when account status is 'pending_payment' */}
|
||||
<PendingPaymentBanner className="mx-4 mt-4 md:mx-6 md:mt-6" />
|
||||
<div className="p-4 pb-20 md:p-6 md:pb-24">
|
||||
<Outlet />
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import PageMeta from "../../components/common/PageMeta";
|
||||
import AuthLayout from "./AuthPageLayout";
|
||||
import SignUpForm from "../../components/auth/SignUpForm";
|
||||
import SignUpFormEnhanced from "../../components/auth/SignUpFormEnhanced";
|
||||
|
||||
export default function SignUp() {
|
||||
const planSlug = useMemo(() => {
|
||||
@@ -44,7 +44,7 @@ export default function SignUp() {
|
||||
description="This is React.js SignUp Tables Dashboard page for TailAdmin - React.js Tailwind CSS Admin Dashboard Template"
|
||||
/>
|
||||
<AuthLayout plan={planDetails}>
|
||||
<SignUpForm planDetails={planDetails} planLoading={planLoading} />
|
||||
<SignUpFormEnhanced planDetails={planDetails} planLoading={planLoading} />
|
||||
</AuthLayout>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -701,6 +701,35 @@ export async function getPaymentMethodConfigs(): Promise<{
|
||||
return fetchAPI('/v1/billing/payment-methods/available/');
|
||||
}
|
||||
|
||||
// Get payment methods for a specific country
|
||||
export async function getPaymentMethodsByCountry(countryCode?: string): Promise<{
|
||||
success: boolean;
|
||||
results: PaymentMethodConfig[];
|
||||
count: number;
|
||||
}> {
|
||||
const params = countryCode ? `?country=${countryCode}` : '';
|
||||
return fetchAPI(`/v1/billing/admin/payment-methods/${params}`);
|
||||
}
|
||||
|
||||
// Confirm manual payment (submit proof of payment)
|
||||
export async function confirmPayment(data: {
|
||||
invoice_id: number;
|
||||
payment_method: string;
|
||||
amount: string;
|
||||
manual_reference: string;
|
||||
manual_notes?: string;
|
||||
proof_url?: string;
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
payment: Payment;
|
||||
}> {
|
||||
return fetchAPI('/v1/billing/admin/payments/confirm/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
export async function createManualPayment(data: {
|
||||
invoice_id?: number;
|
||||
amount: string;
|
||||
|
||||
Reference in New Issue
Block a user