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:
defiQUG
2025-10-05 10:05:03 -07:00
parent f64fdb561e
commit 37469c105c
15 changed files with 2416 additions and 24 deletions

View 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

View 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