feat: invoice
This commit is contained in:
parent
7d908caec3
commit
20c39fcd4e
93
src/app/admin/finance-management/hooks/getInvoices.ts
Normal file
93
src/app/admin/finance-management/hooks/getInvoices.ts
Normal file
@ -0,0 +1,93 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import useAuthStore from '../../../store/authStore';
|
||||
|
||||
export type AdminInvoice = {
|
||||
id: string | number;
|
||||
invoice_number?: string | null;
|
||||
user_id?: string | number | null;
|
||||
buyer_name?: string | null;
|
||||
buyer_email?: string | null;
|
||||
buyer_street?: string | null;
|
||||
buyer_postal_code?: string | null;
|
||||
buyer_city?: string | null;
|
||||
buyer_country?: string | null;
|
||||
currency?: string | null;
|
||||
total_net?: number | null;
|
||||
total_tax?: number | null;
|
||||
total_gross?: number | null;
|
||||
vat_rate?: number | null;
|
||||
status?: string;
|
||||
issued_at?: string | null;
|
||||
due_at?: string | null;
|
||||
pdf_storage_key?: string | null;
|
||||
context?: any | null;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
};
|
||||
|
||||
export function useAdminInvoices(params?: { status?: string; limit?: number; offset?: number }) {
|
||||
const accessToken = useAuthStore(s => s.accessToken);
|
||||
const [invoices, setInvoices] = useState<AdminInvoice[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string>('');
|
||||
const inFlight = useRef<AbortController | null>(null);
|
||||
|
||||
const fetchInvoices = useCallback(async () => {
|
||||
setError('');
|
||||
// Abort previous
|
||||
inFlight.current?.abort();
|
||||
const controller = new AbortController();
|
||||
inFlight.current = controller;
|
||||
|
||||
try {
|
||||
const base = process.env.NEXT_PUBLIC_API_BASE_URL || '';
|
||||
const qp = new URLSearchParams();
|
||||
if (params?.status) qp.set('status', params.status);
|
||||
qp.set('limit', String(params?.limit ?? 200));
|
||||
qp.set('offset', String(params?.offset ?? 0));
|
||||
const url = `${base}/api/admin/invoices${qp.toString() ? `?${qp.toString()}` : ''}`;
|
||||
|
||||
setLoading(true);
|
||||
const res = await fetch(url, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok || body?.success === false) {
|
||||
setInvoices([]);
|
||||
setError(body?.message || `Failed to load invoices (${res.status})`);
|
||||
return;
|
||||
}
|
||||
const list: AdminInvoice[] = Array.isArray(body?.data) ? body.data : [];
|
||||
// sort fallback (issued_at DESC then created_at DESC)
|
||||
list.sort((a, b) => {
|
||||
const ad = new Date(a.issued_at ?? a.created_at ?? 0).getTime();
|
||||
const bd = new Date(b.issued_at ?? b.created_at ?? 0).getTime();
|
||||
return bd - ad;
|
||||
});
|
||||
setInvoices(list);
|
||||
} catch (e: any) {
|
||||
if (e?.name === 'AbortError') return;
|
||||
setError(e?.message || 'Network error');
|
||||
setInvoices([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (inFlight.current === controller) inFlight.current = null;
|
||||
}
|
||||
}, [accessToken, params?.status, params?.limit, params?.offset]);
|
||||
|
||||
useEffect(() => {
|
||||
if (accessToken) fetchInvoices();
|
||||
return () => inFlight.current?.abort();
|
||||
}, [accessToken, fetchInvoices]);
|
||||
|
||||
return { invoices, loading, error, reload: fetchInvoices };
|
||||
}
|
||||
@ -3,23 +3,7 @@ import React, { useMemo, useState } from 'react'
|
||||
import PageLayout from '../../components/PageLayout'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useVatRates } from './hooks/getTaxes'
|
||||
|
||||
type VatRate = { country: string; code: string; rate: number }
|
||||
type Bill = {
|
||||
id: string
|
||||
customer: string
|
||||
amount: number
|
||||
currency: string
|
||||
date: string
|
||||
status: 'paid' | 'open' | 'overdue'
|
||||
}
|
||||
|
||||
const dummyBills: Bill[] = [
|
||||
{ id: 'INV-1001', customer: 'Acme GmbH', amount: 1200, currency: 'EUR', date: '2025-12-01', status: 'paid' },
|
||||
{ id: 'INV-1002', customer: 'Beta SARL', amount: 860, currency: 'EUR', date: '2025-11-20', status: 'open' },
|
||||
{ id: 'INV-1003', customer: 'Charlie SpA', amount: 540, currency: 'EUR', date: '2025-11-15', status: 'overdue' },
|
||||
{ id: 'INV-1004', customer: 'Delta BV', amount: 2300, currency: 'EUR', date: '2025-10-02', status: 'paid' },
|
||||
]
|
||||
import { useAdminInvoices } from './hooks/getInvoices'
|
||||
|
||||
export default function FinanceManagementPage() {
|
||||
const router = useRouter()
|
||||
@ -27,38 +11,60 @@ export default function FinanceManagementPage() {
|
||||
const [timeframe, setTimeframe] = useState<'7d' | '30d' | '90d' | 'ytd'>('30d')
|
||||
const [billFilter, setBillFilter] = useState({ query: '', status: 'all', from: '', to: '' })
|
||||
|
||||
// NEW: fetch invoices from backend
|
||||
const {
|
||||
invoices,
|
||||
loading: invLoading,
|
||||
error: invError,
|
||||
reload,
|
||||
} = useAdminInvoices({
|
||||
status: billFilter.status !== 'all' ? billFilter.status : undefined,
|
||||
limit: 200,
|
||||
offset: 0,
|
||||
})
|
||||
|
||||
// NEW: totals from backend invoices
|
||||
const totals = useMemo(() => {
|
||||
const now = new Date()
|
||||
const filterDate = (d: string) => new Date(d)
|
||||
const inRange = (d: Date) => {
|
||||
const diff = (now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24)
|
||||
if (timeframe === '7d') return diff <= 7
|
||||
if (timeframe === '30d') return diff <= 30
|
||||
if (timeframe === '90d') return diff <= 90
|
||||
return true // ytd or default
|
||||
return true
|
||||
}
|
||||
const filtered = dummyBills.filter(b => inRange(filterDate(b.date)))
|
||||
const total = dummyBills.reduce((s, b) => s + b.amount, 0)
|
||||
const totalRange = filtered.reduce((s, b) => s + b.amount, 0)
|
||||
return { totalAll: total, totalRange }
|
||||
}, [timeframe])
|
||||
|
||||
const filteredBills = useMemo(() => {
|
||||
return dummyBills.filter(b => {
|
||||
const matchesQuery =
|
||||
billFilter.query === '' ||
|
||||
b.id.toLowerCase().includes(billFilter.query.toLowerCase()) ||
|
||||
b.customer.toLowerCase().includes(billFilter.query.toLowerCase())
|
||||
const matchesStatus = billFilter.status === 'all' || b.status === billFilter.status
|
||||
const fromOk = billFilter.from ? new Date(b.date) >= new Date(billFilter.from) : true
|
||||
const toOk = billFilter.to ? new Date(b.date) <= new Date(billFilter.to) : true
|
||||
return matchesQuery && matchesStatus && fromOk && toOk
|
||||
const range = invoices.filter(inv => {
|
||||
const dStr = inv.issued_at ?? inv.created_at
|
||||
if (!dStr) return false
|
||||
const d = new Date(dStr)
|
||||
return inRange(d)
|
||||
})
|
||||
}, [billFilter])
|
||||
const totalAll = invoices.reduce((s, inv) => s + Number(inv.total_gross ?? 0), 0)
|
||||
const totalRange = range.reduce((s, inv) => s + Number(inv.total_gross ?? 0), 0)
|
||||
return { totalAll, totalRange }
|
||||
}, [invoices, timeframe])
|
||||
|
||||
// NEW: filtered rows for table
|
||||
const filteredBills = useMemo(() => {
|
||||
const q = billFilter.query.trim().toLowerCase()
|
||||
const from = billFilter.from ? new Date(billFilter.from) : null
|
||||
const to = billFilter.to ? new Date(billFilter.to) : null
|
||||
|
||||
return invoices.filter(inv => {
|
||||
const byQuery =
|
||||
!q ||
|
||||
String(inv.invoice_number ?? inv.id).toLowerCase().includes(q) ||
|
||||
String(inv.buyer_name ?? '').toLowerCase().includes(q)
|
||||
const issued = inv.issued_at ? new Date(inv.issued_at) : (inv.created_at ? new Date(inv.created_at) : null)
|
||||
const byFrom = from ? (issued ? issued >= from : false) : true
|
||||
const byTo = to ? (issued ? issued <= to : false) : true
|
||||
return byQuery && byFrom && byTo
|
||||
})
|
||||
}, [invoices, billFilter])
|
||||
|
||||
const exportBills = (format: 'csv' | 'pdf') => {
|
||||
console.log('[export]', format, { filters: billFilter, bills: filteredBills })
|
||||
alert(`Export ${format.toUpperCase()} (dummy) for ${filteredBills.length} bills`)
|
||||
console.log('[export]', format, { filters: billFilter, invoices: filteredBills })
|
||||
alert(`Export ${format.toUpperCase()} (dummy) for ${filteredBills.length} invoices`)
|
||||
}
|
||||
|
||||
return (
|
||||
@ -129,12 +135,13 @@ export default function FinanceManagementPage() {
|
||||
<div className="flex flex-wrap gap-2 text-sm">
|
||||
<button onClick={() => exportBills('csv')} className="rounded-lg border border-gray-200 px-3 py-2 hover:bg-gray-50">Export CSV</button>
|
||||
<button onClick={() => exportBills('pdf')} className="rounded-lg border border-gray-200 px-3 py-2 hover:bg-gray-50">Export PDF</button>
|
||||
<button onClick={reload} className="rounded-lg border border-gray-200 px-3 py-2 hover:bg-gray-50">Reload</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-4 text-sm">
|
||||
<input
|
||||
placeholder="Search (ID, customer)"
|
||||
placeholder="Search (invoice no., customer)"
|
||||
value={billFilter.query}
|
||||
onChange={e => setBillFilter(f => ({ ...f, query: e.target.value }))}
|
||||
className="rounded-lg border border-gray-200 px-3 py-2 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
|
||||
@ -145,9 +152,11 @@ export default function FinanceManagementPage() {
|
||||
className="rounded-lg border border-gray-200 px-3 py-2 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
|
||||
>
|
||||
<option value="all">Status: All</option>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="issued">Issued</option>
|
||||
<option value="paid">Paid</option>
|
||||
<option value="open">Open</option>
|
||||
<option value="overdue">Overdue</option>
|
||||
<option value="canceled">Canceled</option>
|
||||
</select>
|
||||
<input
|
||||
type="date"
|
||||
@ -164,35 +173,59 @@ export default function FinanceManagementPage() {
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
{invError && (
|
||||
<div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 mb-3">
|
||||
{invError}
|
||||
</div>
|
||||
)}
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-blue-50 text-left text-blue-900">
|
||||
<th className="px-3 py-2 font-semibold">Invoice</th>
|
||||
<th className="px-3 py-2 font-semibold">Customer</th>
|
||||
<th className="px-3 py-2 font-semibold">Date</th>
|
||||
<th className="px-3 py-2 font-semibold">Issued</th>
|
||||
<th className="px-3 py-2 font-semibold">Amount</th>
|
||||
<th className="px-3 py-2 font-semibold">Status</th>
|
||||
<th className="px-3 py-2 font-semibold">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{filteredBills.map(b => (
|
||||
<tr key={b.id} className="border-b last:border-0">
|
||||
<td className="px-3 py-2">{b.id}</td>
|
||||
<td className="px-3 py-2">{b.customer}</td>
|
||||
<td className="px-3 py-2">{new Date(b.date).toLocaleDateString()}</td>
|
||||
<td className="px-3 py-2">€{b.amount.toFixed(2)}</td>
|
||||
{invLoading ? (
|
||||
<>
|
||||
<tr><td colSpan={6} className="px-3 py-3"><div className="h-4 w-40 bg-gray-200 animate-pulse rounded" /></td></tr>
|
||||
<tr><td colSpan={6} className="px-3 py-3"><div className="h-4 w-3/4 bg-gray-200 animate-pulse rounded" /></td></tr>
|
||||
</>
|
||||
) : filteredBills.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-3 py-4 text-center text-gray-500">
|
||||
Keine Rechnungen gefunden.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredBills.map(inv => (
|
||||
<tr key={inv.id} className="border-b last:border-0">
|
||||
<td className="px-3 py-2">{inv.invoice_number ?? inv.id}</td>
|
||||
<td className="px-3 py-2">{inv.buyer_name ?? '—'}</td>
|
||||
<td className="px-3 py-2">{inv.issued_at ? new Date(inv.issued_at).toLocaleDateString() : '—'}</td>
|
||||
<td className="px-3 py-2">
|
||||
€{Number(inv.total_gross ?? 0).toFixed(2)}{' '}
|
||||
<span className="text-xs text-gray-500">{inv.currency ?? 'EUR'}</span>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-xs font-semibold ${
|
||||
b.status === 'paid'
|
||||
inv.status === 'paid'
|
||||
? 'bg-green-100 text-green-700'
|
||||
: b.status === 'open'
|
||||
? 'bg-yellow-100 text-yellow-700'
|
||||
: 'bg-red-100 text-red-700'
|
||||
: inv.status === 'issued'
|
||||
? 'bg-indigo-100 text-indigo-700'
|
||||
: inv.status === 'draft'
|
||||
? 'bg-gray-100 text-gray-700'
|
||||
: inv.status === 'overdue'
|
||||
? 'bg-red-100 text-red-700'
|
||||
: 'bg-yellow-100 text-yellow-700'
|
||||
}`}
|
||||
>
|
||||
{b.status}
|
||||
{inv.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 space-x-2">
|
||||
@ -200,13 +233,7 @@ export default function FinanceManagementPage() {
|
||||
<button className="text-xs rounded border px-2 py-1 hover:bg-gray-50">Export</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{filteredBills.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-3 py-4 text-center text-gray-500">
|
||||
Keine Rechnungen gefunden.
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useState, useMemo } from 'react'
|
||||
import PageLayout from '../components/PageLayout'
|
||||
|
||||
type Affiliate = {
|
||||
@ -20,6 +20,8 @@ export default function AffiliateLinksPage() {
|
||||
const [affiliates, setAffiliates] = useState<Affiliate[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
// NEW: selected category
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>('all')
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchAffiliates() {
|
||||
@ -63,130 +65,129 @@ export default function AffiliateLinksPage() {
|
||||
category: { title: affiliate.category, href: '#' },
|
||||
commissionRate: affiliate.commissionRate
|
||||
}))
|
||||
|
||||
// NEW: fixed categories from the provided image, merged with backend ones
|
||||
const categories = useMemo(() => {
|
||||
const fromImage = [
|
||||
'Technology',
|
||||
'Energy',
|
||||
'Finance',
|
||||
'Healthcare',
|
||||
'Education',
|
||||
'Travel',
|
||||
'Retail',
|
||||
'Construction',
|
||||
'Food',
|
||||
'Automotive',
|
||||
'Fashion',
|
||||
'Pets',
|
||||
]
|
||||
const set = new Set<string>(fromImage)
|
||||
affiliates.forEach(a => { if (a.category) set.add(a.category) })
|
||||
return ['all', ...Array.from(set)]
|
||||
}, [affiliates])
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="relative py-24 sm:py-32">
|
||||
{/* Background Pattern */}
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 -z-10 h-full w-full stroke-white/10"
|
||||
>
|
||||
<defs>
|
||||
<pattern
|
||||
x="50%"
|
||||
y={-1}
|
||||
id="affiliate-pattern"
|
||||
width={200}
|
||||
height={200}
|
||||
patternUnits="userSpaceOnUse"
|
||||
>
|
||||
<path d="M.5 200V.5H200" fill="none" stroke="rgba(255,255,255,0.05)" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect fill="url(#affiliate-pattern)" width="100%" height="100%" strokeWidth={0} />
|
||||
</svg>
|
||||
|
||||
{/* Colored Blur Effect */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute top-0 right-0 left-1/2 -z-10 -ml-24 transform-gpu overflow-hidden blur-3xl lg:ml-24 xl:ml-48"
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
clipPath:
|
||||
'polygon(63.1% 29.5%, 100% 17.1%, 76.6% 3%, 48.4% 0%, 44.6% 4.7%, 54.5% 25.3%, 59.8% 49%, 55.2% 57.8%, 44.4% 57.2%, 27.8% 47.9%, 35.1% 81.5%, 0% 97.7%, 39.2% 100%, 35.2% 81.4%, 97.2% 52.8%, 63.1% 29.5%)',
|
||||
}}
|
||||
className="aspect-[801/1036] w-[50.0625rem] bg-gradient-to-tr from-[#ff80b5] to-[#9089fc] opacity-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Additional background layers for better visibility */}
|
||||
<div className="absolute inset-0 -z-20 bg-gradient-to-b from-gray-900/95 via-gray-900/80 to-gray-900"></div>
|
||||
<div className="pointer-events-none absolute inset-0 -z-10 bg-[radial-gradient(circle_at_30%_20%,rgba(255,255,255,0.1),transparent_65%)]"></div>
|
||||
|
||||
<div className="mx-auto max-w-7xl px-6 lg:px-8 relative z-10">
|
||||
<div className="mx-auto max-w-2xl text-center">
|
||||
<h2 className="text-4xl font-semibold tracking-tight text-balance text-white sm:text-5xl">
|
||||
Affiliate Partners
|
||||
</h2>
|
||||
<p className="mt-2 text-lg/8 text-gray-300">
|
||||
Discover our trusted partners and earn commissions through affiliate
|
||||
links.
|
||||
<div className="bg-gradient-to-tr from-blue-50 via-white to-blue-100 min-h-screen">
|
||||
<main className="max-w-7xl mx-auto px-2 sm:px-6 lg:px-8 py-8">
|
||||
{/* Header (aligned with management pages) */}
|
||||
<header className="sticky top-0 z-10 bg-white/90 backdrop-blur border-b border-blue-100 py-10 px-8 rounded-2xl shadow-lg flex flex-col gap-4 mb-8">
|
||||
<div>
|
||||
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight">Affiliate Partners</h1>
|
||||
<p className="text-lg text-blue-700 mt-2">
|
||||
Discover our trusted partners and earn commissions through affiliate links.
|
||||
</p>
|
||||
</div>
|
||||
{/* NEW: Category filter */}
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm text-blue-900 font-medium">Filter by category:</label>
|
||||
<select
|
||||
value={selectedCategory}
|
||||
onChange={(e) => setSelectedCategory(e.target.value)}
|
||||
className="rounded-md border border-blue-200 bg-white px-3 py-1.5 text-sm text-blue-900 shadow-sm"
|
||||
>
|
||||
{categories.map(c => (
|
||||
<option key={c} value={c}>{c === 'all' ? 'All' : c}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* States */}
|
||||
{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 className="mx-auto max-w-2xl text-center">
|
||||
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-indigo-400 border-b-transparent" />
|
||||
<p className="mt-4 text-sm text-gray-600">Loading affiliate partners...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mx-auto mt-16 max-w-2xl text-center">
|
||||
<p className="text-red-400">{error}</p>
|
||||
{error && !loading && (
|
||||
<div className="mx-auto max-w-2xl rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 text-center">
|
||||
{error}
|
||||
</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 className="mx-auto max-w-2xl text-center text-sm text-gray-600">
|
||||
No affiliate partners available at the moment.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cards (aligned to white panels, border, shadow) */}
|
||||
{!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">
|
||||
<div className="relative w-full">
|
||||
<img
|
||||
alt=""
|
||||
src={post.imageUrl}
|
||||
className="aspect-video w-full rounded-2xl bg-gray-800 object-cover sm:aspect-2/1 lg:aspect-3/2"
|
||||
/>
|
||||
<div className="absolute inset-0 rounded-2xl inset-ring inset-ring-white/10" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||
{posts.map((post) => {
|
||||
// NEW: highlight when matches selected category (keep all visible)
|
||||
const isHighlighted = selectedCategory !== 'all' && post.category.title === selectedCategory
|
||||
return (
|
||||
<article
|
||||
key={post.id}
|
||||
className={`rounded-2xl bg-white border shadow-lg overflow-hidden flex flex-col transition
|
||||
${isHighlighted ? 'border-2 border-indigo-400 ring-2 ring-indigo-200' : 'border-gray-100'}`}
|
||||
>
|
||||
<div className="relative">
|
||||
<img alt="" src={post.imageUrl} className="aspect-video w-full object-cover" />
|
||||
</div>
|
||||
<div className="flex max-w-xl grow flex-col justify-between">
|
||||
<div className="mt-8 flex items-center gap-x-4 text-xs">
|
||||
<div className="p-6 flex-1 flex flex-col">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<h3 className="text-xl font-semibold text-blue-900">{post.title}</h3>
|
||||
{post.commissionRate && (
|
||||
<span className="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium border border-indigo-200 bg-indigo-50 text-indigo-700">
|
||||
{post.commissionRate}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2 text-xs">
|
||||
<a
|
||||
href={post.category.href}
|
||||
className="relative z-10 rounded-full bg-gray-800/60 px-3 py-1.5 font-medium text-gray-300 hover:bg-gray-800"
|
||||
className={`inline-flex items-center rounded-full px-2 py-0.5 border text-blue-900
|
||||
${isHighlighted ? 'border-indigo-300 bg-indigo-50' : 'border-blue-200 bg-blue-50'}`}
|
||||
>
|
||||
{post.category.title}
|
||||
</a>
|
||||
</div>
|
||||
<div className="group relative grow">
|
||||
<h3 className="mt-3 text-lg/6 font-semibold text-white group-hover:text-gray-300">
|
||||
<a href={post.href} target="_blank" rel="noopener noreferrer">
|
||||
<span className="absolute inset-0" />
|
||||
{post.title}
|
||||
</a>
|
||||
</h3>
|
||||
<p className="mt-5 line-clamp-3 text-sm/6 text-gray-400">
|
||||
{post.description}
|
||||
</p>
|
||||
</div>
|
||||
<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>
|
||||
)}
|
||||
<p className="mt-3 text-sm text-gray-700 line-clamp-4">{post.description}</p>
|
||||
<div className="mt-5 flex items-center justify-between">
|
||||
<a
|
||||
href={post.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm font-semibold text-indigo-400 hover:text-indigo-300"
|
||||
className="rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-2 text-sm font-medium shadow transition"
|
||||
>
|
||||
Visit Affiliate Link →
|
||||
Visit Affiliate Link
|
||||
</a>
|
||||
<span className="text-[11px] text-gray-500">
|
||||
External partner website.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</PageLayout>
|
||||
)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user