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.
This commit is contained in:
seaznCode 2025-12-06 20:29:58 +01:00
parent 0662044b85
commit 20c71636f6
11 changed files with 1577 additions and 178 deletions

21
package-lock.json generated
View File

@ -27,6 +27,7 @@
"pdfjs-dist": "^5.4.149", "pdfjs-dist": "^5.4.149",
"react": "^19.2.1", "react": "^19.2.1",
"react-dom": "^19.2.1", "react-dom": "^19.2.1",
"react-easy-crop": "^5.5.6",
"react-hook-form": "^7.63.0", "react-hook-form": "^7.63.0",
"react-hot-toast": "^2.6.0", "react-hot-toast": "^2.6.0",
"react-pdf": "^10.1.0", "react-pdf": "^10.1.0",
@ -7794,6 +7795,12 @@
"svg-arc-to-cubic-bezier": "^3.0.0" "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": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@ -8932,6 +8939,20 @@
"react": "^19.2.1" "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": { "node_modules/react-hook-form": {
"version": "7.63.0", "version": "7.63.0",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.63.0.tgz", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.63.0.tgz",

View File

@ -28,6 +28,7 @@
"pdfjs-dist": "^5.4.149", "pdfjs-dist": "^5.4.149",
"react": "^19.2.1", "react": "^19.2.1",
"react-dom": "^19.2.1", "react-dom": "^19.2.1",
"react-easy-crop": "^5.5.6",
"react-hook-form": "^7.63.0", "react-hook-form": "^7.63.0",
"react-hot-toast": "^2.6.0", "react-hot-toast": "^2.6.0",
"react-pdf": "^10.1.0", "react-pdf": "^10.1.0",

View File

@ -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<string, string>;
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,
};
}

View File

@ -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 };
}

View File

@ -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<AdminAffiliate[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>('');
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;
}
};
}

View File

@ -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<string, string>;
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 };
}

File diff suppressed because it is too large Load Diff

View File

@ -5,6 +5,7 @@ import useCoffeeManagement from '../hooks/useCoffeeManagement';
import { PhotoIcon } from '@heroicons/react/24/solid'; import { PhotoIcon } from '@heroicons/react/24/solid';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import ImageCropModal from '../components/ImageCropModal';
export default function CreateSubscriptionPage() { export default function CreateSubscriptionPage() {
const { createProduct } = useCoffeeManagement(); const { createProduct } = useCoffeeManagement();
@ -15,10 +16,12 @@ export default function CreateSubscriptionPage() {
// form state // form state
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
const [description, setDescription] = useState(''); const [description, setDescription] = useState('');
const [price, setPrice] = useState(0); const [price, setPrice] = useState('0.00');
const [state, setState] = useState<'available'|'unavailable'>('available'); const [state, setState] = useState<'available'|'unavailable'>('available');
const [pictureFile, setPictureFile] = useState<File | undefined>(undefined); const [pictureFile, setPictureFile] = useState<File | undefined>(undefined);
const [previewUrl, setPreviewUrl] = useState<string | null>(null); const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [originalImageSrc, setOriginalImageSrc] = useState<string | null>(null);
const [showCropModal, setShowCropModal] = useState(false);
const [currency, setCurrency] = useState('EUR'); const [currency, setCurrency] = useState('EUR');
const [isFeatured, setIsFeatured] = useState(false); const [isFeatured, setIsFeatured] = useState(false);
// Fixed billing defaults (locked: month / 1) // Fixed billing defaults (locked: month / 1)
@ -32,7 +35,7 @@ export default function CreateSubscriptionPage() {
await createProduct({ await createProduct({
title, title,
description, description,
price, price: parseFloat(price),
currency, currency,
is_featured: isFeatured, is_featured: isFeatured,
state: state === 'available', state: state === 'available',
@ -44,16 +47,13 @@ export default function CreateSubscriptionPage() {
} }
}; };
// preview object URL management // Cleanup object URLs
useEffect(() => { useEffect(() => {
if (pictureFile) { return () => {
const url = URL.createObjectURL(pictureFile); if (previewUrl) URL.revokeObjectURL(previewUrl);
setPreviewUrl(url); if (originalImageSrc) URL.revokeObjectURL(originalImageSrc);
return () => URL.revokeObjectURL(url); };
} else { }, []);
setPreviewUrl(null);
}
}, [pictureFile]);
function handleSelectFile(file?: File) { function handleSelectFile(file?: File) {
if (!file) return; if (!file) return;
@ -67,7 +67,21 @@ export default function CreateSubscriptionPage() {
return; return;
} }
setError(null); setError(null);
setPictureFile(file);
// Create object URL for cropping
const url = URL.createObjectURL(file);
setOriginalImageSrc(url);
setShowCropModal(true);
}
function handleCropComplete(croppedBlob: Blob) {
// Convert blob to file
const croppedFile = new File([croppedBlob], 'cropped-image.jpg', { type: 'image/jpeg' });
setPictureFile(croppedFile);
// Create preview URL
const url = URL.createObjectURL(croppedBlob);
setPreviewUrl(url);
} }
return ( return (
@ -101,7 +115,27 @@ export default function CreateSubscriptionPage() {
{/* Price */} {/* Price */}
<div> <div>
<label htmlFor="price" className="block text-sm font-medium text-blue-900">Price</label> <label htmlFor="price" className="block text-sm font-medium text-blue-900">Price</label>
<input id="price" name="price" required min={0.01} step={0.01} className="mt-1 block w-full rounded-lg border-gray-300 shadow focus:border-blue-900 focus:ring-blue-900 px-4 py-3 text-black placeholder:text-gray-400" placeholder="Price" type="number" value={price} onChange={e => setPrice(Number(e.target.value))} /> <input
id="price"
name="price"
required
min={0.01}
step={0.01}
className="mt-1 block w-full rounded-lg border-gray-300 shadow focus:border-blue-900 focus:ring-blue-900 px-4 py-3 text-black placeholder:text-gray-400"
placeholder="0.00"
type="number"
value={price}
onChange={e => {
const val = e.target.value;
setPrice(val);
}}
onBlur={e => {
const num = parseFloat(e.target.value);
if (!isNaN(num)) {
setPrice(num.toFixed(2));
}
}}
/>
</div> </div>
{/* Currency */} {/* Currency */}
<div> <div>
@ -142,9 +176,11 @@ export default function CreateSubscriptionPage() {
{/* Picture Upload */} {/* Picture Upload */}
<div> <div>
<label className="block text-sm font-medium text-blue-900">Picture</label> <label className="block text-sm font-medium text-blue-900 mb-2">Picture</label>
<p className="text-xs text-gray-600 mb-3">Upload an image and crop it to fit the coffee thumbnail (16:9 aspect ratio, 144px height)</p>
<div <div
className="mt-2 flex max-w-xl justify-center rounded-lg border border-dashed border-blue-300 px-6 py-10 bg-blue-50 cursor-pointer relative" className="relative flex justify-center items-center rounded-lg border-2 border-dashed border-blue-300 bg-blue-50 cursor-pointer overflow-hidden transition hover:border-blue-400 hover:bg-blue-100"
style={{ minHeight: '400px' }}
onClick={() => document.getElementById('file-upload')?.click()} onClick={() => document.getElementById('file-upload')?.click()}
onDragOver={e => e.preventDefault()} onDragOver={e => e.preventDefault()}
onDrop={e => { onDrop={e => {
@ -153,16 +189,46 @@ export default function CreateSubscriptionPage() {
}} }}
> >
{!previewUrl && ( {!previewUrl && (
<div className="text-center w-full"> <div className="text-center w-full px-6 py-10">
<PhotoIcon aria-hidden="true" className="mx-auto h-12 w-12 text-blue-400" /> <PhotoIcon aria-hidden="true" className="mx-auto h-16 w-16 text-blue-400" />
<div className="mt-4 text-sm text-blue-700"> <div className="mt-4 text-base font-medium text-blue-700">
<span>Drag and drop an image here</span> <span>Click or drag and drop an image here</span>
</div> </div>
<p className="text-xs text-blue-600 mt-2">PNG, JPG, WebP up to 10MB</p> <p className="text-sm text-blue-600 mt-2">PNG, JPG, WebP up to 10MB</p>
<p className="text-xs text-gray-500 mt-2">You'll be able to crop and adjust the image after uploading</p>
</div> </div>
)} )}
{previewUrl && ( {previewUrl && (
<img src={previewUrl} alt="Preview" className="absolute inset-0 w-full h-full object-cover rounded-lg" /> <div className="relative w-full h-full min-h-[400px] flex items-center justify-center bg-gray-100 p-6">
<img
src={previewUrl}
alt="Preview"
className="max-h-[380px] max-w-full object-contain rounded-lg shadow-lg"
/>
<div className="absolute top-4 right-4 flex gap-2">
<button
type="button"
onClick={e => {
e.stopPropagation();
setShowCropModal(true);
}}
className="bg-white/90 backdrop-blur px-3 py-1.5 rounded-lg text-sm font-medium text-blue-900 shadow hover:bg-white transition"
>
Edit Crop
</button>
<button
type="button"
onClick={e => {
e.stopPropagation();
setPictureFile(undefined);
setPreviewUrl(null);
}}
className="bg-white/90 backdrop-blur px-3 py-1.5 rounded-lg text-sm font-medium text-red-600 shadow hover:bg-white transition"
>
Remove
</button>
</div>
</div>
)} )}
<input <input
id="file-upload" id="file-upload"
@ -172,13 +238,6 @@ export default function CreateSubscriptionPage() {
className="hidden" className="hidden"
onChange={e => handleSelectFile(e.target.files?.[0])} onChange={e => handleSelectFile(e.target.files?.[0])}
/> />
{previewUrl && (
<button
type="button"
onClick={e => { e.stopPropagation(); setPictureFile(undefined); }}
className="absolute top-2 right-2 bg-white/80 backdrop-blur px-2 py-1 rounded text-xs font-medium text-blue-900 shadow z-10"
>Remove</button>
)}
</div> </div>
</div> </div>
@ -197,6 +256,16 @@ export default function CreateSubscriptionPage() {
</div> </div>
</main> </main>
</div> </div>
{/* Image Crop Modal */}
{originalImageSrc && (
<ImageCropModal
isOpen={showCropModal}
imageSrc={originalImageSrc}
onClose={() => setShowCropModal(false)}
onCropComplete={handleCropComplete}
/>
)}
</PageLayout> </PageLayout>
); );
} }

View File

@ -204,9 +204,11 @@ export default function EditSubscriptionPage() {
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-blue-900">Picture (optional)</label> <label className="block text-sm font-medium text-blue-900 mb-2">Picture (optional)</label>
<p className="text-xs text-gray-600 mb-3">Upload an image to replace the current picture (16:9 aspect ratio recommended)</p>
<div <div
className="mt-2 flex max-w-xl justify-center rounded-lg border border-dashed border-blue-300 px-6 py-10 bg-blue-50 cursor-pointer relative" className="relative flex justify-center items-center rounded-lg border-2 border-dashed border-blue-300 bg-blue-50 cursor-pointer overflow-hidden transition hover:border-blue-400 hover:bg-blue-100"
style={{ minHeight: '400px' }}
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
onDragOver={e => e.preventDefault()} onDragOver={e => e.preventDefault()}
onDrop={e => { onDrop={e => {
@ -215,19 +217,48 @@ export default function EditSubscriptionPage() {
}} }}
> >
{!previewUrl && !item.pictureUrl && ( {!previewUrl && !item.pictureUrl && (
<div className="text-center w-full"> <div className="text-center w-full px-6 py-10">
<PhotoIcon aria-hidden="true" className="mx-auto h-12 w-12 text-blue-400" /> <PhotoIcon aria-hidden="true" className="mx-auto h-16 w-16 text-blue-400" />
<div className="mt-4 text-sm text-blue-700"> <div className="mt-4 text-base font-medium text-blue-700">
<span>Drag and drop a new image</span> <span>Click or drag and drop a new image here</span>
</div> </div>
<p className="text-xs text-blue-600 mt-2">PNG, JPG, WebP up to 10MB</p> <p className="text-sm text-blue-600 mt-2">PNG, JPG, WebP up to 10MB</p>
</div> </div>
)} )}
{previewUrl && ( {(previewUrl || (!removeExistingPicture && item.pictureUrl)) && (
<img src={previewUrl} alt="Preview" className="absolute inset-0 w-full h-full object-cover rounded-lg" /> <div className="relative w-full h-full min-h-[400px] flex items-center justify-center bg-gray-100 p-6">
<img
src={previewUrl || item.pictureUrl || ''}
alt={previewUrl ? "Preview" : item.title}
className="max-h-[380px] max-w-full object-contain rounded-lg shadow-lg"
/>
<div className="absolute top-4 right-4">
<button
type="button"
onClick={e => {
e.stopPropagation();
if (previewUrl) {
setPictureFile(undefined);
setPreviewUrl(null);
} else if (item.pictureUrl) {
setRemoveExistingPicture(true);
}
}}
className="bg-white/90 backdrop-blur px-3 py-1.5 rounded-lg text-sm font-medium text-red-600 shadow hover:bg-white transition"
>
Remove
</button>
</div>
</div>
)} )}
{!previewUrl && item.pictureUrl && !removeExistingPicture && ( {removeExistingPicture && !previewUrl && (
<img src={item.pictureUrl} alt={item.title} className="absolute inset-0 w-full h-full object-cover rounded-lg" /> <div className="text-center w-full px-6 py-10">
<PhotoIcon aria-hidden="true" className="mx-auto h-16 w-16 text-gray-400" />
<div className="mt-4 text-base font-medium text-gray-600">
<span>Image removed - Click to upload a new one</span>
</div>
<p className="text-sm text-gray-500 mt-2">PNG, JPG, WebP up to 10MB</p>
</div>
)} )}
<input <input
ref={fileInputRef} ref={fileInputRef}
@ -236,18 +267,6 @@ export default function EditSubscriptionPage() {
className="hidden" className="hidden"
onChange={e => handleSelectFile(e.target.files?.[0])} onChange={e => handleSelectFile(e.target.files?.[0])}
/> />
{(previewUrl || item.pictureUrl) && (
<button
type="button"
onClick={e => { e.stopPropagation(); if (previewUrl) { setPictureFile(undefined); setPreviewUrl(null); } else if (item.pictureUrl) { setRemoveExistingPicture(true); } }}
className="absolute top-2 right-2 bg-white/80 backdrop-blur px-2 py-1 rounded text-xs font-medium text-blue-900 shadow z-10"
>Remove</button>
)}
{removeExistingPicture && !previewUrl && (
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-xs text-blue-700">Image removed</span>
</div>
)}
</div> </div>
</div> </div>

View File

@ -1,129 +1,68 @@
import PageLayout from '../components/PageLayout'; 'use client'
const posts = [ import { useEffect, useState } from 'react'
{ import PageLayout from '../components/PageLayout'
id: 1,
title: 'TechInnovate Solutions', type Affiliate = {
href: 'https://example.com/affiliate/techinnovate', id: string
description: name: string
'Leading provider of innovative tech solutions for businesses. Earn commissions on referrals.', description: string
imageUrl: url: string
'https://images.unsplash.com/photo-1496128858413-b36217c2ce36?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=3603&q=80', logoUrl?: string
category: { title: 'Technology', href: '#' }, category: string
}, commissionRate?: string
{ }
id: 2,
title: 'GreenEnergy Corp', // Fallback placeholder image
href: 'https://example.com/affiliate/greenenergy', const PLACEHOLDER_IMAGE = 'https://images.unsplash.com/photo-1557804506-669a67965ba0?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=3270&q=80'
description:
'Sustainable energy products and services. Partner with us for eco-friendly commissions.',
imageUrl:
'https://images.unsplash.com/photo-1547586696-ea22b4d4235d?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=3270&q=80',
category: { title: 'Energy', href: '#' },
},
{
id: 3,
title: 'FinanceHub Advisors',
href: 'https://example.com/affiliate/financehub',
description:
'Expert financial advisory services. Get rewarded for every successful referral.',
imageUrl:
'https://images.unsplash.com/photo-1492724441997-5dc865305da7?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=3270&q=80',
category: { title: 'Finance', href: '#' },
},
{
id: 4,
title: 'HealthWell Clinics',
href: 'https://example.com/affiliate/healthwell',
description:
'Comprehensive healthcare solutions. Affiliate program with competitive payouts.',
imageUrl:
'https://images.unsplash.com/photo-1559136555-9303baea8ebd?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=3270&q=80',
category: { title: 'Healthcare', href: '#' },
},
{
id: 5,
title: 'EduLearn Academy',
href: 'https://example.com/affiliate/edulearn',
description:
'Online education platforms for all ages. Earn from educational referrals.',
imageUrl:
'https://images.unsplash.com/photo-1485217988980-11786ced9454?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=3270&q=80',
category: { title: 'Education', href: '#' },
},
{
id: 6,
title: 'TravelEase Agency',
href: 'https://example.com/affiliate/travelease',
description:
'Seamless travel booking services. Commissions on every trip booked through affiliates.',
imageUrl:
'https://images.unsplash.com/photo-1670272504528-790c24957dda?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=3270&q=80',
category: { title: 'Travel', href: '#' },
},
{
id: 7,
title: 'RetailMax Stores',
href: 'https://example.com/affiliate/retailmax',
description:
'Wide range of retail products. Join our affiliate network for sales commissions.',
imageUrl:
'https://images.unsplash.com/photo-1670272505284-8faba1c31f7d?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=3270&q=80',
category: { title: 'Retail', href: '#' },
},
{
id: 8,
title: 'BuildPro Contractors',
href: 'https://example.com/affiliate/buildpro',
description:
'Professional construction and renovation services. Affiliate rewards for leads.',
imageUrl:
'https://images.unsplash.com/photo-1557804506-669a67965ba0?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=3270&q=80',
category: { title: 'Construction', href: '#' },
},
{
id: 9,
title: 'FoodieDelight Catering',
href: 'https://example.com/affiliate/foodiedelight',
description:
'Delicious catering services for events. Earn commissions on catering bookings.',
imageUrl:
'https://images.unsplash.com/photo-1492724441997-5dc865305da7?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=3270&q=80',
category: { title: 'Food', href: '#' },
},
{
id: 10,
title: 'AutoCare Mechanics',
href: 'https://example.com/affiliate/autocare',
description:
'Reliable automotive repair and maintenance. Affiliate program with steady payouts.',
imageUrl:
'https://images.unsplash.com/photo-1547586696-ea22b4d4235d?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=3270&q=80',
category: { title: 'Automotive', href: '#' },
},
{
id: 11,
title: 'FashionForward Boutique',
href: 'https://example.com/affiliate/fashionforward',
description:
'Trendy fashion and accessories. Commissions on fashion sales through affiliates.',
imageUrl:
'https://images.unsplash.com/photo-1496128858413-b36217c2ce36?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=3270&q=80',
category: { title: 'Fashion', href: '#' },
},
{
id: 12,
title: 'PetCare Essentials',
href: 'https://example.com/affiliate/petcare',
description:
'Everything for your pets. Earn from pet product referrals.',
imageUrl:
'https://images.unsplash.com/photo-1559136555-9303baea8ebd?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=3270&q=80',
category: { title: 'Pets', href: '#' },
},
]
export default function AffiliateLinksPage() { export default function AffiliateLinksPage() {
const [affiliates, setAffiliates] = useState<Affiliate[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
useEffect(() => {
async function fetchAffiliates() {
try {
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'
const res = await fetch(`${BASE_URL}/api/affiliates/active`)
if (!res.ok) {
throw new Error('Failed to fetch affiliates')
}
const data = await res.json()
const activeAffiliates = data.data || []
setAffiliates(activeAffiliates.map((item: any) => ({
id: String(item.id),
name: String(item.name || 'Partner'),
description: String(item.description || ''),
url: String(item.url || '#'),
logoUrl: item.logoUrl || PLACEHOLDER_IMAGE,
category: String(item.category || 'Other'),
commissionRate: item.commission_rate ? String(item.commission_rate) : undefined
})))
} catch (err) {
console.error('Error loading affiliates:', err)
setError('Failed to load affiliate partners')
} finally {
setLoading(false)
}
}
fetchAffiliates()
}, [])
const posts = affiliates.map(affiliate => ({
id: affiliate.id,
title: affiliate.name,
href: affiliate.url,
description: affiliate.description,
imageUrl: affiliate.logoUrl || PLACEHOLDER_IMAGE,
category: { title: affiliate.category, href: '#' },
commissionRate: affiliate.commissionRate
}))
return ( return (
<PageLayout> <PageLayout>
<div className="relative py-24 sm:py-32"> <div className="relative py-24 sm:py-32">
@ -175,8 +114,29 @@ export default function AffiliateLinksPage() {
links. links.
</p> </p>
</div> </div>
<div className="mx-auto mt-16 grid max-w-2xl grid-cols-1 gap-x-8 gap-y-20 lg:mx-0 lg:max-w-none lg:grid-cols-3">
{posts.map((post) => ( {loading && (
<div className="mx-auto mt-16 text-center">
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-indigo-400 border-r-transparent"></div>
<p className="mt-4 text-sm text-gray-400">Loading affiliate partners...</p>
</div>
)}
{error && (
<div className="mx-auto mt-16 max-w-2xl text-center">
<p className="text-red-400">{error}</p>
</div>
)}
{!loading && !error && posts.length === 0 && (
<div className="mx-auto mt-16 max-w-2xl text-center">
<p className="text-gray-400">No affiliate partners available at the moment.</p>
</div>
)}
{!loading && !error && posts.length > 0 && (
<div className="mx-auto mt-16 grid max-w-2xl grid-cols-1 gap-x-8 gap-y-20 lg:mx-0 lg:max-w-none lg:grid-cols-3">
{posts.map((post) => (
<article key={post.id} className="flex flex-col items-start justify-between"> <article key={post.id} className="flex flex-col items-start justify-between">
<div className="relative w-full"> <div className="relative w-full">
<img <img
@ -207,6 +167,11 @@ export default function AffiliateLinksPage() {
</p> </p>
</div> </div>
<div className="relative mt-8 flex items-center gap-x-4 justify-self-end"> <div className="relative mt-8 flex items-center gap-x-4 justify-self-end">
{post.commissionRate && (
<span className="text-xs text-gray-500 border border-gray-700 rounded-full px-2 py-1">
{post.commissionRate}
</span>
)}
<a <a
href={post.href} href={post.href}
target="_blank" target="_blank"
@ -218,8 +183,9 @@ export default function AffiliateLinksPage() {
</div> </div>
</div> </div>
</article> </article>
))} ))}
</div> </div>
)}
</div> </div>
</div> </div>
</PageLayout> </PageLayout>

View File

@ -509,6 +509,13 @@ export default function Header() {
> >
Pool Management Pool Management
</button> </button>
<button
onClick={() => { router.push('/admin/affiliate-management'); setAdminMgmtOpen(false); }}
className="w-full text-left px-4 py-2 text-sm text-[#0F1D37] hover:bg-[#F5F3EE]"
role="menuitem"
>
Affiliate Management
</button>
</div> </div>
</div> </div>
)} )}