diff --git a/.env.local.example b/.env.local.example
new file mode 100644
index 0000000..d475328
--- /dev/null
+++ b/.env.local.example
@@ -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
diff --git a/src/app/api/support/create-payment/route.ts b/src/app/api/support/create-payment/route.ts
new file mode 100644
index 0000000..e7e257b
--- /dev/null
+++ b/src/app/api/support/create-payment/route.ts
@@ -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 }
+ )
+ }
+}
diff --git a/src/app/api/support/webhook/route.ts b/src/app/api/support/webhook/route.ts
new file mode 100644
index 0000000..6733a10
--- /dev/null
+++ b/src/app/api/support/webhook/route.ts
@@ -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 })
+ }
+}
diff --git a/src/app/support/success/page.tsx b/src/app/support/success/page.tsx
new file mode 100644
index 0000000..1a9eaf5
--- /dev/null
+++ b/src/app/support/success/page.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+ {t('support.success_title')}
+
+
+
+
+ {t('support.success_message')}
+
+
+
+
{t('support.success_note1')}
+
{t('support.success_note2')}
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/components/Header.tsx b/src/components/Header.tsx
index dd925c5..8d0bc1b 100644
--- a/src/components/Header.tsx
+++ b/src/components/Header.tsx
@@ -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 (
-
+ <>
+
@@ -44,6 +48,19 @@ export function Header() {
+ {/* Support Button */}
+
+
{/* Theme Toggle */}
+
+ {/* Support Modal */}
+
setIsSupportModalOpen(false)}
+ />
+ >
)
}
diff --git a/src/components/PackageBrowser.tsx b/src/components/PackageBrowser.tsx
index d82d970..fa1a3a3 100644
--- a/src/components/PackageBrowser.tsx
+++ b/src/components/PackageBrowser.tsx
@@ -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([])
- const [loading, setLoading] = useState(true)
const [searchQuery, setSearchQuery] = useState('')
- const [filters, setFilters] = useState({
- 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 (
-
- {/* Filters */}
-
-
- Filters
-
-
-
- {/* Search */}
-
-
- setSearchQuery(e.target.value)}
- />
-
-
- {/* Category Filter */}
-
-
- {/* Type Filter */}
-
-
- {/* Repository Filter */}
-
+
+ {/* Category Filter */}
+
+
+ {/* Type Filter */}
+
+
+ {/* Repository Filter */}
+
+
+
+
+
+ {/* Package List */}
+
+ {filteredPackages.map(pkg => (
+
onPackageToggle(pkg)}
>
- {pkg.tags.map(tag => (
+ {(pkg.tags || []).map(tag => (
)}
-
-
-
+
+
+
)
}
diff --git a/src/components/SupportModal.tsx b/src/components/SupportModal.tsx
new file mode 100644
index 0000000..e708999
--- /dev/null
+++ b/src/components/SupportModal.tsx
@@ -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
(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) => {
+ 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) => {
+ e?.preventDefault()
+ resetForm()
+ onClose()
+ }
+
+ if (!isOpen) return null
+
+ return (
+
+
+
+
+
+
+
+ {t('support.title')}
+
+
+ {t('support.subtitle')}
+
+
+
+
+
+
+ {!payment ? (
+
+ ) : (
+
+
+
+ {t('support.payment_created')}
+
+
+
+
+
+ {payment.payment_id}
+
+
+
+
+
+
+ {amount} {currency}
+
+
+
+
+
+
+ {t('support.payment_note')}
+
+
+
+
+ )}
+
+
+
{t('support.secure_payment')}
+
{t('support.thank_you')}
+
+
+
+
+ )
+}
diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx
new file mode 100644
index 0000000..677d05f
--- /dev/null
+++ b/src/components/ui/input.tsx
@@ -0,0 +1,25 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+export interface InputProps
+ extends React.InputHTMLAttributes {}
+
+const Input = React.forwardRef(
+ ({ className, type, ...props }, ref) => {
+ return (
+
+ )
+ }
+)
+Input.displayName = "Input"
+
+export { Input }
diff --git a/src/components/ui/label.tsx b/src/components/ui/label.tsx
new file mode 100644
index 0000000..683faa7
--- /dev/null
+++ b/src/components/ui/label.tsx
@@ -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,
+ React.ComponentPropsWithoutRef &
+ VariantProps
+>(({ className, ...props }, ref) => (
+
+))
+Label.displayName = LabelPrimitive.Root.displayName
+
+export { Label }
diff --git a/src/contexts/LocaleContext.tsx b/src/contexts/LocaleContext.tsx
index 36142bf..ed2955f 100644
--- a/src/contexts/LocaleContext.tsx
+++ b/src/contexts/LocaleContext.tsx
@@ -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"
}
}
}