profit-planet-frontend/src/app/admin/affiliate-management/page.tsx
seaznCode 20c71636f6 feat: Enhance subscription creation and editing with image cropping and improved UI
- Added image cropping functionality in CreateSubscriptionPage and EditSubscriptionPage.
- Updated price input to handle decimal values and formatting.
- Improved UI elements for image upload sections, including better messaging and styling.
- Refactored affiliate links page to fetch data from an API and handle loading/error states.
- Added Affiliate Management button in the header for easier navigation.
2025-12-06 20:29:58 +01:00

1003 lines
39 KiB
TypeScript

'use client'
import React, { useState } from 'react'
import Header from '../../components/nav/Header'
import Footer from '../../components/Footer'
import {
PlusIcon,
PencilIcon,
TrashIcon,
LinkIcon,
PhotoIcon,
XMarkIcon,
MagnifyingGlassIcon
} from '@heroicons/react/24/outline'
import useAuthStore from '../../store/authStore'
import { useRouter } from 'next/navigation'
import PageTransitionEffect from '../../components/animation/pageTransitionEffect'
import { useAdminAffiliates, type AdminAffiliate } from './hooks/getAffiliates'
import { addAffiliate } from './hooks/addAffiliate'
import { updateAffiliate } from './hooks/updateAffiliate'
import { deleteAffiliate } from './hooks/deleteAffiliate'
import AffiliateCropModal from './components/AffiliateCropModal'
type Affiliate = AdminAffiliate
// Centralized affiliate categories
const AFFILIATE_CATEGORIES = [
'Technology',
'Energy',
'Finance',
'Healthcare',
'Education',
'Travel',
'Retail',
'Construction',
'Food',
'Automotive',
'Fashion',
'Pets'
] as const
export default function AffiliateManagementPage() {
const router = useRouter()
const user = useAuthStore(s => s.user)
const isAdmin = !!user && (
(user as any)?.role === 'admin' ||
(user as any)?.userType === 'admin' ||
(user as any)?.isAdmin === true ||
((user as any)?.roles?.includes?.('admin'))
)
const [authChecked, setAuthChecked] = React.useState(false)
React.useEffect(() => {
if (user === null) {
router.replace('/login')
return
}
if (user && !isAdmin) {
router.replace('/')
return
}
setAuthChecked(true)
}, [user, isAdmin, router])
// Fetch affiliates from API
const { affiliates, loading, error, refresh } = useAdminAffiliates()
const [searchQuery, setSearchQuery] = useState('')
const [categoryFilter, setCategoryFilter] = useState<string>('all')
const [showCreateModal, setShowCreateModal] = useState(false)
const [showEditModal, setShowEditModal] = useState(false)
const [showDeleteModal, setShowDeleteModal] = useState(false)
const [selectedAffiliate, setSelectedAffiliate] = useState<Affiliate | null>(null)
const [isCreating, setIsCreating] = useState(false)
const [isUpdating, setIsUpdating] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const categories = ['all', ...AFFILIATE_CATEGORIES]
const filteredAffiliates = affiliates.filter(affiliate => {
const matchesSearch = affiliate.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
affiliate.description.toLowerCase().includes(searchQuery.toLowerCase())
const matchesCategory = categoryFilter === 'all' || affiliate.category === categoryFilter
return matchesSearch && matchesCategory
})
const handleEdit = (affiliate: Affiliate) => {
setSelectedAffiliate(affiliate)
setShowEditModal(true)
}
const handleDelete = (affiliate: Affiliate) => {
setSelectedAffiliate(affiliate)
setShowDeleteModal(true)
}
const confirmDelete = async () => {
if (!selectedAffiliate) return
setIsDeleting(true)
try {
const result = await deleteAffiliate(selectedAffiliate.id)
if (result.ok) {
setShowDeleteModal(false)
setSelectedAffiliate(null)
await refresh()
} else {
alert(result.message || 'Failed to delete affiliate')
}
} catch (err) {
console.error('Delete error:', err)
alert('Failed to delete affiliate')
} finally {
setIsDeleting(false)
}
}
if (!authChecked) return null
return (
<PageTransitionEffect>
<div className="min-h-screen flex flex-col bg-gradient-to-tr from-blue-50 via-white to-blue-100">
<Header />
<main className="flex-1 py-8 px-4 sm:px-6 lg:px-8">
<div className="max-w-7xl mx-auto">
{/* Error State */}
{error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
<p className="text-red-800 text-sm">
Error loading affiliates: {error}
</p>
<button
onClick={refresh}
className="mt-2 text-sm text-red-600 hover:text-red-800 font-medium"
>
Try again
</button>
</div>
)}
{/* Header */}
<header className="bg-white/90 backdrop-blur border-b border-blue-100 py-10 px-8 rounded-2xl shadow-lg mb-8">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight">Affiliate Management</h1>
<p className="text-lg text-blue-700 mt-2">Manage your affiliate partners and tracking links</p>
</div>
<button
onClick={() => setShowCreateModal(true)}
className="inline-flex items-center gap-2 rounded-lg bg-blue-900 hover:bg-blue-800 text-blue-50 px-5 py-3 text-sm font-semibold shadow transition"
>
<PlusIcon className="h-5 w-5" />
Add Affiliate
</button>
</div>
{/* Search and Filter */}
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1 relative">
<MagnifyingGlassIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
<input
type="text"
placeholder="Search affiliates..."
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2.5 rounded-lg border border-gray-300 bg-white text-gray-900 placeholder-gray-400 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
/>
</div>
<select
value={categoryFilter}
onChange={e => setCategoryFilter(e.target.value)}
className="px-4 py-2.5 rounded-lg border border-gray-300 bg-white text-gray-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
>
{categories.map(cat => (
<option key={cat} value={cat}>
{cat === 'all' ? 'All Categories' : cat}
</option>
))}
</select>
</div>
</header>
{/* Stats */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6 mb-8">
<div className="bg-white rounded-2xl border border-gray-100 shadow-lg p-6">
<div className="flex items-center gap-3">
<div className="rounded-lg bg-blue-50 p-3">
<LinkIcon className="h-6 w-6 text-blue-900" />
</div>
<div>
<p className="text-sm text-gray-600">Total Affiliates</p>
<p className="text-2xl font-bold text-gray-900">{affiliates.length}</p>
</div>
</div>
</div>
<div className="bg-white rounded-2xl border border-gray-100 shadow-lg p-6">
<div className="flex items-center gap-3">
<div className="rounded-lg bg-green-50 p-3">
<LinkIcon className="h-6 w-6 text-green-700" />
</div>
<div>
<p className="text-sm text-gray-600">Active</p>
<p className="text-2xl font-bold text-gray-900">
{affiliates.filter(a => a.isActive).length}
</p>
</div>
</div>
</div>
<div className="bg-white rounded-2xl border border-gray-100 shadow-lg p-6">
<div className="flex items-center gap-3">
<div className="rounded-lg bg-amber-50 p-3">
<LinkIcon className="h-6 w-6 text-amber-700" />
</div>
<div>
<p className="text-sm text-gray-600">Inactive</p>
<p className="text-2xl font-bold text-gray-900">
{affiliates.filter(a => !a.isActive).length}
</p>
</div>
</div>
</div>
</div>
{/* Affiliates Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{loading && (
<div className="col-span-full text-center py-12">
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-blue-600 border-r-transparent"></div>
<p className="mt-4 text-sm text-gray-600">Loading affiliates...</p>
</div>
)}
{!loading && filteredAffiliates.map((affiliate) => (
<article
key={affiliate.id}
className="bg-white rounded-2xl border border-gray-100 shadow-lg overflow-hidden hover:shadow-xl transition"
>
{/* Logo/Image */}
<div className="relative h-40 bg-gradient-to-br from-blue-50 to-gray-100">
{affiliate.logoUrl ? (
<img
src={affiliate.logoUrl}
alt={affiliate.name}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<PhotoIcon className="h-16 w-16 text-gray-300" />
</div>
)}
<div className="absolute top-3 right-3">
<span className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-medium ${
affiliate.isActive
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-700'
}`}>
{affiliate.isActive ? 'Active' : 'Inactive'}
</span>
</div>
</div>
{/* Content */}
<div className="p-5">
<div className="flex items-start justify-between mb-2">
<h3 className="text-lg font-semibold text-blue-900">{affiliate.name}</h3>
<span className="inline-flex items-center rounded-full bg-blue-50 px-2 py-1 text-xs font-medium text-blue-900">
{affiliate.category}
</span>
</div>
<p className="text-sm text-gray-600 mb-4 line-clamp-2">{affiliate.description}</p>
{affiliate.commissionRate && (
<div className="mb-4 flex items-center gap-2">
<span className="text-xs text-gray-500">Commission:</span>
<span className="text-sm font-semibold text-blue-900">{affiliate.commissionRate}</span>
</div>
)}
<a
href={affiliate.url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-blue-600 hover:text-blue-800 flex items-center gap-1 mb-4 truncate"
>
<LinkIcon className="h-3 w-3 flex-shrink-0" />
<span className="truncate">{affiliate.url}</span>
</a>
{/* Actions */}
<div className="space-y-3 pt-4 border-t border-gray-100">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-700">Status</span>
<button
onClick={async () => {
try {
const { updateAffiliate } = await import('./hooks/updateAffiliate');
const result = await updateAffiliate({
id: affiliate.id,
name: affiliate.name,
description: affiliate.description,
url: affiliate.url,
category: affiliate.category,
commissionRate: affiliate.commissionRate,
isActive: !affiliate.isActive
});
if (result.ok) {
await refresh();
}
} catch (err) {
console.error('Toggle status error:', err);
}
}}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
affiliate.isActive ? 'bg-green-600' : 'bg-gray-300'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
affiliate.isActive ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => handleEdit(affiliate)}
className="flex-1 inline-flex items-center justify-center gap-2 rounded-lg bg-blue-50 text-blue-900 px-3 py-2 text-sm font-medium hover:bg-blue-100 transition"
>
<PencilIcon className="h-4 w-4" />
Edit
</button>
<button
onClick={() => handleDelete(affiliate)}
className="flex-1 inline-flex items-center justify-center gap-2 rounded-lg bg-red-50 text-red-700 px-3 py-2 text-sm font-medium hover:bg-red-100 transition"
>
<TrashIcon className="h-4 w-4" />
Delete
</button>
</div>
</div>
</div>
</article>
))}
{!loading && filteredAffiliates.length === 0 && (
<div className="col-span-full text-center py-12">
<PhotoIcon className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900">No affiliates found</h3>
<p className="mt-1 text-sm text-gray-500">
{searchQuery || categoryFilter !== 'all'
? 'Try adjusting your search or filter'
: 'Get started by adding a new affiliate partner'}
</p>
</div>
)}
</div>
</div>
</main>
<Footer />
</div>
{/* Modals */}
{showCreateModal && <CreateAffiliateModal
onClose={() => setShowCreateModal(false)}
onCreate={async (newAffiliate) => {
setIsCreating(true)
try {
const result = await addAffiliate({
name: newAffiliate.name,
description: newAffiliate.description,
url: newAffiliate.url,
category: newAffiliate.category,
commissionRate: newAffiliate.commissionRate,
isActive: newAffiliate.isActive,
logoFile: newAffiliate.logoFile
})
if (result.ok) {
await refresh()
setShowCreateModal(false)
} else {
alert(result.message || 'Failed to create affiliate')
}
} catch (err) {
console.error('Create error:', err)
alert('Failed to create affiliate')
} finally {
setIsCreating(false)
}
}}
/>}
{showEditModal && selectedAffiliate && (
<EditAffiliateModal
affiliate={selectedAffiliate}
onClose={() => {
setShowEditModal(false)
setSelectedAffiliate(null)
}}
onUpdate={async (updated) => {
setIsUpdating(true)
try {
const result = await updateAffiliate({
id: updated.id,
name: updated.name,
description: updated.description,
url: updated.url,
category: updated.category,
commissionRate: updated.commissionRate,
isActive: updated.isActive,
logoFile: updated.logoFile,
removeLogo: updated.removeLogo
})
if (result.ok) {
await refresh()
setShowEditModal(false)
setSelectedAffiliate(null)
} else {
alert(result.message || 'Failed to update affiliate')
}
} catch (err) {
console.error('Update error:', err)
alert('Failed to update affiliate')
} finally {
setIsUpdating(false)
}
}}
/>
)}
{showDeleteModal && selectedAffiliate && (
<DeleteConfirmModal
affiliateName={selectedAffiliate.name}
isDeleting={isDeleting}
onClose={() => {
if (!isDeleting) {
setShowDeleteModal(false)
setSelectedAffiliate(null)
}
}}
onConfirm={confirmDelete}
/>
)}
</PageTransitionEffect>
)
}
// Create Modal Component
function CreateAffiliateModal({ onClose, onCreate }: { onClose: () => void; onCreate: (affiliate: Omit<Affiliate, 'id' | 'createdAt'> & { logoFile?: File }) => void }) {
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [url, setUrl] = useState('')
const [category, setCategory] = useState<string>(AFFILIATE_CATEGORIES[0])
const [commissionRate, setCommissionRate] = useState('')
const [isActive, setIsActive] = useState(true)
const [logoFile, setLogoFile] = useState<File | undefined>(undefined)
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
const [showCropModal, setShowCropModal] = useState(false)
const [rawImageUrl, setRawImageUrl] = useState<string | null>(null)
// Cleanup preview URL on unmount
React.useEffect(() => {
return () => {
if (previewUrl) URL.revokeObjectURL(previewUrl)
if (rawImageUrl) URL.revokeObjectURL(rawImageUrl)
}
}, [previewUrl, rawImageUrl])
const handleLogoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) {
console.log('No file selected')
return
}
console.log('File selected:', file.name, file.type, file.size)
const allowed = ['image/jpeg', 'image/png', 'image/webp', 'image/svg+xml']
if (!allowed.includes(file.type)) {
alert('Invalid image type. Allowed: JPG, PNG, WebP, SVG')
e.target.value = '' // Reset input
return
}
if (file.size > 5 * 1024 * 1024) {
alert('Image exceeds 5MB limit')
e.target.value = '' // Reset input
return
}
// Open crop modal with raw image
const url = URL.createObjectURL(file)
setRawImageUrl(url)
setShowCropModal(true)
// Reset input so same file can be selected again
e.target.value = ''
}
const handleCropComplete = (croppedBlob: Blob) => {
// Clean up old preview
if (previewUrl) URL.revokeObjectURL(previewUrl)
if (rawImageUrl) URL.revokeObjectURL(rawImageUrl)
// Create file from blob
const croppedFile = new File([croppedBlob], 'affiliate-logo.jpg', { type: 'image/jpeg' })
setLogoFile(croppedFile)
// Create preview
const url = URL.createObjectURL(croppedBlob)
setPreviewUrl(url)
setRawImageUrl(null)
}
const handleRemoveLogo = () => {
if (previewUrl) URL.revokeObjectURL(previewUrl)
setLogoFile(undefined)
setPreviewUrl(null)
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
onCreate({
name,
description,
url,
category,
commissionRate: commissionRate || undefined,
isActive,
logoFile
})
}
return (
<>
<AffiliateCropModal
isOpen={showCropModal}
imageSrc={rawImageUrl || ''}
onClose={() => {
setShowCropModal(false)
if (rawImageUrl) URL.revokeObjectURL(rawImageUrl)
setRawImageUrl(null)
}}
onCropComplete={handleCropComplete}
/>
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="relative w-full max-w-2xl mx-4 bg-white rounded-2xl shadow-2xl max-h-[90vh] overflow-y-auto">
<div className="sticky top-0 bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between rounded-t-2xl">
<h2 className="text-2xl font-bold text-blue-900">Add New Affiliate</h2>
<button onClick={onClose} className="text-gray-500 hover:text-gray-700 transition">
<XMarkIcon className="h-6 w-6" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-6">
<div>
<label className="block text-sm font-medium text-blue-900 mb-2">Partner Name *</label>
<input
required
value={name}
onChange={e => setName(e.target.value)}
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-gray-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
placeholder="e.g., Coffee Equipment Co."
/>
</div>
<div>
<label className="block text-sm font-medium text-blue-900 mb-2">Description *</label>
<textarea
required
value={description}
onChange={e => setDescription(e.target.value)}
rows={3}
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-gray-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
placeholder="Brief description of the affiliate partner..."
/>
</div>
<div>
<label className="block text-sm font-medium text-blue-900 mb-2">Affiliate URL *</label>
<input
required
type="url"
value={url}
onChange={e => setUrl(e.target.value)}
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-gray-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
placeholder="https://example.com/affiliate-link"
/>
</div>
<div>
<label className="block text-sm font-medium text-blue-900 mb-2">Logo Image</label>
<div
className="relative flex justify-center items-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 cursor-pointer overflow-hidden transition hover:border-blue-400 hover:bg-blue-100"
style={{ minHeight: '200px' }}
onClick={(e) => {
e.stopPropagation()
const input = document.getElementById('create-logo-upload') as HTMLInputElement
if (input) input.click()
}}
>
{!previewUrl && (
<div className="text-center w-full px-6 py-10">
<PhotoIcon aria-hidden="true" className="mx-auto h-12 w-12 text-gray-400" />
<div className="mt-4 text-sm font-medium text-gray-700">
<span>Click to upload logo</span>
</div>
<p className="text-xs text-gray-500 mt-2">PNG, JPG, WebP, SVG up to 5MB</p>
</div>
)}
{previewUrl && (
<div className="relative w-full h-full min-h-[200px] flex items-center justify-center bg-white p-4">
<img
src={previewUrl}
alt="Logo preview"
className="max-h-[180px] max-w-full object-contain"
/>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
handleRemoveLogo()
}}
className="absolute top-2 right-2 bg-red-50 hover:bg-red-100 text-red-700 px-3 py-1.5 rounded-lg text-sm font-medium shadow transition"
>
Remove
</button>
</div>
)}
</div>
<input
id="create-logo-upload"
type="file"
accept="image/*"
onChange={handleLogoChange}
className="hidden"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-blue-900 mb-2">Category *</label>
<select
value={category}
onChange={e => setCategory(e.target.value)}
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-gray-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
>
{AFFILIATE_CATEGORIES.map(cat => (
<option key={cat} value={cat}>{cat}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-blue-900 mb-2">Commission Rate</label>
<input
value={commissionRate}
onChange={e => setCommissionRate(e.target.value)}
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-gray-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
placeholder="e.g., 10%"
/>
</div>
</div>
<div className="flex items-center gap-2">
<input
id="isActive"
type="checkbox"
checked={isActive}
onChange={e => setIsActive(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-blue-900 focus:ring-blue-900"
/>
<label htmlFor="isActive" className="text-sm font-medium text-gray-700">
Active (visible to users)
</label>
</div>
<div className="flex items-center justify-end gap-3 pt-4 border-t border-gray-200">
<button
type="button"
onClick={onClose}
className="px-5 py-2.5 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition"
>
Cancel
</button>
<button
type="submit"
className="px-5 py-2.5 text-sm font-semibold text-white bg-blue-900 rounded-lg hover:bg-blue-800 shadow transition"
>
Add Affiliate
</button>
</div>
</form>
</div>
</div>
</>
)
}
// Edit Modal Component
function EditAffiliateModal({ affiliate, onClose, onUpdate }: {
affiliate: Affiliate;
onClose: () => void;
onUpdate: (affiliate: Affiliate & { logoFile?: File; removeLogo?: boolean }) => void
}) {
const [name, setName] = useState(affiliate.name)
const [description, setDescription] = useState(affiliate.description)
const [url, setUrl] = useState(affiliate.url)
const [category, setCategory] = useState(affiliate.category)
const [commissionRate, setCommissionRate] = useState(affiliate.commissionRate || '')
const [isActive, setIsActive] = useState(affiliate.isActive)
const [logoFile, setLogoFile] = useState<File | undefined>(undefined)
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
const [currentLogoUrl, setCurrentLogoUrl] = useState<string | undefined>(affiliate.logoUrl)
const [logoRemoved, setLogoRemoved] = useState(false)
const [showCropModal, setShowCropModal] = useState(false)
const [rawImageUrl, setRawImageUrl] = useState<string | null>(null)
// Cleanup preview URL on unmount
React.useEffect(() => {
return () => {
if (previewUrl) URL.revokeObjectURL(previewUrl)
if (rawImageUrl) URL.revokeObjectURL(rawImageUrl)
}
}, [previewUrl, rawImageUrl])
const handleLogoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) {
console.log('No file selected')
return
}
console.log('File selected:', file.name, file.type, file.size)
const allowed = ['image/jpeg', 'image/png', 'image/webp', 'image/svg+xml']
if (!allowed.includes(file.type)) {
alert('Invalid image type. Allowed: JPG, PNG, WebP, SVG')
e.target.value = '' // Reset input
return
}
if (file.size > 5 * 1024 * 1024) {
alert('Image exceeds 5MB limit')
e.target.value = '' // Reset input
return
}
// Open crop modal with raw image
const url = URL.createObjectURL(file)
setRawImageUrl(url)
setShowCropModal(true)
// Reset input so same file can be selected again
e.target.value = ''
}
const handleCropComplete = (croppedBlob: Blob) => {
// Clean up old preview
if (previewUrl) URL.revokeObjectURL(previewUrl)
if (rawImageUrl) URL.revokeObjectURL(rawImageUrl)
// Create file from blob
const croppedFile = new File([croppedBlob], 'affiliate-logo.jpg', { type: 'image/jpeg' })
setLogoFile(croppedFile)
setLogoRemoved(false)
// Create preview
const url = URL.createObjectURL(croppedBlob)
setPreviewUrl(url)
setRawImageUrl(null)
}
const handleRemoveLogo = () => {
if (previewUrl) URL.revokeObjectURL(previewUrl)
setLogoFile(undefined)
setPreviewUrl(null)
setCurrentLogoUrl(undefined)
setLogoRemoved(true)
}
const displayLogoUrl = logoRemoved ? null : (previewUrl || currentLogoUrl)
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
onUpdate({
...affiliate,
name,
description,
url,
category,
commissionRate: commissionRate || undefined,
isActive,
logoFile,
removeLogo: logoRemoved && !logoFile
})
}
return (
<>
<AffiliateCropModal
isOpen={showCropModal}
imageSrc={rawImageUrl || ''}
onClose={() => {
setShowCropModal(false)
if (rawImageUrl) URL.revokeObjectURL(rawImageUrl)
setRawImageUrl(null)
}}
onCropComplete={handleCropComplete}
/>
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="relative w-full max-w-2xl mx-4 bg-white rounded-2xl shadow-2xl max-h-[90vh] overflow-y-auto">
<div className="sticky top-0 bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between rounded-t-2xl">
<h2 className="text-2xl font-bold text-blue-900">Edit Affiliate</h2>
<button onClick={onClose} className="text-gray-500 hover:text-gray-700 transition">
<XMarkIcon className="h-6 w-6" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-6">
<div>
<label className="block text-sm font-medium text-blue-900 mb-2">Partner Name *</label>
<input
required
value={name}
onChange={e => setName(e.target.value)}
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-gray-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-blue-900 mb-2">Description *</label>
<textarea
required
value={description}
onChange={e => setDescription(e.target.value)}
rows={3}
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-gray-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-blue-900 mb-2">Affiliate URL *</label>
<input
required
type="url"
value={url}
onChange={e => setUrl(e.target.value)}
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-gray-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-blue-900 mb-2">Logo Image</label>
<div
className="relative flex justify-center items-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 cursor-pointer overflow-hidden transition hover:border-blue-400 hover:bg-blue-100"
style={{ minHeight: '200px' }}
onClick={(e) => {
e.stopPropagation()
const input = document.getElementById('edit-logo-upload') as HTMLInputElement
if (input) input.click()
}}
>
{!displayLogoUrl && (
<div className="text-center w-full px-6 py-10">
<PhotoIcon aria-hidden="true" className="mx-auto h-12 w-12 text-gray-400" />
<div className="mt-4 text-sm font-medium text-gray-700">
<span>Click to upload logo</span>
</div>
<p className="text-xs text-gray-500 mt-2">PNG, JPG, WebP, SVG up to 5MB</p>
</div>
)}
{displayLogoUrl && (
<div className="relative w-full h-full min-h-[200px] flex flex-col items-center justify-center bg-white p-4">
<img
src={displayLogoUrl}
alt="Logo"
className="max-h-[180px] max-w-full object-contain"
/>
<p className="mt-2 text-xs text-gray-600">
{logoFile ? '🆕 New logo selected' : '📷 Current logo'}
</p>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
handleRemoveLogo()
}}
className="absolute top-2 right-2 bg-red-50 hover:bg-red-100 text-red-700 px-3 py-1.5 rounded-lg text-sm font-medium shadow transition"
>
Remove
</button>
</div>
)}
</div>
<input
id="edit-logo-upload"
type="file"
accept="image/*"
onChange={handleLogoChange}
className="hidden"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-blue-900 mb-2">Category *</label>
<select
value={category}
onChange={e => setCategory(e.target.value)}
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-gray-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
>
{AFFILIATE_CATEGORIES.map(cat => (
<option key={cat} value={cat}>{cat}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-blue-900 mb-2">Commission Rate</label>
<input
value={commissionRate}
onChange={e => setCommissionRate(e.target.value)}
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-gray-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
/>
</div>
</div>
<div className="flex items-center gap-2">
<input
id="editIsActive"
type="checkbox"
checked={isActive}
onChange={e => setIsActive(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-blue-900 focus:ring-blue-900"
/>
<label htmlFor="editIsActive" className="text-sm font-medium text-gray-700">
Active (visible to users)
</label>
</div>
<div className="flex items-center justify-end gap-3 pt-4 border-t border-gray-200">
<button
type="button"
onClick={onClose}
className="px-5 py-2.5 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition"
>
Cancel
</button>
<button
type="submit"
className="px-5 py-2.5 text-sm font-semibold text-white bg-blue-900 rounded-lg hover:bg-blue-800 shadow transition"
>
Save Changes
</button>
</div>
</form>
</div>
</div>
</>
)
}
// Delete Confirmation Modal
function DeleteConfirmModal({ affiliateName, onClose, onConfirm, isDeleting }: {
affiliateName: string;
onClose: () => void;
onConfirm: () => void;
isDeleting: boolean;
}) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="relative w-full max-w-md mx-4 bg-white rounded-2xl shadow-2xl p-6">
<div className="mb-4">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-red-100">
<TrashIcon className="h-6 w-6 text-red-600" />
</div>
<h3 className="mt-4 text-lg font-semibold text-center text-gray-900">Delete Affiliate</h3>
<p className="mt-2 text-sm text-center text-gray-600">
Are you sure you want to delete <span className="font-semibold">{affiliateName}</span>? This action cannot be undone.
</p>
</div>
<div className="flex items-center gap-3">
<button
onClick={onClose}
disabled={isDeleting}
className="flex-1 px-4 py-2.5 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition disabled:opacity-50 disabled:cursor-not-allowed"
>
Cancel
</button>
<button
onClick={onConfirm}
disabled={isDeleting}
className="flex-1 px-4 py-2.5 text-sm font-semibold text-white bg-red-600 rounded-lg hover:bg-red-700 shadow transition disabled:opacity-50 disabled:cursor-not-allowed"
>
{isDeleting ? 'Deleting...' : 'Delete'}
</button>
</div>
</div>
</div>
)
}