- 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.
1003 lines
39 KiB
TypeScript
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>
|
|
)
|
|
}
|