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,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
|
||||||
@@ -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"
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { useTheme } from '@/hooks/useTheme'
|
import { useTheme } from '@/hooks/useTheme'
|
||||||
import { useLocale } from '@/contexts/LocaleContext'
|
import { useLocale } from '@/contexts/LocaleContext'
|
||||||
import { Sun, Moon, Monitor, Globe } from 'lucide-react'
|
import { SupportModal } from '@/components/SupportModal'
|
||||||
|
import { Sun, Moon, Monitor, Globe, Heart } from 'lucide-react'
|
||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
const { theme, isDark, toggleTheme } = useTheme()
|
const { theme, isDark, toggleTheme } = useTheme()
|
||||||
const { locale, toggleLocale } = useLocale()
|
const { locale, toggleLocale, t } = useLocale()
|
||||||
|
const [isSupportModalOpen, setIsSupportModalOpen] = useState(false)
|
||||||
|
|
||||||
const getThemeIcon = () => {
|
const getThemeIcon = () => {
|
||||||
switch (theme) {
|
switch (theme) {
|
||||||
@@ -32,7 +35,8 @@ export function Header() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
<>
|
||||||
|
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||||
<div className="container flex h-14 items-center justify-between">
|
<div className="container flex h-14 items-center justify-between">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
<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>
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
|
{/* Support Button */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsSupportModalOpen(true)}
|
||||||
|
className="w-full justify-start text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/20"
|
||||||
|
>
|
||||||
|
<Heart className="h-4 w-4" />
|
||||||
|
<span className="ml-2 hidden sm:inline">
|
||||||
|
{t('support.title')}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
{/* Theme Toggle */}
|
{/* Theme Toggle */}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -72,5 +89,12 @@ export function Header() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{/* Support Modal */}
|
||||||
|
<SupportModal
|
||||||
|
isOpen={isSupportModalOpen}
|
||||||
|
onClose={() => setIsSupportModalOpen(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+112
-130
@@ -5,77 +5,52 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
import { apiClient } from '@/lib/api/client'
|
|
||||||
import { useLocale } from '@/contexts/LocaleContext'
|
import { useLocale } from '@/contexts/LocaleContext'
|
||||||
import { Package, FilterOptions, Platform } from '@/types'
|
import { PackageIcon, Search, Monitor, Terminal } from 'lucide-react'
|
||||||
import { Search, Package as PackageIcon, Terminal, Monitor } from 'lucide-react'
|
|
||||||
|
|
||||||
interface PackageBrowserProps {
|
interface Package {
|
||||||
selectedPlatform: Platform | null
|
id: string
|
||||||
selectedPackages: Package[]
|
name: string
|
||||||
onPackageToggle: (pkg: Package) => void
|
version: string
|
||||||
onFiltersChange: (filters: FilterOptions) => void
|
description: string
|
||||||
|
category: string
|
||||||
|
type: 'gui' | 'cli'
|
||||||
|
repository: 'official' | 'third-party'
|
||||||
|
license: string
|
||||||
|
tags: string[]
|
||||||
|
popularity?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PackageBrowser({
|
interface PackageBrowserProps {
|
||||||
selectedPlatform,
|
selectedPlatform: { id: string; name: string } | null
|
||||||
selectedPackages,
|
packages: Package[]
|
||||||
|
selectedPackages: Package[]
|
||||||
|
onPackageToggle: (pkg: Package) => void
|
||||||
|
loading?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PackageBrowser({
|
||||||
|
selectedPlatform,
|
||||||
|
packages,
|
||||||
|
selectedPackages,
|
||||||
onPackageToggle,
|
onPackageToggle,
|
||||||
onFiltersChange
|
loading = false
|
||||||
}: PackageBrowserProps) {
|
}: PackageBrowserProps) {
|
||||||
const { t } = useLocale()
|
const { t } = useLocale()
|
||||||
const [packages, setPackages] = useState<Package[]>([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [filters, setFilters] = useState<FilterOptions>({
|
const [filters, setFilters] = useState({
|
||||||
platform_id: selectedPlatform?.id || '',
|
categories: [] as string[],
|
||||||
type: '',
|
types: [] as ('gui' | 'cli')[],
|
||||||
repository: '',
|
repositories: [] as ('official' | 'third-party')[]
|
||||||
search: '',
|
|
||||||
limit: 50,
|
|
||||||
offset: 0
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Load packages when platform changes
|
const categories = useMemo(() => {
|
||||||
useEffect(() => {
|
const cats = new Set(packages.map(pkg => pkg.category))
|
||||||
const loadPackages = async () => {
|
return Array.from(cats).sort()
|
||||||
if (!selectedPlatform) {
|
}, [packages])
|
||||||
setPackages([])
|
|
||||||
setLoading(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true)
|
|
||||||
try {
|
|
||||||
const result = await apiClient.getPackages({
|
|
||||||
platform_id: selectedPlatform.id,
|
|
||||||
search: searchQuery,
|
|
||||||
type: filters.type || undefined,
|
|
||||||
repository: filters.repository || undefined,
|
|
||||||
limit: filters.limit,
|
|
||||||
offset: filters.offset
|
|
||||||
})
|
|
||||||
setPackages(result.packages)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load packages:', error)
|
|
||||||
// Fallback to mock data if API fails
|
|
||||||
const { mockPackages } = await import('@/data/mockData')
|
|
||||||
setPackages(mockPackages.filter(pkg => pkg.platform === selectedPlatform.id))
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loadPackages()
|
|
||||||
}, [selectedPlatform, searchQuery, filters.type, filters.repository])
|
|
||||||
|
|
||||||
const filteredPackages = useMemo(() => {
|
const filteredPackages = useMemo(() => {
|
||||||
return packages.filter(pkg => {
|
return packages.filter(pkg => {
|
||||||
// Platform filter
|
|
||||||
if (selectedPlatform && pkg.platform !== selectedPlatform.id) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Search filter
|
// Search filter
|
||||||
if (searchQuery && !pkg.name.toLowerCase().includes(searchQuery.toLowerCase()) &&
|
if (searchQuery && !pkg.name.toLowerCase().includes(searchQuery.toLowerCase()) &&
|
||||||
!pkg.description.toLowerCase().includes(searchQuery.toLowerCase())) {
|
!pkg.description.toLowerCase().includes(searchQuery.toLowerCase())) {
|
||||||
@@ -87,11 +62,6 @@ export function PackageBrowser({
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// License filter
|
|
||||||
if (filters.licenses.length > 0 && !filters.licenses.includes(pkg.license)) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Type filter
|
// Type filter
|
||||||
if (filters.types.length > 0 && !filters.types.includes(pkg.type)) {
|
if (filters.types.length > 0 && !filters.types.includes(pkg.type)) {
|
||||||
return false
|
return false
|
||||||
@@ -104,12 +74,13 @@ export function PackageBrowser({
|
|||||||
|
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
}, [selectedPlatform, searchQuery, filters])
|
}, [packages, searchQuery, filters])
|
||||||
|
|
||||||
const handleFilterChange = (key: keyof FilterOptions, value: any) => {
|
const handleFilterChange = (filterType: keyof typeof filters, values: string[]) => {
|
||||||
const newFilters = { ...filters, [key]: value }
|
setFilters(prev => ({
|
||||||
setFilters(newFilters)
|
...prev,
|
||||||
onFiltersChange(newFilters)
|
[filterType]: values
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
const isPackageSelected = (pkg: Package) => {
|
const isPackageSelected = (pkg: Package) => {
|
||||||
@@ -126,64 +97,20 @@ export function PackageBrowser({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
if (loading) {
|
||||||
<div className="space-y-6">
|
return (
|
||||||
{/* Filters */}
|
<Card className="w-full">
|
||||||
<Card>
|
<CardHeader className="pb-4">
|
||||||
<CardHeader>
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
<CardTitle>Filters</CardTitle>
|
<PackageIcon className="h-5 w-5" />
|
||||||
</CardHeader>
|
{t('packages.title')}
|
||||||
<CardContent>
|
{selectedPlatform && (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
<span className="text-sm font-normal text-muted-foreground">
|
||||||
{/* Search */}
|
({packages.length} packages for {selectedPlatform.name})
|
||||||
<div className="relative">
|
</span>
|
||||||
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
)}
|
||||||
<input
|
</CardTitle>
|
||||||
type="text"
|
<CardDescription className="text-sm">
|
||||||
placeholder={t('packages.search')}
|
|
||||||
className="w-full pl-10 pr-4 py-2 border border-input rounded-md bg-background"
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Category Filter */}
|
|
||||||
<Select onValueChange={(value) =>
|
|
||||||
handleFilterChange('categories', value ? [value] : [])
|
|
||||||
}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder={t('packages.filters.category')} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{categories.map(category => (
|
|
||||||
<SelectItem key={category} value={category}>
|
|
||||||
{category}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
{/* Type Filter */}
|
|
||||||
<Select onValueChange={(value) =>
|
|
||||||
handleFilterChange('types', value ? [value as 'gui' | 'cli'] : [])
|
|
||||||
}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder={t('packages.filters.type')} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="gui">{t('packages.filters.gui')}</SelectItem>
|
|
||||||
<SelectItem value="cli">{t('packages.filters.cli')}</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
{/* Repository Filter */}
|
|
||||||
<Select onValueChange={(value) =>
|
|
||||||
handleFilterChange('repositories', value ? [value as 'official' | 'third-party'] : [])
|
|
||||||
}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder={t('packages.filters.repository')} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{t('packages.description')}
|
{t('packages.description')}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -230,10 +157,65 @@ export function PackageBrowser({
|
|||||||
className="w-full pl-10 pr-4 py-2 border border-input rounded-md bg-background"
|
className="w-full pl-10 pr-4 py-2 border border-input rounded-md bg-background"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category Filter */}
|
||||||
|
<Select onValueChange={(value) =>
|
||||||
|
handleFilterChange('categories', value ? [value] : [])
|
||||||
|
}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder={t('packages.filters.category')} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{categories.map((category: string) => (
|
||||||
|
<SelectItem key={category} value={category}>
|
||||||
|
{category}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{/* Type Filter */}
|
||||||
|
<Select onValueChange={(value) =>
|
||||||
|
handleFilterChange('types', value ? [value as 'gui' | 'cli'] : [])
|
||||||
|
}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder={t('packages.filters.type')} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="gui">{t('packages.filters.gui')}</SelectItem>
|
||||||
|
<SelectItem value="cli">{t('packages.filters.cli')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{/* Repository Filter */}
|
||||||
|
<Select onValueChange={(value) =>
|
||||||
|
handleFilterChange('repositories', value ? [value as 'official' | 'third-party'] : [])
|
||||||
|
}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder={t('packages.filters.repository')} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="official">{t('packages.filters.official')}</SelectItem>
|
||||||
|
<SelectItem value="third-party">{t('packages.filters.third_party')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Package List */}
|
||||||
|
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||||
|
{filteredPackages.map(pkg => (
|
||||||
|
<div
|
||||||
|
key={pkg.id}
|
||||||
|
className={`p-3 border rounded-lg cursor-pointer transition-colors ${
|
||||||
isPackageSelected(pkg)
|
isPackageSelected(pkg)
|
||||||
? 'border-primary bg-primary/5'
|
? 'border-primary bg-primary/5'
|
||||||
: 'border-border hover:bg-secondary/50'
|
: 'border-border hover:bg-secondary/50'
|
||||||
}`}
|
}`}
|
||||||
|
onClick={() => onPackageToggle(pkg)}
|
||||||
>
|
>
|
||||||
<div className="flex items-start space-x-3">
|
<div className="flex items-start space-x-3">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -264,7 +246,7 @@ export function PackageBrowser({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-1 mt-2">
|
<div className="flex flex-wrap gap-1 mt-2">
|
||||||
{pkg.tags.map(tag => (
|
{(pkg.tags || []).map(tag => (
|
||||||
<span
|
<span
|
||||||
key={tag}
|
key={tag}
|
||||||
className="text-xs bg-secondary text-secondary-foreground px-2 py-1 rounded"
|
className="text-xs bg-secondary text-secondary-foreground px-2 py-1 rounded"
|
||||||
@@ -284,8 +266,8 @@ export function PackageBrowser({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</CardContent>
|
||||||
</div>
|
</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",
|
store_link_label: "Store link",
|
||||||
direct_link_label: "Direct link"
|
direct_link_label: "Direct link"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
support: {
|
||||||
|
title: "Support Us",
|
||||||
|
subtitle: "Help keep RepoHub free and open source",
|
||||||
|
description: "Support RepoHub development",
|
||||||
|
amount: "Amount",
|
||||||
|
currency: "Cryptocurrency",
|
||||||
|
email_optional: "Email (optional)",
|
||||||
|
create_payment: "Create Payment",
|
||||||
|
creating: "Creating...",
|
||||||
|
payment_created: "Payment created successfully!",
|
||||||
|
payment_id: "Payment ID",
|
||||||
|
amount_to_pay: "Amount to Pay",
|
||||||
|
pay_now: "Pay Now",
|
||||||
|
payment_note: "You will be redirected to Cryptomus secure payment page",
|
||||||
|
create_another: "Create Another Payment",
|
||||||
|
secure_payment: "All payments are processed securely through Cryptomus",
|
||||||
|
thank_you: "Thank you for your support!",
|
||||||
|
success_title: "Thank You!",
|
||||||
|
success_message: "Your support helps us keep RepoHub free and continue development.",
|
||||||
|
success_note1: "Payment confirmation will be sent to your email if provided.",
|
||||||
|
success_note2: "You can close this page and return to RepoHub.",
|
||||||
|
back_to_site: "Back to RepoHub"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
tr: {
|
tr: {
|
||||||
@@ -159,6 +182,29 @@ const translations = {
|
|||||||
store_link_label: "Mağaza bağlantısı",
|
store_link_label: "Mağaza bağlantısı",
|
||||||
direct_link_label: "Doğrudan bağlantı"
|
direct_link_label: "Doğrudan bağlantı"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
support: {
|
||||||
|
title: "Bizi Destekleyin",
|
||||||
|
subtitle: "RepoHub'ı ücretsiz ve açık kaynaklı tutmamıza yardım edin",
|
||||||
|
description: "RepoHub geliştirmesini destekleyin",
|
||||||
|
amount: "Tutar",
|
||||||
|
currency: "Kriptopara",
|
||||||
|
email_optional: "E-posta (isteğe bağlı)",
|
||||||
|
create_payment: "Ödeme Oluştur",
|
||||||
|
creating: "Oluşturuluyor...",
|
||||||
|
payment_created: "Ödeme başarıyla oluşturuldu!",
|
||||||
|
payment_id: "Ödeme ID",
|
||||||
|
amount_to_pay: "Ödenecek Tutar",
|
||||||
|
pay_now: "Şimdi Öde",
|
||||||
|
payment_note: "Cryptomus güvenli ödeme sayfasına yönlendirileceksiniz",
|
||||||
|
create_another: "Başka Ödeme Oluştur",
|
||||||
|
secure_payment: "Tüm ödemeler Cryptomus üzerinden güvenli bir şekilde işlenir",
|
||||||
|
thank_you: "Desteğiniz için teşekkürler!",
|
||||||
|
success_title: "Teşekkürler!",
|
||||||
|
success_message: "Desteğiniz RepoHub'ı ücretsiz tutmamıza ve geliştirmeye devam etmemize yardımcı olur.",
|
||||||
|
success_note1: "Ödeme onayı sağlandıysa e-postanıza gönderilecektir.",
|
||||||
|
success_note2: "Bu sayfayı kapatabilir ve RepoHub'a dönebilirsiniz.",
|
||||||
|
back_to_site: "RepoHub'a Geri Dön"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user