From 6d39c9b48e842d7521e87bd0d8548237f19e3750 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20=C4=B0pek?= Date: Tue, 11 Nov 2025 16:37:32 +0300 Subject: [PATCH] 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 --- .env.local.example | 7 + src/app/api/support/create-payment/route.ts | 136 +++++++++++ src/app/api/support/webhook/route.ts | 83 +++++++ src/app/support/success/page.tsx | 54 +++++ src/components/Header.tsx | 30 ++- src/components/PackageBrowser.tsx | 242 +++++++++----------- src/components/SupportModal.tsx | 226 ++++++++++++++++++ src/components/ui/input.tsx | 25 ++ src/components/ui/label.tsx | 24 ++ src/contexts/LocaleContext.tsx | 46 ++++ 10 files changed, 740 insertions(+), 133 deletions(-) create mode 100644 .env.local.example create mode 100644 src/app/api/support/create-payment/route.ts create mode 100644 src/app/api/support/webhook/route.ts create mode 100644 src/app/support/success/page.tsx create mode 100644 src/components/SupportModal.tsx create mode 100644 src/components/ui/input.tsx create mode 100644 src/components/ui/label.tsx 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 */} - + handleFilterChange('categories', value ? [value] : []) + }> + + + + + {categories.map((category: string) => ( + + {category} + + ))} + + + + {/* 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 ? ( +
+
+ + ) => setAmount(e.target.value)} + required + /> +
+ +
+ + +
+ +
+ + ) => setEmail(e.target.value)} + /> +
+ + {error && ( +
+ + {error} +
+ )} + + +
+ ) : ( +
+
+ + {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" } } }