diff --git a/package-lock.json b/package-lock.json index a46c831..50b8b48 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "pdfjs-dist": "^5.4.149", "react": "^19.2.1", "react-dom": "^19.2.1", + "react-easy-crop": "^5.5.6", "react-hook-form": "^7.63.0", "react-hot-toast": "^2.6.0", "react-pdf": "^10.1.0", @@ -7794,6 +7795,12 @@ "svg-arc-to-cubic-bezier": "^3.0.0" } }, + "node_modules/normalize-wheel": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/normalize-wheel/-/normalize-wheel-1.0.1.tgz", + "integrity": "sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==", + "license": "BSD-3-Clause" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -8932,6 +8939,20 @@ "react": "^19.2.1" } }, + "node_modules/react-easy-crop": { + "version": "5.5.6", + "resolved": "https://registry.npmjs.org/react-easy-crop/-/react-easy-crop-5.5.6.tgz", + "integrity": "sha512-Jw3/ozs8uXj3NpL511Suc4AHY+mLRO23rUgipXvNYKqezcFSYHxe4QXibBymkOoY6oOtLVMPO2HNPRHYvMPyTw==", + "license": "MIT", + "dependencies": { + "normalize-wheel": "^1.0.1", + "tslib": "^2.0.1" + }, + "peerDependencies": { + "react": ">=16.4.0", + "react-dom": ">=16.4.0" + } + }, "node_modules/react-hook-form": { "version": "7.63.0", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.63.0.tgz", diff --git a/package.json b/package.json index e78d041..eebdb6b 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "pdfjs-dist": "^5.4.149", "react": "^19.2.1", "react-dom": "^19.2.1", + "react-easy-crop": "^5.5.6", "react-hook-form": "^7.63.0", "react-hot-toast": "^2.6.0", "react-pdf": "^10.1.0", diff --git a/src/app/admin/affiliate-management/hooks/addAffiliate.ts b/src/app/admin/affiliate-management/hooks/addAffiliate.ts new file mode 100644 index 0000000..5c7c5d9 --- /dev/null +++ b/src/app/admin/affiliate-management/hooks/addAffiliate.ts @@ -0,0 +1,84 @@ +import { authFetch } from '../../../utils/authFetch'; + +export type AddAffiliatePayload = { + name: string; + description: string; + url: string; + category: string; + commissionRate?: string; + isActive?: boolean; + logoFile?: File; +}; + +export async function addAffiliate(payload: AddAffiliatePayload) { + const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'; + const url = `${BASE_URL}/api/admin/affiliates`; + + // Use FormData if there's a logo file, otherwise JSON + let body: FormData | string; + let headers: Record; + + if (payload.logoFile) { + const formData = new FormData(); + formData.append('name', payload.name); + formData.append('description', payload.description); + formData.append('url', payload.url); + formData.append('category', payload.category); + if (payload.commissionRate) formData.append('commission_rate', payload.commissionRate); + formData.append('is_active', String(payload.isActive ?? true)); + formData.append('logo', payload.logoFile); + + body = formData; + headers = { Accept: 'application/json' }; // Don't set Content-Type, browser will set it with boundary + } else { + body = JSON.stringify({ + name: payload.name, + description: payload.description, + url: payload.url, + category: payload.category, + commission_rate: payload.commissionRate, + is_active: payload.isActive ?? true, + }); + headers = { + 'Content-Type': 'application/json', + Accept: 'application/json', + }; + } + + const res = await authFetch(url, { + method: 'POST', + headers, + body, + }); + + let responseBody: any = null; + try { + responseBody = await res.json(); + } catch { + responseBody = null; + } + + const ok = res.status === 201 || res.ok; + const message = + responseBody?.message || + (res.status === 409 + ? 'Affiliate already exists.' + : res.status === 400 + ? 'Invalid request. Check affiliate data.' + : res.status === 401 + ? 'Unauthorized.' + : res.status === 403 + ? 'Forbidden.' + : res.status === 500 + ? 'Internal server error.' + : !ok + ? `Request failed (${res.status}).` + : ''); + + return { + ok, + status: res.status, + body: responseBody, + message, + }; +} diff --git a/src/app/admin/affiliate-management/hooks/deleteAffiliate.ts b/src/app/admin/affiliate-management/hooks/deleteAffiliate.ts new file mode 100644 index 0000000..5d63ea4 --- /dev/null +++ b/src/app/admin/affiliate-management/hooks/deleteAffiliate.ts @@ -0,0 +1,34 @@ +import { authFetch } from '../../../utils/authFetch'; + +export async function deleteAffiliate(id: string) { + const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'; + const url = `${BASE_URL}/api/admin/affiliates/${id}`; + const res = await authFetch(url, { + method: 'DELETE', + headers: { + Accept: 'application/json', + }, + }); + + let body: any = null; + try { + body = await res.json(); + } catch { + body = null; + } + + const ok = res.ok; + const message = + body?.message || + (res.status === 404 + ? 'Affiliate not found.' + : res.status === 403 + ? 'Forbidden.' + : res.status === 500 + ? 'Server error.' + : !ok + ? `Request failed (${res.status}).` + : 'Affiliate deleted successfully.'); + + return { ok, status: res.status, body, message }; +} diff --git a/src/app/admin/affiliate-management/hooks/getAffiliates.ts b/src/app/admin/affiliate-management/hooks/getAffiliates.ts new file mode 100644 index 0000000..4576a9f --- /dev/null +++ b/src/app/admin/affiliate-management/hooks/getAffiliates.ts @@ -0,0 +1,116 @@ +import { useEffect, useState } from 'react'; +import { authFetch } from '../../../utils/authFetch'; +import { log } from '../../../utils/logger'; + +export type AdminAffiliate = { + id: string; + name: string; + description: string; + url: string; + logoUrl?: string; + category: string; + isActive: boolean; + commissionRate?: string; + createdAt: string; +}; + +export function useAdminAffiliates() { + const [affiliates, setAffiliates] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'; + + useEffect(() => { + let cancelled = false; + async function load() { + setLoading(true); + setError(''); + const url = `${BASE_URL}/api/admin/affiliates`; + log("🌐 Affiliates: GET", url); + try { + const headers = { Accept: 'application/json' }; + log("📤 Affiliates: Request headers:", headers); + + const res = await authFetch(url, { headers }); + log("📡 Affiliates: Response status:", res.status); + + let body: any = null; + try { + body = await res.clone().json(); + const preview = JSON.stringify(body).slice(0, 600); + log("📦 Affiliates: Response body preview:", preview); + } catch { + log("📦 Affiliates: Response body is not JSON or failed to parse"); + } + + if (res.status === 401) { + if (!cancelled) setError('Unauthorized. Please log in.'); + return; + } + if (res.status === 403) { + if (!cancelled) setError('Forbidden. Admin access required.'); + return; + } + if (!res.ok) { + if (!cancelled) setError('Failed to load affiliates.'); + return; + } + + const apiItems: any[] = Array.isArray(body?.data) ? body.data : []; + log("🔧 Affiliates: Mapping items count:", apiItems.length); + + const mapped: AdminAffiliate[] = apiItems.map(item => ({ + id: String(item.id), + name: String(item.name ?? 'Unnamed Affiliate'), + description: String(item.description ?? ''), + url: String(item.url ?? ''), + logoUrl: item.logoUrl ? String(item.logoUrl) : undefined, + category: String(item.category ?? 'Other'), + isActive: Boolean(item.is_active), + commissionRate: item.commission_rate ? String(item.commission_rate) : undefined, + createdAt: String(item.created_at ?? new Date().toISOString()), + })); + log("✅ Affiliates: Mapped sample:", mapped.slice(0, 3)); + + if (!cancelled) setAffiliates(mapped); + } catch (e: any) { + log("❌ Affiliates: Network or parsing error:", e?.message || e); + if (!cancelled) setError('Network error while loading affiliates.'); + } finally { + if (!cancelled) setLoading(false); + } + } + load(); + return () => { cancelled = true; }; + }, [BASE_URL]); + + return { + affiliates, + loading, + error, + refresh: async () => { + const url = `${BASE_URL}/api/admin/affiliates`; + log("🔁 Affiliates: Refresh GET", url); + const res = await authFetch(url, { headers: { Accept: 'application/json' } }); + if (!res.ok) { + log("❌ Affiliates: Refresh failed status:", res.status); + return false; + } + const body = await res.json(); + const apiItems: any[] = Array.isArray(body?.data) ? body.data : []; + setAffiliates(apiItems.map(item => ({ + id: String(item.id), + name: String(item.name ?? 'Unnamed Affiliate'), + description: String(item.description ?? ''), + url: String(item.url ?? ''), + logoUrl: item.logoUrl ? String(item.logoUrl) : undefined, + category: String(item.category ?? 'Other'), + isActive: Boolean(item.is_active), + commissionRate: item.commission_rate ? String(item.commission_rate) : undefined, + createdAt: String(item.created_at ?? new Date().toISOString()), + }))); + log("✅ Affiliates: Refresh succeeded, items:", apiItems.length); + return true; + } + }; +} diff --git a/src/app/admin/affiliate-management/hooks/updateAffiliate.ts b/src/app/admin/affiliate-management/hooks/updateAffiliate.ts new file mode 100644 index 0000000..556390b --- /dev/null +++ b/src/app/admin/affiliate-management/hooks/updateAffiliate.ts @@ -0,0 +1,80 @@ +import { authFetch } from '../../../utils/authFetch'; + +export type UpdateAffiliatePayload = { + id: string; + name: string; + description: string; + url: string; + category: string; + commissionRate?: string; + isActive: boolean; + logoFile?: File; + removeLogo?: boolean; +}; + +export async function updateAffiliate(payload: UpdateAffiliatePayload) { + const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'; + const url = `${BASE_URL}/api/admin/affiliates/${payload.id}`; + + // Use FormData if there's a logo file or removeLogo flag, otherwise JSON + let body: FormData | string; + let headers: Record; + + if (payload.logoFile || payload.removeLogo) { + const formData = new FormData(); + formData.append('name', payload.name); + formData.append('description', payload.description); + formData.append('url', payload.url); + formData.append('category', payload.category); + if (payload.commissionRate) formData.append('commission_rate', payload.commissionRate); + formData.append('is_active', String(payload.isActive)); + if (payload.logoFile) formData.append('logo', payload.logoFile); + if (payload.removeLogo) formData.append('removeLogo', 'true'); + + body = formData; + headers = { Accept: 'application/json' }; + } else { + body = JSON.stringify({ + name: payload.name, + description: payload.description, + url: payload.url, + category: payload.category, + commission_rate: payload.commissionRate, + is_active: payload.isActive, + }); + headers = { + 'Content-Type': 'application/json', + Accept: 'application/json', + }; + } + + const res = await authFetch(url, { + method: 'PATCH', + headers, + body, + }); + + let responseBody: any = null; + try { + responseBody = await res.json(); + } catch { + responseBody = null; + } + + const ok = res.ok; + const message = + responseBody?.message || + (res.status === 404 + ? 'Affiliate not found.' + : res.status === 400 + ? 'Invalid request.' + : res.status === 403 + ? 'Forbidden.' + : res.status === 500 + ? 'Server error.' + : !ok + ? `Request failed (${res.status}).` + : ''); + + return { ok, status: res.status, body: responseBody, message }; +} diff --git a/src/app/admin/affiliate-management/page.tsx b/src/app/admin/affiliate-management/page.tsx new file mode 100644 index 0000000..916616a --- /dev/null +++ b/src/app/admin/affiliate-management/page.tsx @@ -0,0 +1,1002 @@ +'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('all') + const [showCreateModal, setShowCreateModal] = useState(false) + const [showEditModal, setShowEditModal] = useState(false) + const [showDeleteModal, setShowDeleteModal] = useState(false) + const [selectedAffiliate, setSelectedAffiliate] = useState(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 ( + +
+
+
+
+ {/* Error State */} + {error && ( +
+

+ Error loading affiliates: {error} +

+ +
+ )} + + {/* Header */} +
+
+
+

Affiliate Management

+

Manage your affiliate partners and tracking links

+
+ +
+ + {/* Search and Filter */} +
+
+ + 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" + /> +
+ +
+
+ + {/* Stats */} +
+
+
+
+ +
+
+

Total Affiliates

+

{affiliates.length}

+
+
+
+
+
+
+ +
+
+

Active

+

+ {affiliates.filter(a => a.isActive).length} +

+
+
+
+
+
+
+ +
+
+

Inactive

+

+ {affiliates.filter(a => !a.isActive).length} +

+
+
+
+
+ + {/* Affiliates Grid */} +
+ {loading && ( +
+
+

Loading affiliates...

+
+ )} + + {!loading && filteredAffiliates.map((affiliate) => ( +
+ {/* Logo/Image */} +
+ {affiliate.logoUrl ? ( + {affiliate.name} + ) : ( +
+ +
+ )} +
+ + {affiliate.isActive ? 'Active' : 'Inactive'} + +
+
+ + {/* Content */} +
+
+

{affiliate.name}

+ + {affiliate.category} + +
+

{affiliate.description}

+ + {affiliate.commissionRate && ( +
+ Commission: + {affiliate.commissionRate} +
+ )} + + + + {affiliate.url} + + + {/* Actions */} +
+
+ Status + +
+
+ + +
+
+
+
+ ))} + + {!loading && filteredAffiliates.length === 0 && ( +
+ +

No affiliates found

+

+ {searchQuery || categoryFilter !== 'all' + ? 'Try adjusting your search or filter' + : 'Get started by adding a new affiliate partner'} +

+
+ )} +
+
+
+
+
+ + {/* Modals */} + {showCreateModal && 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 && ( + { + 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 && ( + { + if (!isDeleting) { + setShowDeleteModal(false) + setSelectedAffiliate(null) + } + }} + onConfirm={confirmDelete} + /> + )} +
+ ) +} + +// Create Modal Component +function CreateAffiliateModal({ onClose, onCreate }: { onClose: () => void; onCreate: (affiliate: Omit & { logoFile?: File }) => void }) { + const [name, setName] = useState('') + const [description, setDescription] = useState('') + const [url, setUrl] = useState('') + const [category, setCategory] = useState(AFFILIATE_CATEGORIES[0]) + const [commissionRate, setCommissionRate] = useState('') + const [isActive, setIsActive] = useState(true) + const [logoFile, setLogoFile] = useState(undefined) + const [previewUrl, setPreviewUrl] = useState(null) + const [showCropModal, setShowCropModal] = useState(false) + const [rawImageUrl, setRawImageUrl] = useState(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) => { + 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 ( + <> + { + setShowCropModal(false) + if (rawImageUrl) URL.revokeObjectURL(rawImageUrl) + setRawImageUrl(null) + }} + onCropComplete={handleCropComplete} + /> +
+
+
+

Add New Affiliate

+ +
+ +
+
+ + 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." + /> +
+ +
+ +