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:
parent
0662044b85
commit
20c71636f6
21
package-lock.json
generated
21
package-lock.json
generated
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
84
src/app/admin/affiliate-management/hooks/addAffiliate.ts
Normal file
84
src/app/admin/affiliate-management/hooks/addAffiliate.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
34
src/app/admin/affiliate-management/hooks/deleteAffiliate.ts
Normal file
34
src/app/admin/affiliate-management/hooks/deleteAffiliate.ts
Normal 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 };
|
||||||
|
}
|
||||||
116
src/app/admin/affiliate-management/hooks/getAffiliates.ts
Normal file
116
src/app/admin/affiliate-management/hooks/getAffiliates.ts
Normal 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
80
src/app/admin/affiliate-management/hooks/updateAffiliate.ts
Normal file
80
src/app/admin/affiliate-management/hooks/updateAffiliate.ts
Normal 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 };
|
||||||
|
}
|
||||||
1002
src/app/admin/affiliate-management/page.tsx
Normal file
1002
src/app/admin/affiliate-management/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user