mirror of
https://github.com/yusufipk/RepoHub.git
synced 2026-09-11 10:36:07 +00:00
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:
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -81,6 +81,29 @@ const translations = {
|
||||
store_link_label: "Store 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: {
|
||||
@@ -159,6 +182,29 @@ const translations = {
|
||||
store_link_label: "Mağaza bağlantısı",
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user