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
+27 -3
View File
@@ -1,13 +1,16 @@
"use client"
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { useTheme } from '@/hooks/useTheme'
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() {
const { theme, isDark, toggleTheme } = useTheme()
const { locale, toggleLocale } = useLocale()
const { locale, toggleLocale, t } = useLocale()
const [isSupportModalOpen, setIsSupportModalOpen] = useState(false)
const getThemeIcon = () => {
switch (theme) {
@@ -32,7 +35,8 @@ export function Header() {
}
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="flex items-center space-x-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
@@ -44,6 +48,19 @@ export function Header() {
</div>
<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 */}
<Button
variant="ghost"
@@ -72,5 +89,12 @@ export function Header() {
</div>
</div>
</header>
{/* Support Modal */}
<SupportModal
isOpen={isSupportModalOpen}
onClose={() => setIsSupportModalOpen(false)}
/>
</>
)
}
+112 -130
View File
@@ -5,77 +5,52 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { apiClient } from '@/lib/api/client'
import { useLocale } from '@/contexts/LocaleContext'
import { Package, FilterOptions, Platform } from '@/types'
import { Search, Package as PackageIcon, Terminal, Monitor } from 'lucide-react'
import { PackageIcon, Search, Monitor, Terminal } from 'lucide-react'
interface PackageBrowserProps {
selectedPlatform: Platform | null
selectedPackages: Package[]
onPackageToggle: (pkg: Package) => void
onFiltersChange: (filters: FilterOptions) => void
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
}
export function PackageBrowser({
selectedPlatform,
selectedPackages,
interface PackageBrowserProps {
selectedPlatform: { id: string; name: string } | null
packages: Package[]
selectedPackages: Package[]
onPackageToggle: (pkg: Package) => void
loading?: boolean
}
export function PackageBrowser({
selectedPlatform,
packages,
selectedPackages,
onPackageToggle,
onFiltersChange
loading = false
}: PackageBrowserProps) {
const { t } = useLocale()
const [packages, setPackages] = useState<Package[]>([])
const [loading, setLoading] = useState(true)
const [searchQuery, setSearchQuery] = useState('')
const [filters, setFilters] = useState<FilterOptions>({
platform_id: selectedPlatform?.id || '',
type: '',
repository: '',
search: '',
limit: 50,
offset: 0
const [filters, setFilters] = useState({
categories: [] as string[],
types: [] as ('gui' | 'cli')[],
repositories: [] as ('official' | 'third-party')[]
})
// Load packages when platform changes
useEffect(() => {
const loadPackages = async () => {
if (!selectedPlatform) {
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 categories = useMemo(() => {
const cats = new Set(packages.map(pkg => pkg.category))
return Array.from(cats).sort()
}, [packages])
const filteredPackages = useMemo(() => {
return packages.filter(pkg => {
// Platform filter
if (selectedPlatform && pkg.platform !== selectedPlatform.id) {
return false
}
// Search filter
if (searchQuery && !pkg.name.toLowerCase().includes(searchQuery.toLowerCase()) &&
!pkg.description.toLowerCase().includes(searchQuery.toLowerCase())) {
@@ -87,11 +62,6 @@ export function PackageBrowser({
return false
}
// License filter
if (filters.licenses.length > 0 && !filters.licenses.includes(pkg.license)) {
return false
}
// Type filter
if (filters.types.length > 0 && !filters.types.includes(pkg.type)) {
return false
@@ -104,12 +74,13 @@ export function PackageBrowser({
return true
})
}, [selectedPlatform, searchQuery, filters])
}, [packages, searchQuery, filters])
const handleFilterChange = (key: keyof FilterOptions, value: any) => {
const newFilters = { ...filters, [key]: value }
setFilters(newFilters)
onFiltersChange(newFilters)
const handleFilterChange = (filterType: keyof typeof filters, values: string[]) => {
setFilters(prev => ({
...prev,
[filterType]: values
}))
}
const isPackageSelected = (pkg: Package) => {
@@ -126,64 +97,20 @@ export function PackageBrowser({
)
}
return (
<div className="space-y-6">
{/* Filters */}
<Card>
<CardHeader>
<CardTitle>Filters</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<input
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>
if (loading) {
return (
<Card className="w-full">
<CardHeader className="pb-4">
<CardTitle className="text-lg flex items-center gap-2">
<PackageIcon className="h-5 w-5" />
{t('packages.title')}
{selectedPlatform && (
<span className="text-sm font-normal text-muted-foreground">
({packages.length} packages for {selectedPlatform.name})
</span>
)}
</CardTitle>
<CardDescription className="text-sm">
{t('packages.description')}
</CardDescription>
</CardHeader>
@@ -230,10 +157,65 @@ export function PackageBrowser({
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: 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)
? 'border-primary bg-primary/5'
: 'border-border hover:bg-secondary/50'
}`}
onClick={() => onPackageToggle(pkg)}
>
<div className="flex items-start space-x-3">
<Checkbox
@@ -264,7 +246,7 @@ export function PackageBrowser({
)}
</div>
<div className="flex flex-wrap gap-1 mt-2">
{pkg.tags.map(tag => (
{(pkg.tags || []).map(tag => (
<span
key={tag}
className="text-xs bg-secondary text-secondary-foreground px-2 py-1 rounded"
@@ -284,8 +266,8 @@ export function PackageBrowser({
</div>
)}
</div>
</CardContent>
</Card>
</div>
</div>
</CardContent>
</Card>
)
}
+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 }