feat: enhance news management and display features
This commit is contained in:
parent
0a8c570610
commit
7d908caec3
@ -4,11 +4,14 @@ export type AdminNewsItem = {
|
|||||||
id: number
|
id: number
|
||||||
title: string
|
title: string
|
||||||
summary?: string
|
summary?: string
|
||||||
|
content?: string
|
||||||
slug: string
|
slug: string
|
||||||
category?: string
|
category?: string
|
||||||
imageUrl?: string
|
imageUrl?: string
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
publishedAt?: string | null
|
publishedAt?: string | null
|
||||||
|
createdAt?: string
|
||||||
|
updatedAt?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAdminNews() {
|
export function useAdminNews() {
|
||||||
@ -32,11 +35,14 @@ export function useAdminNews() {
|
|||||||
id: r.id,
|
id: r.id,
|
||||||
title: r.title,
|
title: r.title,
|
||||||
summary: r.summary,
|
summary: r.summary,
|
||||||
|
content: r.content,
|
||||||
slug: r.slug,
|
slug: r.slug,
|
||||||
category: r.category,
|
category: r.category,
|
||||||
imageUrl: r.imageUrl,
|
imageUrl: r.imageUrl,
|
||||||
isActive: !!r.is_active,
|
isActive: !!r.is_active,
|
||||||
publishedAt: r.published_at || null,
|
publishedAt: r.published_at || null,
|
||||||
|
createdAt: r.created_at,
|
||||||
|
updatedAt: r.updated_at,
|
||||||
}))
|
}))
|
||||||
setItems(data)
|
setItems(data)
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|||||||
@ -15,12 +15,13 @@ export default function NewsManagementPage() {
|
|||||||
const { items, loading, error, refresh } = useAdminNews()
|
const { items, loading, error, refresh } = useAdminNews()
|
||||||
const [showCreate, setShowCreate] = React.useState(false)
|
const [showCreate, setShowCreate] = React.useState(false)
|
||||||
const [selected, setSelected] = React.useState<any | null>(null)
|
const [selected, setSelected] = React.useState<any | null>(null)
|
||||||
|
const [deleteTarget, setDeleteTarget] = React.useState<any | null>(null)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageTransitionEffect>
|
<PageTransitionEffect>
|
||||||
<Header />
|
<Header />
|
||||||
<main className="bg-white">
|
<main className="bg-white min-h-screen pb-20">
|
||||||
<div className="mx-auto max-w-7xl px-6 pt-8">
|
<div className="mx-auto max-w-7xl px-6 pt-8 pb-12">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold text-blue-900">News Manager</h1>
|
<h1 className="text-2xl font-bold text-blue-900">News Manager</h1>
|
||||||
<button onClick={() => setShowCreate(true)} className="flex items-center gap-2 px-4 py-2 bg-blue-900 text-white rounded-lg hover:bg-blue-800">
|
<button onClick={() => setShowCreate(true)} className="flex items-center gap-2 px-4 py-2 bg-blue-900 text-white rounded-lg hover:bg-blue-800">
|
||||||
@ -30,7 +31,7 @@ export default function NewsManagementPage() {
|
|||||||
|
|
||||||
{error && <div className="mt-4 text-red-600">{error}</div>}
|
{error && <div className="mt-4 text-red-600">{error}</div>}
|
||||||
|
|
||||||
<div className="mt-8 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="mt-8 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 pb-8">
|
||||||
{items.map(item => (
|
{items.map(item => (
|
||||||
<div key={item.id} className="rounded-2xl border border-gray-200 overflow-hidden bg-white shadow-sm">
|
<div key={item.id} className="rounded-2xl border border-gray-200 overflow-hidden bg-white shadow-sm">
|
||||||
<div className="aspect-[3/2] bg-gray-100">
|
<div className="aspect-[3/2] bg-gray-100">
|
||||||
@ -48,11 +49,16 @@ export default function NewsManagementPage() {
|
|||||||
<span className={`text-xs px-2 py-1 rounded ${item.isActive ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600'}`}>{item.isActive ? 'Active' : 'Inactive'}</span>
|
<span className={`text-xs px-2 py-1 rounded ${item.isActive ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600'}`}>{item.isActive ? 'Active' : 'Inactive'}</span>
|
||||||
</div>
|
</div>
|
||||||
{item.summary && <p className="mt-2 text-sm text-gray-700 line-clamp-2">{item.summary}</p>}
|
{item.summary && <p className="mt-2 text-sm text-gray-700 line-clamp-2">{item.summary}</p>}
|
||||||
|
<div className="mt-3 space-y-1 text-xs text-gray-500">
|
||||||
|
{item.publishedAt && <div>Published: {new Date(item.publishedAt).toLocaleDateString('de-DE')}</div>}
|
||||||
|
{item.createdAt && <div>Created: {new Date(item.createdAt).toLocaleDateString('de-DE')}</div>}
|
||||||
|
{item.updatedAt && <div>Updated: {new Date(item.updatedAt).toLocaleDateString('de-DE')}</div>}
|
||||||
|
</div>
|
||||||
<div className="mt-4 flex items-center justify-end gap-2">
|
<div className="mt-4 flex items-center justify-end gap-2">
|
||||||
<button onClick={() => setSelected(item)} className="px-3 py-1.5 text-sm bg-blue-50 text-blue-900 rounded hover:bg-blue-100">
|
<button onClick={() => setSelected(item)} className="px-3 py-1.5 text-sm bg-blue-50 text-blue-900 rounded hover:bg-blue-100">
|
||||||
<PencilIcon className="h-4 w-4" />
|
<PencilIcon className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
<button onClick={async () => { await deleteNews(item.id); await refresh() }} className="px-3 py-1.5 text-sm bg-red-50 text-red-700 rounded hover:bg-red-100">
|
<button onClick={() => setDeleteTarget(item)} className="px-3 py-1.5 text-sm bg-red-50 text-red-700 rounded hover:bg-red-100">
|
||||||
<TrashIcon className="h-4 w-4" />
|
<TrashIcon className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -70,6 +76,33 @@ export default function NewsManagementPage() {
|
|||||||
{selected && (
|
{selected && (
|
||||||
<EditNewsModal item={selected} onClose={() => setSelected(null)} onUpdate={async (id, payload) => { await updateNews(id, payload); setSelected(null); await refresh() }} />
|
<EditNewsModal item={selected} onClose={() => setSelected(null)} onUpdate={async (id, payload) => { await updateNews(id, payload); setSelected(null); await refresh() }} />
|
||||||
)}
|
)}
|
||||||
|
{deleteTarget && (
|
||||||
|
<div className="fixed inset-0 z-50">
|
||||||
|
<div className="absolute inset-0 bg-black/30" onClick={() => setDeleteTarget(null)} />
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center p-4">
|
||||||
|
<div className="w-full max-w-md rounded-2xl bg-white shadow-xl ring-1 ring-gray-200">
|
||||||
|
<div className="px-6 pt-6">
|
||||||
|
<h3 className="text-lg font-semibold text-blue-900">Delete news?</h3>
|
||||||
|
<p className="mt-2 text-sm text-gray-700">You are about to delete "{deleteTarget.title}". This action cannot be undone.</p>
|
||||||
|
</div>
|
||||||
|
<div className="px-6 pb-6 pt-4 flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
className="inline-flex items-center rounded-lg px-4 py-2 text-sm font-medium text-gray-700 ring-1 ring-inset ring-gray-300 hover:bg-gray-50"
|
||||||
|
onClick={() => setDeleteTarget(null)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="inline-flex items-center rounded-lg px-4 py-2 text-sm font-semibold text-white bg-red-600 hover:bg-red-500 shadow"
|
||||||
|
onClick={async () => { await deleteNews(deleteTarget.id); setDeleteTarget(null); await refresh(); }}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</PageTransitionEffect>
|
</PageTransitionEffect>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -125,11 +158,11 @@ function CreateNewsModal({ onClose, onCreate }: { onClose: () => void; onCreate:
|
|||||||
<button onClick={onClose} className="text-gray-500 hover:text-gray-700"><XMarkIcon className="h-6 w-6"/></button>
|
<button onClick={onClose} className="text-gray-500 hover:text-gray-700"><XMarkIcon className="h-6 w-6"/></button>
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={submit} className="p-6 space-y-4">
|
<form onSubmit={submit} className="p-6 space-y-4">
|
||||||
<input className="w-full border rounded px-4 py-2" placeholder="Title" value={title} onChange={e=>setTitle(e.target.value)} required />
|
<input className="w-full border rounded px-4 py-2 text-gray-900" placeholder="Title" value={title} onChange={e=>setTitle(e.target.value)} required />
|
||||||
<input className="w-full border rounded px-4 py-2" placeholder="Slug" value={slug} onChange={e=>setSlug(e.target.value)} required />
|
<input className="w-full border rounded px-4 py-2 text-gray-900" placeholder="Slug" value={slug} onChange={e=>setSlug(e.target.value)} required />
|
||||||
<input className="w-full border rounded px-4 py-2" placeholder="Category" value={category} onChange={e=>setCategory(e.target.value)} />
|
<input className="w-full border rounded px-4 py-2 text-gray-900" placeholder="Category" value={category} onChange={e=>setCategory(e.target.value)} />
|
||||||
<textarea className="w-full border rounded px-4 py-2" placeholder="Summary" value={summary} onChange={e=>setSummary(e.target.value)} rows={3} />
|
<textarea className="w-full border rounded px-4 py-2 text-gray-900" placeholder="Summary" value={summary} onChange={e=>setSummary(e.target.value)} rows={3} />
|
||||||
<textarea className="w-full border rounded px-4 py-2" placeholder="Content (markdown/html)" value={content} onChange={e=>setContent(e.target.value)} rows={6} />
|
<textarea className="w-full border rounded px-4 py-2 text-gray-900" placeholder="Content (markdown/html)" value={content} onChange={e=>setContent(e.target.value)} rows={6} />
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-blue-900 mb-2">Image</label>
|
<label className="block text-sm font-medium text-blue-900 mb-2">Image</label>
|
||||||
<div className="relative flex justify-center items-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 cursor-pointer overflow-hidden" style={{ minHeight:'200px' }} onClick={() => (document.getElementById('news-image-upload') as HTMLInputElement)?.click()}>
|
<div className="relative flex justify-center items-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 cursor-pointer overflow-hidden" style={{ minHeight:'200px' }} onClick={() => (document.getElementById('news-image-upload') as HTMLInputElement)?.click()}>
|
||||||
@ -151,10 +184,10 @@ function CreateNewsModal({ onClose, onCreate }: { onClose: () => void; onCreate:
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input id="isActive" type="checkbox" checked={isActive} onChange={e=>setIsActive(e.target.checked)} className="h-4 w-4" />
|
<input id="isActive" type="checkbox" checked={isActive} onChange={e=>setIsActive(e.target.checked)} className="h-4 w-4" />
|
||||||
<label htmlFor="isActive" className="text-sm font-medium text-gray-700">Active</label>
|
<label htmlFor="isActive" className="text-sm font-medium text-gray-700">Active</label>
|
||||||
<input type="datetime-local" value={publishedAt} onChange={e=>setPublishedAt(e.target.value)} className="ml-auto border rounded px-2 py-1 text-sm" />
|
<input type="datetime-local" value={publishedAt} onChange={e=>setPublishedAt(e.target.value)} className="ml-auto border rounded px-2 py-1 text-sm text-gray-900" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-end gap-2 pt-4 border-t">
|
<div className="flex items-center justify-end gap-2 pt-4 border-t">
|
||||||
<button type="button" onClick={onClose} className="px-5 py-2.5 text-sm bg-gray-100 rounded-lg">Cancel</button>
|
<button type="button" onClick={onClose} className="px-5 py-2.5 text-sm bg-red-50 text-red-700 rounded-lg hover:bg-red-100">Cancel</button>
|
||||||
<button type="submit" className="px-5 py-2.5 text-sm text-white bg-blue-900 rounded-lg">Add News</button>
|
<button type="submit" className="px-5 py-2.5 text-sm text-white bg-blue-900 rounded-lg">Add News</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@ -167,7 +200,7 @@ function CreateNewsModal({ onClose, onCreate }: { onClose: () => void; onCreate:
|
|||||||
function EditNewsModal({ item, onClose, onUpdate }: { item: any; onClose: () => void; onUpdate: (id: number, payload: { title?: string; summary?: string; content?: string; slug?: string; category?: string; isActive?: boolean; publishedAt?: string | null; imageFile?: File; removeImage?: boolean }) => void }) {
|
function EditNewsModal({ item, onClose, onUpdate }: { item: any; onClose: () => void; onUpdate: (id: number, payload: { title?: string; summary?: string; content?: string; slug?: string; category?: string; isActive?: boolean; publishedAt?: string | null; imageFile?: File; removeImage?: boolean }) => void }) {
|
||||||
const [title, setTitle] = React.useState(item.title)
|
const [title, setTitle] = React.useState(item.title)
|
||||||
const [summary, setSummary] = React.useState(item.summary || '')
|
const [summary, setSummary] = React.useState(item.summary || '')
|
||||||
const [content, setContent] = React.useState('')
|
const [content, setContent] = React.useState(item.content || '')
|
||||||
const [slug, setSlug] = React.useState(item.slug)
|
const [slug, setSlug] = React.useState(item.slug)
|
||||||
const [category, setCategory] = React.useState(item.category || '')
|
const [category, setCategory] = React.useState(item.category || '')
|
||||||
const [isActive, setIsActive] = React.useState(item.isActive)
|
const [isActive, setIsActive] = React.useState(item.isActive)
|
||||||
@ -220,11 +253,11 @@ function EditNewsModal({ item, onClose, onUpdate }: { item: any; onClose: () =>
|
|||||||
<button onClick={onClose} className="text-gray-500 hover:text-gray-700"><XMarkIcon className="h-6 w-6"/></button>
|
<button onClick={onClose} className="text-gray-500 hover:text-gray-700"><XMarkIcon className="h-6 w-6"/></button>
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={submit} className="p-6 space-y-4">
|
<form onSubmit={submit} className="p-6 space-y-4">
|
||||||
<input className="w-full border rounded px-4 py-2" placeholder="Title" value={title} onChange={e=>setTitle(e.target.value)} required />
|
<input className="w-full border rounded px-4 py-2 text-gray-900" placeholder="Title" value={title} onChange={e=>setTitle(e.target.value)} required />
|
||||||
<input className="w-full border rounded px-4 py-2" placeholder="Slug" value={slug} onChange={e=>setSlug(e.target.value)} required />
|
<input className="w-full border rounded px-4 py-2 text-gray-900" placeholder="Slug" value={slug} onChange={e=>setSlug(e.target.value)} required />
|
||||||
<input className="w-full border rounded px-4 py-2" placeholder="Category" value={category} onChange={e=>setCategory(e.target.value)} />
|
<input className="w-full border rounded px-4 py-2 text-gray-900" placeholder="Category" value={category} onChange={e=>setCategory(e.target.value)} />
|
||||||
<textarea className="w-full border rounded px-4 py-2" placeholder="Summary" value={summary} onChange={e=>setSummary(e.target.value)} rows={3} />
|
<textarea className="w-full border rounded px-4 py-2 text-gray-900" placeholder="Summary" value={summary} onChange={e=>setSummary(e.target.value)} rows={3} />
|
||||||
<textarea className="w-full border rounded px-4 py-2" placeholder="Content (markdown/html)" value={content} onChange={e=>setContent(e.target.value)} rows={6} />
|
<textarea className="w-full border rounded px-4 py-2 text-gray-900" placeholder="Content (markdown/html)" value={content} onChange={e=>setContent(e.target.value)} rows={6} />
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-blue-900 mb-2">Image</label>
|
<label className="block text-sm font-medium text-blue-900 mb-2">Image</label>
|
||||||
<div className="relative flex justify-center items-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 cursor-pointer overflow-hidden" style={{ minHeight:'200px' }} onClick={() => (document.getElementById('edit-news-image-upload') as HTMLInputElement)?.click()}>
|
<div className="relative flex justify-center items-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 cursor-pointer overflow-hidden" style={{ minHeight:'200px' }} onClick={() => (document.getElementById('edit-news-image-upload') as HTMLInputElement)?.click()}>
|
||||||
@ -246,10 +279,10 @@ function EditNewsModal({ item, onClose, onUpdate }: { item: any; onClose: () =>
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input id="editIsActive" type="checkbox" checked={isActive} onChange={e=>setIsActive(e.target.checked)} className="h-4 w-4" />
|
<input id="editIsActive" type="checkbox" checked={isActive} onChange={e=>setIsActive(e.target.checked)} className="h-4 w-4" />
|
||||||
<label htmlFor="editIsActive" className="text-sm font-medium text-gray-700">Active</label>
|
<label htmlFor="editIsActive" className="text-sm font-medium text-gray-700">Active</label>
|
||||||
<input type="datetime-local" value={publishedAt || ''} onChange={e=>setPublishedAt(e.target.value)} className="ml-auto border rounded px-2 py-1 text-sm" />
|
<input type="datetime-local" value={publishedAt || ''} onChange={e=>setPublishedAt(e.target.value)} className="ml-auto border rounded px-2 py-1 text-sm text-gray-900" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-end gap-2 pt-4 border-t">
|
<div className="flex items-center justify-end gap-2 pt-4 border-t">
|
||||||
<button type="button" onClick={onClose} className="px-5 py-2.5 text-sm bg-gray-100 rounded-lg">Cancel</button>
|
<button type="button" onClick={onClose} className="px-5 py-2.5 text-sm bg-red-50 text-red-700 rounded-lg hover:bg-red-100">Cancel</button>
|
||||||
<button type="submit" className="px-5 py-2.5 text-sm text-white bg-blue-900 rounded-lg">Save Changes</button>
|
<button type="submit" className="px-5 py-2.5 text-sm text-white bg-blue-900 rounded-lg">Save Changes</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -211,6 +211,22 @@ export default function AdminDashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
<ArrowRightIcon className="h-5 w-5 text-blue-600 opacity-70 group-hover:opacity-100" />
|
<ArrowRightIcon className="h-5 w-5 text-blue-600 opacity-70 group-hover:opacity-100" />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.push('/admin/news-management')}
|
||||||
|
className="group w-full flex items-center justify-between rounded-lg border border-green-200 bg-green-50 hover:bg-green-100 px-4 py-4 transition"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="inline-flex h-10 w-10 items-center justify-center rounded-md bg-green-100 border border-green-200">
|
||||||
|
<ClipboardDocumentListIcon className="h-6 w-6 text-green-600" />
|
||||||
|
</span>
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="text-base font-semibold text-green-900">News Management</div>
|
||||||
|
<div className="text-xs text-green-700">Create and manage news articles</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ArrowRightIcon className="h-5 w-5 text-green-600 opacity-70 group-hover:opacity-100" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -35,10 +35,14 @@ const shopItems = [
|
|||||||
{ name: 'Public', href: '/shop/public', description: 'Open catalog for everyone', icon: UsersIcon },
|
{ name: 'Public', href: '/shop/public', description: 'Open catalog for everyone', icon: UsersIcon },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const informationItems = [
|
||||||
|
{ name: 'Affiliate-Links', href: '/affiliate-links', description: 'Browse our partner links' },
|
||||||
|
{ name: 'Memberships', href: '/memberships', description: 'Explore membership options' },
|
||||||
|
{ name: 'About us', href: '/about-us', description: 'Learn more about us' },
|
||||||
|
];
|
||||||
|
|
||||||
const navLinks = [
|
const navLinks = [
|
||||||
{ name: 'Affiliate-Links', href: '/affiliate-links' },
|
{ name: 'News', href: '/news' },
|
||||||
{ name: 'Memberships', href: '/memberships' },
|
|
||||||
{ name: 'About us', href: '/about-us' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// Toggle visibility of Shop navigation across header (desktop + mobile)
|
// Toggle visibility of Shop navigation across header (desktop + mobile)
|
||||||
@ -288,16 +292,18 @@ export default function Header() {
|
|||||||
</Popover>
|
</Popover>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Affiliate Links */}
|
{/* Navigation Links */}
|
||||||
|
{navLinks.map((link) => (
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push('/affiliate-links')}
|
key={link.href}
|
||||||
|
onClick={() => router.push(link.href)}
|
||||||
className="text-sm/6 font-semibold text-gray-900 dark:text-white hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
|
className="text-sm/6 font-semibold text-gray-900 dark:text-white hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
|
||||||
>
|
>
|
||||||
Affiliate-Links
|
{link.name}
|
||||||
</button>
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
{/* Remove Memberships */}
|
{/* Conditional user-specific links */}
|
||||||
{/* Referral Management - match others (no highlight) */}
|
|
||||||
{userPresent && hasReferralPerm && (
|
{userPresent && hasReferralPerm && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
@ -306,14 +312,12 @@ export default function Header() {
|
|||||||
>
|
>
|
||||||
Referral Management
|
Referral Management
|
||||||
</button>
|
</button>
|
||||||
{/* New: Personal Matrix */}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push('/personal-matrix')}
|
onClick={() => router.push('/personal-matrix')}
|
||||||
className="text-sm/6 font-semibold text-gray-900 dark:text-white hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
|
className="text-sm/6 font-semibold text-gray-900 dark:text-white hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
|
||||||
>
|
>
|
||||||
Personal Matrix
|
Personal Matrix
|
||||||
</button>
|
</button>
|
||||||
{/* New: Coffee Abonnements */}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push('/coffee-abonnements')}
|
onClick={() => router.push('/coffee-abonnements')}
|
||||||
className="text-sm/6 font-semibold text-gray-900 dark:text-white hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
|
className="text-sm/6 font-semibold text-gray-900 dark:text-white hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
|
||||||
@ -323,13 +327,42 @@ export default function Header() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* About us */}
|
{/* Information dropdown - moved to far right */}
|
||||||
<button
|
<Popover>
|
||||||
onClick={() => router.push('/about-us')}
|
<PopoverButton className="flex items-center gap-x-1 text-sm/6 font-semibold text-gray-900 dark:text-white">
|
||||||
className="text-sm/6 font-semibold text-gray-900 dark:text-white hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
|
Information
|
||||||
|
<ChevronDownIcon aria-hidden="true" className="size-5 flex-none text-gray-500" />
|
||||||
|
</PopoverButton>
|
||||||
|
<PopoverPanel
|
||||||
|
transition
|
||||||
|
className="absolute left-0 right-0 top-full z-50 rounded-b-2xl shadow-xl shadow-black/40 ring-1 ring-white/10 dark:ring-white/15 overflow-hidden data-closed:-translate-y-1 data-closed:opacity-0 data-enter:duration-200 data-enter:ease-out data-leave:duration-150 data-leave:ease-in"
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(150deg, rgba(26,46,84,0.95) 0%, rgba(18,37,70,0.92) 45%, rgba(30,56,104,0.88) 100%)',
|
||||||
|
backdropFilter: 'blur(26px) saturate(175%)',
|
||||||
|
WebkitBackdropFilter: 'blur(26px) saturate(175%)'
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
About us
|
<div className="relative before:absolute before:inset-0 before:pointer-events-none before:bg-[radial-gradient(circle_at_18%_30%,rgba(56,124,255,0.30),transparent_62%),radial-gradient(circle_at_82%_40%,rgba(139,92,246,0.22),transparent_65%)]">
|
||||||
|
<div className="mx-auto grid max-w-7xl grid-cols-1 md:grid-cols-3 gap-x-4 px-6 py-10 lg:px-8 xl:gap-x-8">
|
||||||
|
{informationItems.map(item => (
|
||||||
|
<div
|
||||||
|
key={item.name}
|
||||||
|
className="group relative rounded-lg p-6 text-sm/6 hover:bg-white/5 transition-colors"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push(item.href)}
|
||||||
|
className="block font-semibold text-white"
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
<span className="absolute inset-0" />
|
||||||
</button>
|
</button>
|
||||||
|
<p className="mt-1 text-gray-300">{item.description}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverPanel>
|
||||||
|
</Popover>
|
||||||
</PopoverGroup>
|
</PopoverGroup>
|
||||||
<div className="hidden lg:flex lg:flex-1 lg:justify-end lg:items-center lg:gap-x-4">
|
<div className="hidden lg:flex lg:flex-1 lg:justify-end lg:items-center lg:gap-x-4">
|
||||||
{/* Stable auth slot to avoid SSR/CSR structural drift */}
|
{/* Stable auth slot to avoid SSR/CSR structural drift */}
|
||||||
@ -516,6 +549,13 @@ export default function Header() {
|
|||||||
>
|
>
|
||||||
Affiliate Management
|
Affiliate Management
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { router.push('/admin/news-management'); setAdminMgmtOpen(false); }}
|
||||||
|
className="w-full text-left px-4 py-2 text-sm text-[#0F1D37] hover:bg-[#F5F3EE]"
|
||||||
|
role="menuitem"
|
||||||
|
>
|
||||||
|
News Management
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -657,15 +697,36 @@ export default function Header() {
|
|||||||
</DisclosurePanel>
|
</DisclosurePanel>
|
||||||
</Disclosure>
|
</Disclosure>
|
||||||
)}
|
)}
|
||||||
{/* Affiliate Links */}
|
{/* Information disclosure */}
|
||||||
|
<Disclosure as="div">
|
||||||
|
<DisclosureButton className="group flex w-full items-center justify-between rounded-lg py-2 px-3 text-base/7 font-semibold text-gray-900 dark:text-white hover:bg-white/5">
|
||||||
|
Information
|
||||||
|
<ChevronDownIcon aria-hidden="true" className="size-5 flex-none group-data-open:rotate-180" />
|
||||||
|
</DisclosureButton>
|
||||||
|
<DisclosurePanel className="mt-2 space-y-1">
|
||||||
|
{informationItems.map(item => (
|
||||||
|
<DisclosureButton
|
||||||
|
key={item.name}
|
||||||
|
as="button"
|
||||||
|
onClick={() => { router.push(item.href); setMobileMenuOpen(false); }}
|
||||||
|
className="block rounded-lg py-2 pl-6 pr-3 text-sm/7 font-semibold text-gray-900 dark:text-white hover:bg-white/5 w-full text-left"
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</DisclosureButton>
|
||||||
|
))}
|
||||||
|
</DisclosurePanel>
|
||||||
|
</Disclosure>
|
||||||
|
{/* Navigation Links */}
|
||||||
|
{navLinks.map((link) => (
|
||||||
<button
|
<button
|
||||||
onClick={() => { router.push('/affiliate-links'); setMobileMenuOpen(false); }}
|
key={link.href}
|
||||||
|
onClick={() => { router.push(link.href); setMobileMenuOpen(false); }}
|
||||||
className="block rounded-lg px-3 py-2 text-base/7 font-semibold text-gray-900 dark:text-white hover:bg-white/5 w-full text-left"
|
className="block rounded-lg px-3 py-2 text-base/7 font-semibold text-gray-900 dark:text-white hover:bg-white/5 w-full text-left"
|
||||||
>
|
>
|
||||||
Affiliate-Links
|
{link.name}
|
||||||
</button>
|
</button>
|
||||||
{/* Remove Memberships */}
|
))}
|
||||||
{/* Referral Management + new items */}
|
{/* Conditional user-specific links */}
|
||||||
{hasReferralPerm && (
|
{hasReferralPerm && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
@ -688,13 +749,6 @@ export default function Header() {
|
|||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{/* About us */}
|
|
||||||
<button
|
|
||||||
onClick={() => { router.push('/about-us'); setMobileMenuOpen(false); }}
|
|
||||||
className="block rounded-lg px-3 py-2 text-base/7 font-semibold text-gray-900 dark:text-white hover:bg-white/5 w-full text-left"
|
|
||||||
>
|
|
||||||
About us
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@ -719,19 +773,35 @@ export default function Header() {
|
|||||||
</DisclosurePanel>
|
</DisclosurePanel>
|
||||||
</Disclosure>
|
</Disclosure>
|
||||||
)}
|
)}
|
||||||
|
{/* Information disclosure */}
|
||||||
|
<Disclosure as="div">
|
||||||
|
<DisclosureButton className="group flex w-full items-center justify-between rounded-lg py-2 px-3 text-base/7 font-semibold text-gray-900 dark:text-white hover:bg-white/5">
|
||||||
|
Information
|
||||||
|
<ChevronDownIcon aria-hidden="true" className="size-5 flex-none group-data-open:rotate-180" />
|
||||||
|
</DisclosureButton>
|
||||||
|
<DisclosurePanel className="mt-2 space-y-1">
|
||||||
|
{informationItems.map(item => (
|
||||||
|
<DisclosureButton
|
||||||
|
key={item.name}
|
||||||
|
as="button"
|
||||||
|
onClick={() => { router.push(item.href); setMobileMenuOpen(false); }}
|
||||||
|
className="block rounded-lg py-2 pl-6 pr-3 text-sm/7 font-semibold text-gray-900 dark:text-white hover:bg-white/5 w-full text-left"
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</DisclosureButton>
|
||||||
|
))}
|
||||||
|
</DisclosurePanel>
|
||||||
|
</Disclosure>
|
||||||
|
{/* Navigation Links */}
|
||||||
|
{navLinks.map((link) => (
|
||||||
<button
|
<button
|
||||||
onClick={() => { router.push('/affiliate-links'); setMobileMenuOpen(false); }}
|
key={link.href}
|
||||||
|
onClick={() => { router.push(link.href); setMobileMenuOpen(false); }}
|
||||||
className="block rounded-lg px-3 py-2 text-base/7 font-semibold text-gray-900 dark:text-white hover:bg-white/5 w-full text-left"
|
className="block rounded-lg px-3 py-2 text-base/7 font-semibold text-gray-900 dark:text-white hover:bg-white/5 w-full text-left"
|
||||||
>
|
>
|
||||||
Affiliate-Links
|
{link.name}
|
||||||
</button>
|
|
||||||
{/* Remove Memberships */}
|
|
||||||
<button
|
|
||||||
onClick={() => { router.push('/about-us'); setMobileMenuOpen(false); }}
|
|
||||||
className="block rounded-lg px-3 py-2 text-base/7 font-semibold text-gray-900 dark:text-white hover:bg-white/5 w-full text-left"
|
|
||||||
>
|
|
||||||
About us
|
|
||||||
</button>
|
</button>
|
||||||
|
))}
|
||||||
{/* Language (English-only) in logged-out mobile */}
|
{/* Language (English-only) in logged-out mobile */}
|
||||||
<div className="px-3">
|
<div className="px-3">
|
||||||
<Disclosure as="div">
|
<Disclosure as="div">
|
||||||
|
|||||||
@ -4,28 +4,68 @@ import React from 'react'
|
|||||||
import Header from '../components/nav/Header'
|
import Header from '../components/nav/Header'
|
||||||
import Footer from '../components/Footer'
|
import Footer from '../components/Footer'
|
||||||
import PageTransitionEffect from '../components/animation/pageTransitionEffect'
|
import PageTransitionEffect from '../components/animation/pageTransitionEffect'
|
||||||
|
import { XMarkIcon } from '@heroicons/react/24/outline'
|
||||||
|
|
||||||
type PublicNewsItem = {
|
type PublicNewsItem = {
|
||||||
id: number
|
id: number
|
||||||
title: string
|
title: string
|
||||||
summary?: string
|
summary?: string
|
||||||
|
content?: string
|
||||||
slug: string
|
slug: string
|
||||||
category?: string
|
category?: string
|
||||||
imageUrl?: string
|
imageUrl?: string
|
||||||
published_at?: string | null
|
published_at?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function NewsDetailModal({ item, onClose }: { item: PublicNewsItem; onClose: () => void }) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50">
|
||||||
|
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center p-4">
|
||||||
|
<div className="relative w-full max-w-4xl bg-white rounded-2xl shadow-2xl max-h-[90vh] flex flex-col">
|
||||||
|
<div className="flex-shrink-0 bg-white border-b px-6 py-4 flex items-center justify-between rounded-t-2xl">
|
||||||
|
<h2 className="text-2xl font-bold text-blue-900 pr-4">{item.title}</h2>
|
||||||
|
<button onClick={onClose} className="text-gray-500 hover:text-gray-700 flex-shrink-0">
|
||||||
|
<XMarkIcon className="h-6 w-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 overflow-y-auto">
|
||||||
|
{item.imageUrl && (
|
||||||
|
<div className="rounded-xl overflow-hidden mb-6 max-h-80 flex items-center justify-center bg-gray-50">
|
||||||
|
<img src={item.imageUrl} alt={item.title} className="w-full h-full object-contain" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{item.published_at && (
|
||||||
|
<div className="text-sm text-gray-500 mb-4">
|
||||||
|
Published: {new Date(item.published_at).toLocaleDateString('de-DE')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{item.summary && (
|
||||||
|
<div className="text-lg text-gray-700 mb-6 font-medium">{item.summary}</div>
|
||||||
|
)}
|
||||||
|
<div className="prose prose-lg max-w-none text-gray-800 whitespace-pre-wrap break-words overflow-wrap-anywhere">
|
||||||
|
{item.content || 'No content available.'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function NewsPage() {
|
export default function NewsPage() {
|
||||||
const [items, setItems] = React.useState<PublicNewsItem[]>([])
|
const [items, setItems] = React.useState<PublicNewsItem[]>([])
|
||||||
const [loading, setLoading] = React.useState(false)
|
const [loading, setLoading] = React.useState(false)
|
||||||
const [error, setError] = React.useState<string | null>(null)
|
const [error, setError] = React.useState<string | null>(null)
|
||||||
|
const [selectedItem, setSelectedItem] = React.useState<PublicNewsItem | null>(null)
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/news/active')
|
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'
|
||||||
|
const res = await fetch(`${BASE_URL}/api/news/active`)
|
||||||
if (!res.ok) throw new Error('Failed to fetch news')
|
if (!res.ok) throw new Error('Failed to fetch news')
|
||||||
const json = await res.json()
|
const json = await res.json()
|
||||||
const data = (json.data || [])
|
const data = (json.data || [])
|
||||||
@ -41,34 +81,80 @@ export default function NewsPage() {
|
|||||||
return (
|
return (
|
||||||
<PageTransitionEffect>
|
<PageTransitionEffect>
|
||||||
<Header />
|
<Header />
|
||||||
<main className="bg-white">
|
<main className="bg-gradient-to-b from-blue-50 to-white min-h-screen">
|
||||||
<div className="mx-auto max-w-7xl px-6 pt-8">
|
{/* Hero Section */}
|
||||||
<h1 className="text-2xl font-bold text-blue-900">Latest News</h1>
|
<div className="bg-blue-900 text-white py-16">
|
||||||
{loading && <div className="mt-4">Loading...</div>}
|
<div className="mx-auto max-w-7xl px-6">
|
||||||
{error && <div className="mt-4 text-red-600">{error}</div>}
|
<h1 className="text-5xl font-bold mb-4">News & Updates</h1>
|
||||||
{!loading && !error && items.length === 0 && <div className="mt-4 text-gray-600">No news available.</div>}
|
<p className="text-xl text-blue-100">Stay informed with our latest announcements and insights</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-8 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="mx-auto max-w-7xl px-6 py-12">
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 border border-red-200 text-red-700 px-6 py-4 rounded-lg mb-6">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="text-center py-12 text-gray-500">Loading news...</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && items.length === 0 && !error && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-gray-500 text-lg">No news articles available yet.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && items.length > 0 && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
{items.map(item => (
|
{items.map(item => (
|
||||||
<article key={item.id} className="rounded-2xl border border-gray-200 overflow-hidden bg-white shadow-sm">
|
<article
|
||||||
<div className="aspect-[3/2] bg-gray-100">
|
key={item.id}
|
||||||
|
onClick={() => setSelectedItem(item)}
|
||||||
|
className="bg-white rounded-2xl shadow-lg overflow-hidden hover:shadow-2xl transition-all duration-300 cursor-pointer transform hover:-translate-y-1"
|
||||||
|
>
|
||||||
|
<div className="aspect-[3/2] bg-gradient-to-br from-blue-100 to-blue-50">
|
||||||
{item.imageUrl ? (
|
{item.imageUrl ? (
|
||||||
<img src={item.imageUrl} alt={item.title} className="h-full w-full object-cover" />
|
<img src={item.imageUrl} alt={item.title} className="h-full w-full object-cover" />
|
||||||
) : (
|
) : (
|
||||||
<div className="h-full w-full flex items-center justify-center text-gray-400">No image</div>
|
<div className="h-full w-full flex items-center justify-center">
|
||||||
|
<div className="text-blue-300 text-6xl font-bold">P²</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4">
|
<div className="p-6">
|
||||||
<h2 className="font-semibold text-blue-900">{item.title}</h2>
|
<h2 className="text-xl font-bold text-blue-900 mb-3 line-clamp-2 hover:text-blue-700">
|
||||||
{item.published_at && <div className="text-xs text-gray-500 mt-1">{new Date(item.published_at).toLocaleString()}</div>}
|
{item.title}
|
||||||
{item.summary && <p className="mt-2 text-sm text-gray-700 line-clamp-3">{item.summary}</p>}
|
</h2>
|
||||||
|
{item.summary && (
|
||||||
|
<p className="text-gray-700 mb-4 line-clamp-3">{item.summary}</p>
|
||||||
|
)}
|
||||||
|
{item.published_at && (
|
||||||
|
<div className="text-sm text-gray-500 mb-4">
|
||||||
|
{new Date(item.published_at).toLocaleDateString('de-DE')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button className="inline-flex items-center text-blue-900 font-semibold hover:text-blue-700">
|
||||||
|
Read More
|
||||||
|
<svg className="ml-2 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
|
|
||||||
|
{selectedItem && (
|
||||||
|
<NewsDetailModal item={selectedItem} onClose={() => setSelectedItem(null)} />
|
||||||
|
)}
|
||||||
</PageTransitionEffect>
|
</PageTransitionEffect>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user