feat: invoice

This commit is contained in:
DeathKaioken 2025-12-15 16:59:16 +01:00
parent 7d908caec3
commit 20c39fcd4e
3 changed files with 296 additions and 175 deletions

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

View File

@ -3,23 +3,7 @@ import React, { useMemo, useState } from 'react'
import PageLayout from '../../components/PageLayout' import PageLayout from '../../components/PageLayout'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { useVatRates } from './hooks/getTaxes' import { useVatRates } from './hooks/getTaxes'
import { useAdminInvoices } from './hooks/getInvoices'
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' },
]
export default function FinanceManagementPage() { export default function FinanceManagementPage() {
const router = useRouter() const router = useRouter()
@ -27,38 +11,60 @@ export default function FinanceManagementPage() {
const [timeframe, setTimeframe] = useState<'7d' | '30d' | '90d' | 'ytd'>('30d') const [timeframe, setTimeframe] = useState<'7d' | '30d' | '90d' | 'ytd'>('30d')
const [billFilter, setBillFilter] = useState({ query: '', status: 'all', from: '', to: '' }) 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 totals = useMemo(() => {
const now = new Date() const now = new Date()
const filterDate = (d: string) => new Date(d)
const inRange = (d: Date) => { const inRange = (d: Date) => {
const diff = (now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24) const diff = (now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24)
if (timeframe === '7d') return diff <= 7 if (timeframe === '7d') return diff <= 7
if (timeframe === '30d') return diff <= 30 if (timeframe === '30d') return diff <= 30
if (timeframe === '90d') return diff <= 90 if (timeframe === '90d') return diff <= 90
return true // ytd or default return true
} }
const filtered = dummyBills.filter(b => inRange(filterDate(b.date))) const range = invoices.filter(inv => {
const total = dummyBills.reduce((s, b) => s + b.amount, 0) const dStr = inv.issued_at ?? inv.created_at
const totalRange = filtered.reduce((s, b) => s + b.amount, 0) if (!dStr) return false
return { totalAll: total, totalRange } const d = new Date(dStr)
}, [timeframe]) return inRange(d)
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
}) })
}, [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') => { const exportBills = (format: 'csv' | 'pdf') => {
console.log('[export]', format, { filters: billFilter, bills: filteredBills }) console.log('[export]', format, { filters: billFilter, invoices: filteredBills })
alert(`Export ${format.toUpperCase()} (dummy) for ${filteredBills.length} bills`) alert(`Export ${format.toUpperCase()} (dummy) for ${filteredBills.length} invoices`)
} }
return ( return (
@ -129,12 +135,13 @@ export default function FinanceManagementPage() {
<div className="flex flex-wrap gap-2 text-sm"> <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('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={() => 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> </div>
<div className="grid gap-3 md:grid-cols-4 text-sm"> <div className="grid gap-3 md:grid-cols-4 text-sm">
<input <input
placeholder="Search (ID, customer)" placeholder="Search (invoice no., customer)"
value={billFilter.query} value={billFilter.query}
onChange={e => setBillFilter(f => ({ ...f, query: e.target.value }))} 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" 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" 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="all">Status: All</option>
<option value="draft">Draft</option>
<option value="issued">Issued</option>
<option value="paid">Paid</option> <option value="paid">Paid</option>
<option value="open">Open</option>
<option value="overdue">Overdue</option> <option value="overdue">Overdue</option>
<option value="canceled">Canceled</option>
</select> </select>
<input <input
type="date" type="date"
@ -164,49 +173,67 @@ export default function FinanceManagementPage() {
</div> </div>
<div className="overflow-x-auto"> <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"> <table className="min-w-full text-sm">
<thead> <thead>
<tr className="bg-blue-50 text-left text-blue-900"> <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">Invoice</th>
<th className="px-3 py-2 font-semibold">Customer</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">Amount</th>
<th className="px-3 py-2 font-semibold">Status</th> <th className="px-3 py-2 font-semibold">Status</th>
<th className="px-3 py-2 font-semibold">Actions</th> <th className="px-3 py-2 font-semibold">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-100"> <tbody className="divide-y divide-gray-100">
{filteredBills.map(b => ( {invLoading ? (
<tr key={b.id} className="border-b last:border-0"> <>
<td className="px-3 py-2">{b.id}</td> <tr><td colSpan={6} className="px-3 py-3"><div className="h-4 w-40 bg-gray-200 animate-pulse rounded" /></td></tr>
<td className="px-3 py-2">{b.customer}</td> <tr><td colSpan={6} className="px-3 py-3"><div className="h-4 w-3/4 bg-gray-200 animate-pulse rounded" /></td></tr>
<td className="px-3 py-2">{new Date(b.date).toLocaleDateString()}</td> </>
<td className="px-3 py-2">{b.amount.toFixed(2)}</td> ) : filteredBills.length === 0 ? (
<td className="px-3 py-2">
<span
className={`rounded-full px-2 py-0.5 text-xs font-semibold ${
b.status === 'paid'
? 'bg-green-100 text-green-700'
: b.status === 'open'
? 'bg-yellow-100 text-yellow-700'
: 'bg-red-100 text-red-700'
}`}
>
{b.status}
</span>
</td>
<td className="px-3 py-2 space-x-2">
<button className="text-xs rounded border px-2 py-1 hover:bg-gray-50">View</button>
<button className="text-xs rounded border px-2 py-1 hover:bg-gray-50">Export</button>
</td>
</tr>
))}
{filteredBills.length === 0 && (
<tr> <tr>
<td colSpan={6} className="px-3 py-4 text-center text-gray-500"> <td colSpan={6} className="px-3 py-4 text-center text-gray-500">
Keine Rechnungen gefunden. Keine Rechnungen gefunden.
</td> </td>
</tr> </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 ${
inv.status === 'paid'
? 'bg-green-100 text-green-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'
}`}
>
{inv.status}
</span>
</td>
<td className="px-3 py-2 space-x-2">
<button className="text-xs rounded border px-2 py-1 hover:bg-gray-50">View</button>
<button className="text-xs rounded border px-2 py-1 hover:bg-gray-50">Export</button>
</td>
</tr>
))
)} )}
</tbody> </tbody>
</table> </table>

View File

@ -1,6 +1,6 @@
'use client' 'use client'
import { useEffect, useState } from 'react' import { useEffect, useState, useMemo } from 'react'
import PageLayout from '../components/PageLayout' import PageLayout from '../components/PageLayout'
type Affiliate = { type Affiliate = {
@ -20,6 +20,8 @@ export default function AffiliateLinksPage() {
const [affiliates, setAffiliates] = useState<Affiliate[]>([]) const [affiliates, setAffiliates] = useState<Affiliate[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState('') const [error, setError] = useState('')
// NEW: selected category
const [selectedCategory, setSelectedCategory] = useState<string>('all')
useEffect(() => { useEffect(() => {
async function fetchAffiliates() { async function fetchAffiliates() {
@ -63,130 +65,129 @@ export default function AffiliateLinksPage() {
category: { title: affiliate.category, href: '#' }, category: { title: affiliate.category, href: '#' },
commissionRate: affiliate.commissionRate 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 ( return (
<PageLayout> <PageLayout>
<div className="relative py-24 sm:py-32"> <div className="bg-gradient-to-tr from-blue-50 via-white to-blue-100 min-h-screen">
{/* Background Pattern */} <main className="max-w-7xl mx-auto px-2 sm:px-6 lg:px-8 py-8">
<svg {/* Header (aligned with management pages) */}
aria-hidden="true" <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">
className="absolute inset-0 -z-10 h-full w-full stroke-white/10" <div>
> <h1 className="text-4xl font-extrabold text-blue-900 tracking-tight">Affiliate Partners</h1>
<defs> <p className="text-lg text-blue-700 mt-2">
<pattern Discover our trusted partners and earn commissions through affiliate links.
x="50%" </p>
y={-1} </div>
id="affiliate-pattern" {/* NEW: Category filter */}
width={200} <div className="flex items-center gap-2">
height={200} <label className="text-sm text-blue-900 font-medium">Filter by category:</label>
patternUnits="userSpaceOnUse" <select
> value={selectedCategory}
<path d="M.5 200V.5H200" fill="none" stroke="rgba(255,255,255,0.05)" /> onChange={(e) => setSelectedCategory(e.target.value)}
</pattern> className="rounded-md border border-blue-200 bg-white px-3 py-1.5 text-sm text-blue-900 shadow-sm"
</defs> >
<rect fill="url(#affiliate-pattern)" width="100%" height="100%" strokeWidth={0} /> {categories.map(c => (
</svg> <option key={c} value={c}>{c === 'all' ? 'All' : c}</option>
))}
{/* Colored Blur Effect */} </select>
<div </div>
aria-hidden="true" </header>
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.
</p>
</div>
{/* States */}
{loading && ( {loading && (
<div className="mx-auto mt-16 text-center"> <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-r-transparent"></div> <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-400">Loading affiliate partners...</p> <p className="mt-4 text-sm text-gray-600">Loading affiliate partners...</p>
</div> </div>
)} )}
{error && ( {error && !loading && (
<div className="mx-auto mt-16 max-w-2xl text-center"> <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">
<p className="text-red-400">{error}</p> {error}
</div> </div>
)} )}
{!loading && !error && posts.length === 0 && ( {!loading && !error && posts.length === 0 && (
<div className="mx-auto mt-16 max-w-2xl text-center"> <div className="mx-auto max-w-2xl text-center text-sm text-gray-600">
<p className="text-gray-400">No affiliate partners available at the moment.</p> No affiliate partners available at the moment.
</div> </div>
)} )}
{/* Cards (aligned to white panels, border, shadow) */}
{!loading && !error && posts.length > 0 && ( {!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"> <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
{posts.map((post) => ( {posts.map((post) => {
<article key={post.id} className="flex flex-col items-start justify-between"> // NEW: highlight when matches selected category (keep all visible)
<div className="relative w-full"> const isHighlighted = selectedCategory !== 'all' && post.category.title === selectedCategory
<img return (
alt="" <article
src={post.imageUrl} key={post.id}
className="aspect-video w-full rounded-2xl bg-gray-800 object-cover sm:aspect-2/1 lg:aspect-3/2" 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="absolute inset-0 rounded-2xl inset-ring inset-ring-white/10" /> >
</div> <div className="relative">
<div className="flex max-w-xl grow flex-col justify-between"> <img alt="" src={post.imageUrl} className="aspect-video w-full object-cover" />
<div className="mt-8 flex items-center gap-x-4 text-xs"> </div>
<a <div className="p-6 flex-1 flex flex-col">
href={post.category.href} <div className="flex items-start justify-between gap-3">
className="relative z-10 rounded-full bg-gray-800/60 px-3 py-1.5 font-medium text-gray-300 hover:bg-gray-800" <h3 className="text-xl font-semibold text-blue-900">{post.title}</h3>
> {post.commissionRate && (
{post.category.title} <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">
</a> {post.commissionRate}
</div> </span>
<div className="group relative grow"> )}
<h3 className="mt-3 text-lg/6 font-semibold text-white group-hover:text-gray-300"> </div>
<a href={post.href} target="_blank" rel="noopener noreferrer"> <div className="mt-2 flex flex-wrap gap-2 text-xs">
<span className="absolute inset-0" /> <a
{post.title} href={post.category.href}
</a> className={`inline-flex items-center rounded-full px-2 py-0.5 border text-blue-900
</h3> ${isHighlighted ? 'border-indigo-300 bg-indigo-50' : 'border-blue-200 bg-blue-50'}`}
<p className="mt-5 line-clamp-3 text-sm/6 text-gray-400"> >
{post.description} {post.category.title}
</p> </a>
</div> </div>
<div className="relative mt-8 flex items-center gap-x-4 justify-self-end"> <p className="mt-3 text-sm text-gray-700 line-clamp-4">{post.description}</p>
{post.commissionRate && ( <div className="mt-5 flex items-center justify-between">
<span className="text-xs text-gray-500 border border-gray-700 rounded-full px-2 py-1"> <a
{post.commissionRate} href={post.href}
</span> target="_blank"
)} rel="noopener noreferrer"
<a className="rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-2 text-sm font-medium shadow transition"
href={post.href} >
target="_blank" Visit Affiliate Link
rel="noopener noreferrer" </a>
className="text-sm font-semibold text-indigo-400 hover:text-indigo-300" <span className="text-[11px] text-gray-500">
> External partner website.
Visit Affiliate Link </span>
</a> </div>
</div> </div>
</div> </article>
</article> )
))} })}
</div> </div>
)} )}
</div> </main>
</div> </div>
</PageLayout> </PageLayout>
) )