feat: Implement Stripe payment form and language switcher
- Added StripePaymentForm component for handling donations with Stripe integration. - Included customer information fields and payment processing logic. - Integrated language switcher component for multilingual support. - Configured i18n with multiple languages and corresponding translation files. - Added translation files for Arabic, German, English, Spanish, French, Portuguese, Russian, and Chinese.
This commit is contained in:
283
src/components/payments/StripePaymentForm.tsx
Normal file
283
src/components/payments/StripePaymentForm.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import {
|
||||
Elements,
|
||||
CardElement,
|
||||
useStripe,
|
||||
useElements
|
||||
} from '@stripe/react-stripe-js'
|
||||
import { loadStripe } from '@stripe/stripe-js'
|
||||
import {
|
||||
CreditCard,
|
||||
Lock,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
Heart,
|
||||
Users,
|
||||
Sparkles
|
||||
} from 'lucide-react'
|
||||
|
||||
// Initialize Stripe
|
||||
const stripePromise = loadStripe(process.env.REACT_APP_STRIPE_PUBLISHABLE_KEY || '')
|
||||
|
||||
interface PaymentFormProps {
|
||||
amount: number
|
||||
isRecurring?: boolean
|
||||
onSuccess?: (paymentIntent: any) => void
|
||||
onError?: (error: string) => void
|
||||
}
|
||||
|
||||
|
||||
|
||||
function PaymentForm({ amount, isRecurring = false, onSuccess, onError }: PaymentFormProps) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const stripe = useStripe()
|
||||
const elements = useElements()
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [paymentStatus, setPaymentStatus] = useState<'idle' | 'processing' | 'succeeded' | 'failed'>('idle')
|
||||
const [errorMessage, setErrorMessage] = useState('')
|
||||
const [customerInfo, setCustomerInfo] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
phone: ''
|
||||
})
|
||||
|
||||
const formatCurrency = (value: number) => {
|
||||
const formatter = new Intl.NumberFormat(i18n.language, {
|
||||
style: 'currency',
|
||||
currency: 'USD'
|
||||
})
|
||||
return formatter.format(value / 100) // Stripe uses cents
|
||||
}
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault()
|
||||
|
||||
if (!stripe || !elements) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
setPaymentStatus('processing')
|
||||
setErrorMessage('')
|
||||
|
||||
const cardElement = elements.getElement(CardElement)
|
||||
if (!cardElement) return
|
||||
|
||||
try {
|
||||
// Create payment intent
|
||||
const response = await fetch('/api/create-payment-intent', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
amount,
|
||||
currency: 'usd',
|
||||
recurring: isRecurring,
|
||||
customer: customerInfo
|
||||
})
|
||||
})
|
||||
|
||||
const { client_secret } = await response.json()
|
||||
|
||||
// Confirm payment
|
||||
const { error, paymentIntent } = await stripe.confirmCardPayment(client_secret, {
|
||||
payment_method: {
|
||||
card: cardElement,
|
||||
billing_details: {
|
||||
name: customerInfo.name,
|
||||
email: customerInfo.email
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (error) {
|
||||
setErrorMessage(error.message || t('donate.error'))
|
||||
setPaymentStatus('failed')
|
||||
onError?.(error.message || t('donate.error'))
|
||||
} else {
|
||||
setPaymentStatus('succeeded')
|
||||
onSuccess?.(paymentIntent)
|
||||
}
|
||||
} catch (error: any) {
|
||||
setErrorMessage(error.message || t('donate.error'))
|
||||
setPaymentStatus('failed')
|
||||
onError?.(error.message)
|
||||
}
|
||||
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
const cardElementOptions = {
|
||||
style: {
|
||||
base: {
|
||||
fontSize: '16px',
|
||||
color: '#424770',
|
||||
'::placeholder': {
|
||||
color: '#aab7c4'
|
||||
}
|
||||
}
|
||||
},
|
||||
hidePostalCode: false
|
||||
}
|
||||
|
||||
if (paymentStatus === 'succeeded') {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="text-center py-12"
|
||||
>
|
||||
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<CheckCircle2 className="w-8 h-8 text-green-600" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
{t('donate.success')}
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Your donation of {formatCurrency(amount)} will help students in need.
|
||||
</p>
|
||||
<div className="bg-gradient-to-r from-purple-50 to-blue-50 rounded-lg p-6">
|
||||
<div className="flex items-center justify-center gap-2 text-purple-600 mb-2">
|
||||
<Sparkles className="w-5 h-5" />
|
||||
<span className="font-semibold">Impact Preview</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">
|
||||
You've just provided school supplies for {Math.floor(amount / 2500)} students!
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Customer Information */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||
<Users className="w-5 h-5" />
|
||||
Donor Information
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Full Name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={customerInfo.name}
|
||||
onChange={(e) => setCustomerInfo(prev => ({ ...prev, name: e.target.value }))}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
placeholder="John Doe"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Email Address <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={customerInfo.email}
|
||||
onChange={(e) => setCustomerInfo(prev => ({ ...prev, email: e.target.value }))}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
placeholder="john@example.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Phone Number (Optional)
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={customerInfo.phone}
|
||||
onChange={(e) => setCustomerInfo(prev => ({ ...prev, phone: e.target.value }))}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
placeholder="(555) 123-4567"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment Information */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||
<CreditCard className="w-5 h-5" />
|
||||
Payment Information
|
||||
</h3>
|
||||
|
||||
<div className="p-4 border border-gray-300 rounded-lg">
|
||||
<CardElement options={cardElementOptions} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Donation Summary */}
|
||||
<div className="bg-gradient-to-r from-purple-50 to-blue-50 rounded-lg p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="text-lg font-semibold text-gray-900">
|
||||
{isRecurring ? 'Monthly' : 'One-time'} Donation
|
||||
</span>
|
||||
<span className="text-2xl font-bold text-purple-600">
|
||||
{formatCurrency(amount)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<Lock className="w-4 h-4" />
|
||||
<span>Secure payment powered by Stripe</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
<AnimatePresence>
|
||||
{errorMessage && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center gap-3"
|
||||
>
|
||||
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0" />
|
||||
<p className="text-red-700 text-sm">{errorMessage}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!stripe || isLoading || paymentStatus === 'processing'}
|
||||
className="w-full bg-gradient-to-r from-purple-600 to-blue-600 text-white py-4 px-6 rounded-lg font-semibold text-lg hover:from-purple-700 hover:to-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
{t('donate.processing')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Heart className="w-5 h-5" />
|
||||
Donate {formatCurrency(amount)}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export function StripePaymentForm(props: PaymentFormProps) {
|
||||
return (
|
||||
<Elements stripe={stripePromise}>
|
||||
<PaymentForm {...props} />
|
||||
</Elements>
|
||||
)
|
||||
}
|
||||
|
||||
export default StripePaymentForm
|
||||
226
src/components/ui/LanguageSwitcher.tsx
Normal file
226
src/components/ui/LanguageSwitcher.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Globe, ChevronDown } from 'lucide-react'
|
||||
import { languages } from '@/i18n/config'
|
||||
|
||||
interface LanguageSwitcherProps {
|
||||
className?: string
|
||||
variant?: 'default' | 'minimal' | 'mobile'
|
||||
}
|
||||
|
||||
export function LanguageSwitcher({
|
||||
className = '',
|
||||
variant = 'default'
|
||||
}: LanguageSwitcherProps) {
|
||||
const { i18n, t } = useTranslation()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const currentLang = i18n.language || 'en'
|
||||
const currentLanguage = languages[currentLang as keyof typeof languages]
|
||||
|
||||
const handleLanguageChange = (langCode: string) => {
|
||||
i18n.changeLanguage(langCode)
|
||||
setIsOpen(false)
|
||||
|
||||
// Update document direction for RTL languages
|
||||
const langConfig = languages[langCode as keyof typeof languages]
|
||||
document.documentElement.dir = langConfig.dir
|
||||
document.documentElement.lang = langCode
|
||||
|
||||
// Store preference
|
||||
localStorage.setItem('i18nextLng', langCode)
|
||||
}
|
||||
|
||||
const dropdownVariants = {
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
scale: 0.95,
|
||||
y: -10
|
||||
},
|
||||
visible: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 0.2,
|
||||
ease: 'easeOut'
|
||||
}
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
scale: 0.95,
|
||||
y: -10,
|
||||
transition: {
|
||||
duration: 0.15,
|
||||
ease: 'easeIn'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (variant === 'minimal') {
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex items-center gap-1 p-2 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
aria-label={t('accessibility.toggleLanguage')}
|
||||
>
|
||||
<span className="text-lg">{currentLanguage?.flag || '🌐'}</span>
|
||||
<span className="text-sm font-medium">{currentLang.toUpperCase()}</span>
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
variants={dropdownVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
className="absolute top-full right-0 mt-1 bg-white rounded-lg shadow-lg border border-gray-200 py-1 min-w-[140px] z-50"
|
||||
>
|
||||
{Object.entries(languages).map(([code, lang]) => (
|
||||
<button
|
||||
key={code}
|
||||
onClick={() => handleLanguageChange(code)}
|
||||
className={`w-full px-3 py-2 text-left hover:bg-gray-50 transition-colors flex items-center gap-2 ${
|
||||
code === currentLang ? 'bg-purple-50 text-purple-600' : 'text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span className="text-base">{lang.flag}</span>
|
||||
<span className="text-sm">{lang.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (variant === 'mobile') {
|
||||
return (
|
||||
<div className={`w-full ${className}`}>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="w-full flex items-center justify-between p-3 bg-white rounded-lg border border-gray-200 hover:border-purple-300 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Globe className="w-5 h-5 text-gray-500" />
|
||||
<div className="text-left">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
Language / Idioma
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{currentLanguage?.flag} {currentLanguage?.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDown className={`w-4 h-4 text-gray-500 transition-transform ${
|
||||
isOpen ? 'rotate-180' : ''
|
||||
}`} />
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
variants={dropdownVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
className="mt-2 bg-white rounded-lg shadow-lg border border-gray-200 overflow-hidden"
|
||||
>
|
||||
{Object.entries(languages).map(([code, lang]) => (
|
||||
<button
|
||||
key={code}
|
||||
onClick={() => handleLanguageChange(code)}
|
||||
className={`w-full px-4 py-3 text-left hover:bg-gray-50 transition-colors border-b border-gray-100 last:border-b-0 ${
|
||||
code === currentLang ? 'bg-purple-50 text-purple-600' : 'text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xl">{lang.flag}</span>
|
||||
<div>
|
||||
<div className="font-medium">{lang.name}</div>
|
||||
<div className="text-sm text-gray-500">{code.toUpperCase()}</div>
|
||||
</div>
|
||||
{code === currentLang && (
|
||||
<div className="ml-auto w-2 h-2 bg-purple-600 rounded-full" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Default variant
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-white rounded-lg border border-gray-200 hover:border-purple-300 transition-colors shadow-sm"
|
||||
aria-label={t('accessibility.toggleLanguage')}
|
||||
>
|
||||
<Globe className="w-4 h-4 text-gray-500" />
|
||||
<span className="text-lg">{currentLanguage?.flag || '🌐'}</span>
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
{currentLanguage?.name || 'English'}
|
||||
</span>
|
||||
<ChevronDown className={`w-4 h-4 text-gray-500 transition-transform ${
|
||||
isOpen ? 'rotate-180' : ''
|
||||
}`} />
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Dropdown */}
|
||||
<motion.div
|
||||
variants={dropdownVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
className="absolute top-full right-0 mt-2 bg-white rounded-lg shadow-xl border border-gray-200 py-2 min-w-[200px] z-50"
|
||||
>
|
||||
<div className="px-3 py-2 border-b border-gray-100">
|
||||
<div className="text-xs font-medium text-gray-500 uppercase tracking-wide">
|
||||
Select Language
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{Object.entries(languages).map(([code, lang]) => (
|
||||
<button
|
||||
key={code}
|
||||
onClick={() => handleLanguageChange(code)}
|
||||
className={`w-full px-4 py-2 text-left hover:bg-gray-50 transition-colors flex items-center gap-3 ${
|
||||
code === currentLang ? 'bg-purple-50 text-purple-600' : 'text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span className="text-xl">{lang.flag}</span>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{lang.name}</div>
|
||||
<div className="text-xs text-gray-500">{code.toUpperCase()}</div>
|
||||
</div>
|
||||
{code === currentLang && (
|
||||
<div className="w-2 h-2 bg-purple-600 rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LanguageSwitcher
|
||||
Reference in New Issue
Block a user