feat: add cryptocurrency donation support with Cryptomus integration

- Added support modal component with payment creation flow and multi-language translations
- Integrated heart icon button in header to open donation modal
- Refactored PackageBrowser to accept packages as props and simplified filtering logic
This commit is contained in:
Yusuf İpek
2025-11-11 16:37:32 +03:00
parent 40a34d7133
commit 6d39c9b48e
10 changed files with 740 additions and 133 deletions
+7
View File
@@ -0,0 +1,7 @@
# Cryptomus API Configuration
# Get these from your Cryptomus merchant dashboard: https://cryptomus.com/merchant
CRYPTOMUS_MERCHANT_ID=your_merchant_id_here
CRYPTOMUS_PAYMENT_API_KEY=your_payment_api_key_here
# NextAuth URL Configuration
NEXTAUTH_URL=http://localhost:3002
+136
View File
@@ -0,0 +1,136 @@
import { NextRequest, NextResponse } from 'next/server'
const CRYPTOMUS_API_URL = 'https://api.cryptomus.com/v1/payment'
const MERCHANT_ID = process.env.CRYPTOMUS_MERCHANT_ID || ''
const PAYMENT_API_KEY = process.env.CRYPTOMUS_PAYMENT_API_KEY || ''
interface PaymentRequest {
amount: number
currency: string
order_id: string
description: string
email?: string
}
interface CryptomusResponse {
result: {
uuid: string
url: string
order_id: string
amount: string
currency: string
status: string
}
state: number
}
function generateSign(data: any, apiKey: string): string {
// Convert data to JSON and sort keys for consistent signature
const sortedData = Object.keys(data)
.sort()
.reduce((result: any, key: string) => {
result[key] = data[key]
return result
}, {})
const jsonString = JSON.stringify(sortedData)
// Create MD5 hash using Web Crypto API
const encoder = new TextEncoder()
const dataBuffer = encoder.encode(jsonString + apiKey)
// For server-side, we'll use Node.js crypto
const crypto = require('crypto')
return crypto.createHash('md5').update(jsonString + apiKey).digest('hex')
}
export async function POST(request: NextRequest) {
try {
if (!MERCHANT_ID || !PAYMENT_API_KEY) {
return NextResponse.json(
{ error: 'Payment service not configured' },
{ status: 500 }
)
}
const body: PaymentRequest = await request.json()
// Validate input
if (!body.amount || body.amount <= 0) {
return NextResponse.json(
{ error: 'Invalid amount' },
{ status: 400 }
)
}
if (!body.currency) {
return NextResponse.json(
{ error: 'Currency is required' },
{ status: 400 }
)
}
// Prepare data for Cryptomus
const paymentData = {
merchant_id: MERCHANT_ID,
amount: body.amount.toString(),
currency: body.currency,
order_id: body.order_id,
description: body.description,
url_callback: `${process.env.NEXTAUTH_URL || 'http://localhost:3002'}/api/support/webhook`,
url_success: `${process.env.NEXTAUTH_URL || 'http://localhost:3002'}/support/success`,
email: body.email || undefined,
lifetime: 3600, // 1 hour
is_payment_multiple: false
}
// Generate signature
const sign = generateSign(paymentData, PAYMENT_API_KEY)
// Make request to Cryptomus
const response = await fetch(CRYPTOMUS_API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'merchant': MERCHANT_ID,
'sign': sign
},
body: JSON.stringify(paymentData)
})
if (!response.ok) {
const errorData = await response.text()
console.error('Cryptomus API error:', errorData)
return NextResponse.json(
{ error: 'Payment service error' },
{ status: 500 }
)
}
const cryptomusResponse: CryptomusResponse = await response.json()
if (cryptomusResponse.state !== 0) {
return NextResponse.json(
{ error: 'Payment creation failed' },
{ status: 500 }
)
}
// Return payment data to client
return NextResponse.json({
payment_id: cryptomusResponse.result.uuid,
payment_url: cryptomusResponse.result.url,
order_id: cryptomusResponse.result.order_id,
amount: cryptomusResponse.result.amount,
currency: cryptomusResponse.result.currency,
status: cryptomusResponse.result.status
})
} catch (error) {
console.error('Payment creation error:', error)
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
)
}
}
+83
View File
@@ -0,0 +1,83 @@
import { NextRequest, NextResponse } from 'next/server'
const PAYMENT_API_KEY = process.env.CRYPTOMUS_PAYMENT_API_KEY || ''
interface WebhookData {
uuid: string
order_id: string
amount: string
currency: string
status: string
sign: string
}
function verifySign(data: any, apiKey: string): boolean {
// Extract sign from data and create a copy without it
const { sign: receivedSign, ...dataToVerify } = data
// Sort keys and create JSON string
const sortedData = Object.keys(dataToVerify)
.sort()
.reduce((result: any, key: string) => {
result[key] = dataToVerify[key]
return result
}, {})
const jsonString = JSON.stringify(sortedData)
// Generate expected signature
const crypto = require('crypto')
const expectedSign = crypto.createHash('md5').update(jsonString + apiKey).digest('hex')
return receivedSign === expectedSign
}
export async function POST(request: NextRequest) {
try {
if (!PAYMENT_API_KEY) {
console.error('Cryptomus API key not configured')
return NextResponse.json({ error: 'Service not configured' }, { status: 500 })
}
const body: WebhookData = await request.json()
// Verify webhook signature
if (!verifySign(body, PAYMENT_API_KEY)) {
console.error('Invalid webhook signature')
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 })
}
// Log payment status
console.log('Payment webhook received:', {
uuid: body.uuid,
order_id: body.order_id,
amount: body.amount,
currency: body.currency,
status: body.status,
timestamp: new Date().toISOString()
})
// Here you can:
// 1. Update your database with payment status
// 2. Send confirmation email
// 3. Notify administrators
// 4. Grant access to premium features
if (body.status === 'paid' || body.status === 'paid_over') {
console.log(`Payment successful: ${body.order_id} - ${body.amount} ${body.currency}`)
// TODO: Add your business logic here
// - Update user's support status
// - Send thank you email
// - Record in database
} else if (body.status === 'cancelled' || body.status === 'expired') {
console.log(`Payment cancelled/expired: ${body.order_id}`)
}
return NextResponse.json({ status: 'success' })
} catch (error) {
console.error('Webhook error:', error)
return NextResponse.json({ error: 'Webhook processing failed' }, { status: 500 })
}
}
+54
View File
@@ -0,0 +1,54 @@
"use client"
import { useEffect } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { CheckCircle, Heart, ArrowLeft } from 'lucide-react'
import { useLocale } from '@/contexts/LocaleContext'
import { useRouter } from 'next/navigation'
export default function SupportSuccessPage() {
const { t } = useLocale()
const router = useRouter()
useEffect(() => {
// You could verify payment status here if needed
console.log('Support payment success page loaded')
}, [])
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-gray-900 dark:to-gray-800 flex items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4 w-16 h-16 bg-green-100 rounded-full flex items-center justify-center">
<CheckCircle className="w-8 h-8 text-green-600" />
</div>
<CardTitle className="flex items-center justify-center space-x-2">
<Heart className="w-5 h-5 text-red-500" />
<span>{t('support.success_title')}</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-center">
<p className="text-muted-foreground">
{t('support.success_message')}
</p>
<div className="space-y-2 text-sm text-muted-foreground">
<p>{t('support.success_note1')}</p>
<p>{t('support.success_note2')}</p>
</div>
<div className="pt-4 space-y-2">
<Button
onClick={() => router.push('/')}
className="w-full"
>
<ArrowLeft className="w-4 h-4 mr-2" />
{t('support.back_to_site')}
</Button>
</div>
</CardContent>
</Card>
</div>
)
}
+26 -2
View File
@@ -1,13 +1,16 @@
"use client" "use client"
import { useState } from 'react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { useTheme } from '@/hooks/useTheme' import { useTheme } from '@/hooks/useTheme'
import { useLocale } from '@/contexts/LocaleContext' import { useLocale } from '@/contexts/LocaleContext'
import { Sun, Moon, Monitor, Globe } from 'lucide-react' import { SupportModal } from '@/components/SupportModal'
import { Sun, Moon, Monitor, Globe, Heart } from 'lucide-react'
export function Header() { export function Header() {
const { theme, isDark, toggleTheme } = useTheme() const { theme, isDark, toggleTheme } = useTheme()
const { locale, toggleLocale } = useLocale() const { locale, toggleLocale, t } = useLocale()
const [isSupportModalOpen, setIsSupportModalOpen] = useState(false)
const getThemeIcon = () => { const getThemeIcon = () => {
switch (theme) { switch (theme) {
@@ -32,6 +35,7 @@ export function Header() {
} }
return ( return (
<>
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"> <header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container flex h-14 items-center justify-between"> <div className="container flex h-14 items-center justify-between">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
@@ -44,6 +48,19 @@ export function Header() {
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
{/* Support Button */}
<Button
variant="ghost"
size="sm"
onClick={() => setIsSupportModalOpen(true)}
className="w-full justify-start text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/20"
>
<Heart className="h-4 w-4" />
<span className="ml-2 hidden sm:inline">
{t('support.title')}
</span>
</Button>
{/* Theme Toggle */} {/* Theme Toggle */}
<Button <Button
variant="ghost" variant="ghost"
@@ -72,5 +89,12 @@ export function Header() {
</div> </div>
</div> </div>
</header> </header>
{/* Support Modal */}
<SupportModal
isOpen={isSupportModalOpen}
onClose={() => setIsSupportModalOpen(false)}
/>
</>
) )
} }
+103 -121
View File
@@ -5,77 +5,52 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from '@/components/ui/checkbox'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { apiClient } from '@/lib/api/client'
import { useLocale } from '@/contexts/LocaleContext' import { useLocale } from '@/contexts/LocaleContext'
import { Package, FilterOptions, Platform } from '@/types' import { PackageIcon, Search, Monitor, Terminal } from 'lucide-react'
import { Search, Package as PackageIcon, Terminal, Monitor } from 'lucide-react'
interface Package {
id: string
name: string
version: string
description: string
category: string
type: 'gui' | 'cli'
repository: 'official' | 'third-party'
license: string
tags: string[]
popularity?: number
}
interface PackageBrowserProps { interface PackageBrowserProps {
selectedPlatform: Platform | null selectedPlatform: { id: string; name: string } | null
packages: Package[]
selectedPackages: Package[] selectedPackages: Package[]
onPackageToggle: (pkg: Package) => void onPackageToggle: (pkg: Package) => void
onFiltersChange: (filters: FilterOptions) => void loading?: boolean
} }
export function PackageBrowser({ export function PackageBrowser({
selectedPlatform, selectedPlatform,
packages,
selectedPackages, selectedPackages,
onPackageToggle, onPackageToggle,
onFiltersChange loading = false
}: PackageBrowserProps) { }: PackageBrowserProps) {
const { t } = useLocale() const { t } = useLocale()
const [packages, setPackages] = useState<Package[]>([])
const [loading, setLoading] = useState(true)
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const [filters, setFilters] = useState<FilterOptions>({ const [filters, setFilters] = useState({
platform_id: selectedPlatform?.id || '', categories: [] as string[],
type: '', types: [] as ('gui' | 'cli')[],
repository: '', repositories: [] as ('official' | 'third-party')[]
search: '',
limit: 50,
offset: 0
}) })
// Load packages when platform changes const categories = useMemo(() => {
useEffect(() => { const cats = new Set(packages.map(pkg => pkg.category))
const loadPackages = async () => { return Array.from(cats).sort()
if (!selectedPlatform) { }, [packages])
setPackages([])
setLoading(false)
return
}
setLoading(true)
try {
const result = await apiClient.getPackages({
platform_id: selectedPlatform.id,
search: searchQuery,
type: filters.type || undefined,
repository: filters.repository || undefined,
limit: filters.limit,
offset: filters.offset
})
setPackages(result.packages)
} catch (error) {
console.error('Failed to load packages:', error)
// Fallback to mock data if API fails
const { mockPackages } = await import('@/data/mockData')
setPackages(mockPackages.filter(pkg => pkg.platform === selectedPlatform.id))
} finally {
setLoading(false)
}
}
loadPackages()
}, [selectedPlatform, searchQuery, filters.type, filters.repository])
const filteredPackages = useMemo(() => { const filteredPackages = useMemo(() => {
return packages.filter(pkg => { return packages.filter(pkg => {
// Platform filter
if (selectedPlatform && pkg.platform !== selectedPlatform.id) {
return false
}
// Search filter // Search filter
if (searchQuery && !pkg.name.toLowerCase().includes(searchQuery.toLowerCase()) && if (searchQuery && !pkg.name.toLowerCase().includes(searchQuery.toLowerCase()) &&
!pkg.description.toLowerCase().includes(searchQuery.toLowerCase())) { !pkg.description.toLowerCase().includes(searchQuery.toLowerCase())) {
@@ -87,11 +62,6 @@ export function PackageBrowser({
return false return false
} }
// License filter
if (filters.licenses.length > 0 && !filters.licenses.includes(pkg.license)) {
return false
}
// Type filter // Type filter
if (filters.types.length > 0 && !filters.types.includes(pkg.type)) { if (filters.types.length > 0 && !filters.types.includes(pkg.type)) {
return false return false
@@ -104,12 +74,13 @@ export function PackageBrowser({
return true return true
}) })
}, [selectedPlatform, searchQuery, filters]) }, [packages, searchQuery, filters])
const handleFilterChange = (key: keyof FilterOptions, value: any) => { const handleFilterChange = (filterType: keyof typeof filters, values: string[]) => {
const newFilters = { ...filters, [key]: value } setFilters(prev => ({
setFilters(newFilters) ...prev,
onFiltersChange(newFilters) [filterType]: values
}))
} }
const isPackageSelected = (pkg: Package) => { const isPackageSelected = (pkg: Package) => {
@@ -126,64 +97,20 @@ export function PackageBrowser({
) )
} }
if (loading) {
return ( return (
<div className="space-y-6"> <Card className="w-full">
{/* Filters */} <CardHeader className="pb-4">
<Card> <CardTitle className="text-lg flex items-center gap-2">
<CardHeader> <PackageIcon className="h-5 w-5" />
<CardTitle>Filters</CardTitle> {t('packages.title')}
</CardHeader> {selectedPlatform && (
<CardContent> <span className="text-sm font-normal text-muted-foreground">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"> ({packages.length} packages for {selectedPlatform.name})
{/* Search */} </span>
<div className="relative"> )}
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" /> </CardTitle>
<input <CardDescription className="text-sm">
type="text"
placeholder={t('packages.search')}
className="w-full pl-10 pr-4 py-2 border border-input rounded-md bg-background"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
{/* Category Filter */}
<Select onValueChange={(value) =>
handleFilterChange('categories', value ? [value] : [])
}>
<SelectTrigger>
<SelectValue placeholder={t('packages.filters.category')} />
</SelectTrigger>
<SelectContent>
{categories.map(category => (
<SelectItem key={category} value={category}>
{category}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Type Filter */}
<Select onValueChange={(value) =>
handleFilterChange('types', value ? [value as 'gui' | 'cli'] : [])
}>
<SelectTrigger>
<SelectValue placeholder={t('packages.filters.type')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="gui">{t('packages.filters.gui')}</SelectItem>
<SelectItem value="cli">{t('packages.filters.cli')}</SelectItem>
</SelectContent>
</Select>
{/* Repository Filter */}
<Select onValueChange={(value) =>
handleFilterChange('repositories', value ? [value as 'official' | 'third-party'] : [])
}>
<SelectTrigger>
<SelectValue placeholder={t('packages.filters.repository')} />
</SelectTrigger>
<SelectContent>
{t('packages.description')} {t('packages.description')}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
@@ -230,10 +157,65 @@ export function PackageBrowser({
className="w-full pl-10 pr-4 py-2 border border-input rounded-md bg-background" className="w-full pl-10 pr-4 py-2 border border-input rounded-md bg-background"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
{/* Category Filter */}
<Select onValueChange={(value) =>
handleFilterChange('categories', value ? [value] : [])
}>
<SelectTrigger>
<SelectValue placeholder={t('packages.filters.category')} />
</SelectTrigger>
<SelectContent>
{categories.map((category: string) => (
<SelectItem key={category} value={category}>
{category}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Type Filter */}
<Select onValueChange={(value) =>
handleFilterChange('types', value ? [value as 'gui' | 'cli'] : [])
}>
<SelectTrigger>
<SelectValue placeholder={t('packages.filters.type')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="gui">{t('packages.filters.gui')}</SelectItem>
<SelectItem value="cli">{t('packages.filters.cli')}</SelectItem>
</SelectContent>
</Select>
{/* Repository Filter */}
<Select onValueChange={(value) =>
handleFilterChange('repositories', value ? [value as 'official' | 'third-party'] : [])
}>
<SelectTrigger>
<SelectValue placeholder={t('packages.filters.repository')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="official">{t('packages.filters.official')}</SelectItem>
<SelectItem value="third-party">{t('packages.filters.third_party')}</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
{/* Package List */}
<div className="space-y-2 max-h-96 overflow-y-auto">
{filteredPackages.map(pkg => (
<div
key={pkg.id}
className={`p-3 border rounded-lg cursor-pointer transition-colors ${
isPackageSelected(pkg) isPackageSelected(pkg)
? 'border-primary bg-primary/5' ? 'border-primary bg-primary/5'
: 'border-border hover:bg-secondary/50' : 'border-border hover:bg-secondary/50'
}`} }`}
onClick={() => onPackageToggle(pkg)}
> >
<div className="flex items-start space-x-3"> <div className="flex items-start space-x-3">
<Checkbox <Checkbox
@@ -264,7 +246,7 @@ export function PackageBrowser({
)} )}
</div> </div>
<div className="flex flex-wrap gap-1 mt-2"> <div className="flex flex-wrap gap-1 mt-2">
{pkg.tags.map(tag => ( {(pkg.tags || []).map(tag => (
<span <span
key={tag} key={tag}
className="text-xs bg-secondary text-secondary-foreground px-2 py-1 rounded" className="text-xs bg-secondary text-secondary-foreground px-2 py-1 rounded"
@@ -284,8 +266,8 @@ export function PackageBrowser({
</div> </div>
)} )}
</div> </div>
</div>
</CardContent> </CardContent>
</Card> </Card>
</div>
) )
} }
+226
View File
@@ -0,0 +1,226 @@
"use client"
import { useState } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { useLocale } from '@/contexts/LocaleContext'
import { Loader2, Heart, AlertCircle, CheckCircle } from 'lucide-react'
interface SupportModalProps {
isOpen: boolean
onClose: () => void
}
interface PaymentData {
payment_url: string
payment_id: string
}
export function SupportModal({ isOpen, onClose }: SupportModalProps) {
const [amount, setAmount] = useState('')
const [currency, setCurrency] = useState('USDT')
const [email, setEmail] = useState('')
const [loading, setLoading] = useState(false)
const [payment, setPayment] = useState<PaymentData | null>(null)
const [error, setError] = useState('')
const { t } = useLocale()
const cryptoOptions = [
{ value: 'USDT', label: 'USDT (TRC20)', network: 'TRC20' },
{ value: 'USDC', label: 'USDC (TRC20)', network: 'TRC20' },
{ value: 'BTC', label: 'Bitcoin', network: 'BTC' },
{ value: 'ETH', label: 'Ethereum', network: 'ETH' },
{ value: 'LTC', label: 'Litecoin', network: 'LTC' },
{ value: 'TRX', label: 'TRON', network: 'TRC20' }
]
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setLoading(true)
setError('')
setPayment(null)
try {
const response = await fetch('/api/support/create-payment', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
amount: parseFloat(amount),
currency,
order_id: `support_${Date.now()}`,
description: t('support.description'),
email: email || undefined
})
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Payment creation failed')
}
setPayment(data)
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error')
} finally {
setLoading(false)
}
}
const resetForm = () => {
setAmount('')
setCurrency('USDT')
setEmail('')
setPayment(null)
setError('')
}
const handleClose = (e?: React.MouseEvent<HTMLButtonElement>) => {
e?.preventDefault()
resetForm()
onClose()
}
if (!isOpen) return null
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
<Card className="w-full max-w-md">
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center space-x-2">
<Heart className="h-5 w-5 text-red-500" />
<span>{t('support.title')}</span>
</CardTitle>
<CardDescription>
{t('support.subtitle')}
</CardDescription>
</div>
<Button variant="outline" size="sm" onClick={handleClose}>
{t('common.close')}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{!payment ? (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="amount">{t('support.amount')}</Label>
<Input
id="amount"
type="number"
step="0.01"
min="1"
placeholder="10.00"
value={amount}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setAmount(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="currency">{t('support.currency')}</Label>
<Select value={currency} onValueChange={setCurrency}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{cryptoOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="email">{t('support.email_optional')}</Label>
<Input
id="email"
type="email"
placeholder="[email protected]"
value={email}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setEmail(e.target.value)}
/>
</div>
{error && (
<div className="flex items-center space-x-2 text-red-600 bg-red-50 p-3 rounded-lg">
<AlertCircle className="h-4 w-4" />
<span className="text-sm">{error}</span>
</div>
)}
<Button
type="submit"
className="w-full"
disabled={loading || !amount}
>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('support.creating')}
</>
) : (
t('support.create_payment')
)}
</Button>
</form>
) : (
<div className="space-y-4">
<div className="flex items-center space-x-2 text-green-600 bg-green-50 p-3 rounded-lg">
<CheckCircle className="h-4 w-4" />
<span className="text-sm">{t('support.payment_created')}</span>
</div>
<div className="space-y-2">
<Label>{t('support.payment_id')}</Label>
<div className="p-2 bg-muted rounded font-mono text-sm">
{payment.payment_id}
</div>
</div>
<div className="space-y-2">
<Label>{t('support.amount_to_pay')}</Label>
<div className="p-2 bg-muted rounded text-sm">
{amount} {currency}
</div>
</div>
<Button
onClick={() => window.open(payment.payment_url, '_blank')}
className="w-full"
>
{t('support.pay_now')}
</Button>
<div className="text-xs text-muted-foreground text-center">
{t('support.payment_note')}
</div>
<Button
variant="outline"
onClick={resetForm}
className="w-full"
>
{t('support.create_another')}
</Button>
</div>
)}
<div className="text-xs text-muted-foreground space-y-1">
<p>{t('support.secure_payment')}</p>
<p>{t('support.thank_you')}</p>
</div>
</CardContent>
</Card>
</div>
)
}
+25
View File
@@ -0,0 +1,25 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }
+24
View File
@@ -0,0 +1,24 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }
+46
View File
@@ -81,6 +81,29 @@ const translations = {
store_link_label: "Store link", store_link_label: "Store link",
direct_link_label: "Direct link" direct_link_label: "Direct link"
} }
},
support: {
title: "Support Us",
subtitle: "Help keep RepoHub free and open source",
description: "Support RepoHub development",
amount: "Amount",
currency: "Cryptocurrency",
email_optional: "Email (optional)",
create_payment: "Create Payment",
creating: "Creating...",
payment_created: "Payment created successfully!",
payment_id: "Payment ID",
amount_to_pay: "Amount to Pay",
pay_now: "Pay Now",
payment_note: "You will be redirected to Cryptomus secure payment page",
create_another: "Create Another Payment",
secure_payment: "All payments are processed securely through Cryptomus",
thank_you: "Thank you for your support!",
success_title: "Thank You!",
success_message: "Your support helps us keep RepoHub free and continue development.",
success_note1: "Payment confirmation will be sent to your email if provided.",
success_note2: "You can close this page and return to RepoHub.",
back_to_site: "Back to RepoHub"
} }
}, },
tr: { tr: {
@@ -159,6 +182,29 @@ const translations = {
store_link_label: "Mağaza bağlantısı", store_link_label: "Mağaza bağlantısı",
direct_link_label: "Doğrudan bağlantı" direct_link_label: "Doğrudan bağlantı"
} }
},
support: {
title: "Bizi Destekleyin",
subtitle: "RepoHub'ı ücretsiz ve açık kaynaklı tutmamıza yardım edin",
description: "RepoHub geliştirmesini destekleyin",
amount: "Tutar",
currency: "Kriptopara",
email_optional: "E-posta (isteğe bağlı)",
create_payment: "Ödeme Oluştur",
creating: "Oluşturuluyor...",
payment_created: "Ödeme başarıyla oluşturuldu!",
payment_id: "Ödeme ID",
amount_to_pay: "Ödenecek Tutar",
pay_now: "Şimdi Öde",
payment_note: "Cryptomus güvenli ödeme sayfasına yönlendirileceksiniz",
create_another: "Başka Ödeme Oluştur",
secure_payment: "Tüm ödemeler Cryptomus üzerinden güvenli bir şekilde işlenir",
thank_you: "Desteğiniz için teşekkürler!",
success_title: "Teşekkürler!",
success_message: "Desteğiniz RepoHub'ı ücretsiz tutmamıza ve geliştirmeye devam etmemize yardımcı olur.",
success_note1: "Ödeme onayı sağlandıysa e-postanıza gönderilecektir.",
success_note2: "Bu sayfayı kapatabilir ve RepoHub'a dönebilirsiniz.",
back_to_site: "RepoHub'a Geri Dön"
} }
} }
} }