- Crop logo image (remove 58% bottom whitespace) - Logo 90px, centered with nav links - Cursor fix restored (no I-beam on non-interactive content) - Contracts Finder: fix empty authority_name (was looking for procurer role, CF uses buyer) - Contracts Finder: generate notice_url from OCID when release.url is empty - Find a Tender: fix doubled base URL in notice_url - Dashboard: use authority_name field (not buyer) for tender cards - Card shadows strengthened on auth pages - Password eye icon repositioned inside input
1320 lines
46 KiB
HTML
1320 lines
46 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<!-- Primary Meta Tags -->
|
|
<title>Dashboard | TenderRadar</title>
|
|
<meta name="title" content="Dashboard | TenderRadar">
|
|
<meta name="description" content="Your TenderRadar dashboard - browse and search UK public sector tenders.">
|
|
<meta name="keywords" content="tender dashboard">
|
|
<meta name="robots" content="noindex, nofollow">
|
|
|
|
<!-- Canonical URL -->
|
|
<link rel="canonical" href="https://tenderradar.co.uk/dashboard.html">
|
|
|
|
<!-- Open Graph / Facebook -->
|
|
<meta property="og:type" content="website">
|
|
<meta property="og:url" content="https://tenderradar.co.uk/dashboard.html">
|
|
<meta property="og:title" content="TenderRadar Dashboard">
|
|
<meta property="og:description" content="Your tender intelligence dashboard.">
|
|
<meta property="og:image" content="https://tenderradar.co.uk/og-image.png">
|
|
<meta property="og:locale" content="en_GB">
|
|
<meta property="og:site_name" content="TenderRadar">
|
|
|
|
<!-- Twitter Card -->
|
|
<meta name="twitter:card" content="summary_large_image">
|
|
<meta name="twitter:url" content="https://tenderradar.co.uk/dashboard.html">
|
|
<meta name="twitter:title" content="TenderRadar Dashboard">
|
|
<meta name="twitter:description" content="Your tender intelligence dashboard.">
|
|
<meta name="twitter:image" content="https://tenderradar.co.uk/twitter-card.png">
|
|
|
|
<!-- Preconnect for Performance -->
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
|
|
<!-- Favicon -->
|
|
<link rel="icon">
|
|
<link rel="apple-touch-icon">
|
|
|
|
<!-- Stylesheet -->
|
|
<link rel="stylesheet" href="styles.css">
|
|
<style>
|
|
/* Dashboard Specific Styles */
|
|
.dashboard-header {
|
|
background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%);
|
|
border-bottom: 1px solid var(--border);
|
|
padding: 2rem 0 2rem;
|
|
}
|
|
|
|
.dashboard-container {
|
|
display: flex;
|
|
gap: 2rem;
|
|
margin-top: 2rem;
|
|
}
|
|
|
|
.sidebar {
|
|
width: 280px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.main-content {
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.stats-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
gap: 1.5rem;
|
|
margin-bottom: 2rem;
|
|
}
|
|
|
|
.stat-card {
|
|
background: white;
|
|
padding: 1.5rem;
|
|
border-radius: 0.75rem;
|
|
border: 1px solid var(--border);
|
|
box-shadow: var(--shadow-sm);
|
|
}
|
|
|
|
.stat-card h3 {
|
|
font-size: 0.875rem;
|
|
font-weight: 600;
|
|
color: var(--text-secondary);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
margin-bottom: 0.75rem;
|
|
}
|
|
|
|
.stat-card .value {
|
|
font-size: 2rem;
|
|
font-weight: 700;
|
|
color: var(--primary);
|
|
line-height: 1;
|
|
}
|
|
|
|
.stat-card .subtitle {
|
|
font-size: 0.8125rem;
|
|
color: var(--text-light);
|
|
margin-top: 0.5rem;
|
|
}
|
|
|
|
.search-section {
|
|
background: white;
|
|
padding: 2rem;
|
|
border-radius: 0.75rem;
|
|
border: 1px solid var(--border);
|
|
box-shadow: var(--shadow-sm);
|
|
margin-bottom: 2rem;
|
|
}
|
|
|
|
.search-box {
|
|
position: relative;
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
|
|
.search-box input {
|
|
width: 100%;
|
|
padding: 0.875rem 1rem 0.875rem 2.75rem;
|
|
border: 1px solid var(--border);
|
|
border-radius: 0.5rem;
|
|
font-size: 0.9375rem;
|
|
font-family: 'Inter', sans-serif;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.search-box input:focus {
|
|
outline: none;
|
|
border-color: var(--primary);
|
|
box-shadow: 0 0 0 3px rgba(30, 64, 175, 0.1);
|
|
}
|
|
|
|
.search-icon {
|
|
position: absolute;
|
|
left: 0.75rem;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
color: var(--text-light);
|
|
width: 20px;
|
|
height: 20px;
|
|
}
|
|
|
|
.filters-section {
|
|
background: white;
|
|
padding: 1.5rem;
|
|
border-radius: 0.75rem;
|
|
border: 1px solid var(--border);
|
|
box-shadow: var(--shadow-sm);
|
|
}
|
|
|
|
.filter-group {
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
|
|
.filter-group:last-child {
|
|
margin-bottom: 0;
|
|
}
|
|
|
|
.filter-label {
|
|
display: block;
|
|
font-size: 0.875rem;
|
|
font-weight: 600;
|
|
color: var(--text-primary);
|
|
margin-bottom: 0.75rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
}
|
|
|
|
.filter-options {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.filter-option {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.filter-option input[type="checkbox"],
|
|
.filter-option input[type="radio"] {
|
|
width: 18px;
|
|
height: 18px;
|
|
cursor: pointer;
|
|
accent-color: var(--primary);
|
|
}
|
|
|
|
.filter-option label {
|
|
flex: 1;
|
|
font-size: 0.875rem;
|
|
color: var(--text-secondary);
|
|
cursor: pointer;
|
|
margin: 0;
|
|
}
|
|
|
|
.filter-range {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
align-items: center;
|
|
}
|
|
|
|
.filter-range input {
|
|
flex: 1;
|
|
padding: 0.5rem;
|
|
border: 1px solid var(--border);
|
|
border-radius: 0.375rem;
|
|
font-size: 0.8125rem;
|
|
font-family: 'Inter', sans-serif;
|
|
}
|
|
|
|
.filter-range input:focus {
|
|
outline: none;
|
|
border-color: var(--primary);
|
|
}
|
|
|
|
.tenders-section {
|
|
background: white;
|
|
border-radius: 0.75rem;
|
|
border: 1px solid var(--border);
|
|
box-shadow: var(--shadow-sm);
|
|
padding: 0;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.tenders-header {
|
|
padding: 1.5rem;
|
|
border-bottom: 1px solid var(--border);
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
|
|
.tenders-header h2 {
|
|
font-size: 1.25rem;
|
|
font-weight: 700;
|
|
color: var(--text-primary);
|
|
margin: 0;
|
|
}
|
|
|
|
.results-count {
|
|
font-size: 0.875rem;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.tender-list {
|
|
list-style: none;
|
|
}
|
|
|
|
.tender-item {
|
|
padding: 1.5rem;
|
|
border-bottom: 1px solid var(--border);
|
|
transition: all 0.2s;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.tender-item:last-child {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.tender-item:hover {
|
|
background: var(--bg-secondary);
|
|
}
|
|
|
|
.tender-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: flex-start;
|
|
gap: 1rem;
|
|
margin-bottom: 0.75rem;
|
|
}
|
|
|
|
.tender-title {
|
|
font-size: 1.0625rem;
|
|
font-weight: 600;
|
|
color: var(--primary);
|
|
margin: 0;
|
|
flex: 1;
|
|
}
|
|
|
|
.tender-badges {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
flex-wrap: wrap;
|
|
justify-content: flex-end;
|
|
}
|
|
|
|
.badge-source {
|
|
display: inline-block;
|
|
padding: 0.375rem 0.75rem;
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--border);
|
|
border-radius: 0.375rem;
|
|
font-size: 0.75rem;
|
|
font-weight: 600;
|
|
color: var(--text-secondary);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
}
|
|
|
|
.badge-relevance {
|
|
display: inline-block;
|
|
padding: 0.375rem 0.75rem;
|
|
background: rgba(245, 158, 11, 0.1);
|
|
border: 1px solid rgba(245, 158, 11, 0.3);
|
|
border-radius: 0.375rem;
|
|
font-size: 0.75rem;
|
|
font-weight: 600;
|
|
color: #92400e;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
}
|
|
|
|
.tender-meta {
|
|
display: flex;
|
|
gap: 1.5rem;
|
|
margin-bottom: 0.75rem;
|
|
font-size: 0.9375rem;
|
|
color: var(--text-secondary);
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.meta-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.meta-icon {
|
|
width: 18px;
|
|
height: 18px;
|
|
color: var(--text-light);
|
|
}
|
|
|
|
.tender-description {
|
|
font-size: 0.9375rem;
|
|
color: var(--text-secondary);
|
|
line-height: 1.5;
|
|
margin: 0;
|
|
}
|
|
|
|
.tender-footer {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-top: 1rem;
|
|
padding-top: 1rem;
|
|
border-top: 1px solid var(--border);
|
|
}
|
|
|
|
.tender-value {
|
|
font-size: 1.25rem;
|
|
font-weight: 700;
|
|
color: var(--primary);
|
|
}
|
|
|
|
.tender-actions {
|
|
display: flex;
|
|
gap: 0.75rem;
|
|
}
|
|
|
|
.tender-actions button {
|
|
padding: 0.5rem 1rem;
|
|
border-radius: 0.375rem;
|
|
border: none;
|
|
font-size: 0.875rem;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.btn-expand {
|
|
background: var(--primary);
|
|
color: white;
|
|
}
|
|
|
|
.btn-expand:hover {
|
|
background: var(--primary-dark);
|
|
}
|
|
|
|
.btn-save {
|
|
background: var(--bg-secondary);
|
|
color: var(--primary);
|
|
border: 1px solid var(--border);
|
|
}
|
|
|
|
.btn-save:hover {
|
|
background: white;
|
|
border-color: var(--primary);
|
|
}
|
|
|
|
.pagination {
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding: 2rem;
|
|
border-top: 1px solid var(--border);
|
|
}
|
|
|
|
.pagination button,
|
|
.pagination a {
|
|
padding: 0.5rem 0.75rem;
|
|
border: 1px solid var(--border);
|
|
border-radius: 0.375rem;
|
|
background: white;
|
|
color: var(--text-primary);
|
|
cursor: pointer;
|
|
font-size: 0.875rem;
|
|
font-weight: 500;
|
|
transition: all 0.2s;
|
|
text-decoration: none;
|
|
}
|
|
|
|
.pagination button:hover,
|
|
.pagination a:hover {
|
|
border-color: var(--primary);
|
|
color: var(--primary);
|
|
}
|
|
|
|
.pagination .active {
|
|
background: var(--primary);
|
|
color: white;
|
|
border-color: var(--primary);
|
|
}
|
|
|
|
.pagination .disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.empty-state {
|
|
text-align: center;
|
|
padding: 3rem 1.5rem;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.empty-state-icon {
|
|
width: 64px;
|
|
height: 64px;
|
|
color: var(--text-light);
|
|
margin-bottom: 1rem;
|
|
opacity: 0.5;
|
|
}
|
|
|
|
.empty-state h3 {
|
|
font-size: 1.25rem;
|
|
font-weight: 600;
|
|
color: var(--text-primary);
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
.empty-state p {
|
|
margin: 0;
|
|
}
|
|
|
|
.loading-spinner {
|
|
display: inline-block;
|
|
width: 20px;
|
|
height: 20px;
|
|
border: 3px solid rgba(30, 64, 175, 0.1);
|
|
border-top-color: var(--primary);
|
|
border-radius: 50%;
|
|
animation: spin 0.6s linear infinite;
|
|
}
|
|
|
|
@keyframes spin {
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
|
|
.modal {
|
|
display: none;
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background: rgba(0, 0, 0, 0.5);
|
|
z-index: 2000;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 1.5rem;
|
|
}
|
|
|
|
.modal.active {
|
|
display: flex;
|
|
}
|
|
|
|
.modal-content {
|
|
background: white;
|
|
border-radius: 0.75rem;
|
|
max-width: 800px;
|
|
width: 100%;
|
|
max-height: 90vh;
|
|
overflow-y: auto;
|
|
box-shadow: var(--shadow-xl);
|
|
}
|
|
|
|
.modal-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: flex-start;
|
|
padding: 1.5rem;
|
|
border-bottom: 1px solid var(--border);
|
|
position: sticky;
|
|
top: 0;
|
|
background: white;
|
|
}
|
|
|
|
.modal-header h2 {
|
|
font-size: 1.5rem;
|
|
font-weight: 700;
|
|
color: var(--text-primary);
|
|
margin: 0;
|
|
flex: 1;
|
|
}
|
|
|
|
.modal-close {
|
|
background: none;
|
|
border: none;
|
|
font-size: 1.5rem;
|
|
color: var(--text-light);
|
|
cursor: pointer;
|
|
padding: 0;
|
|
width: 32px;
|
|
height: 32px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.modal-close:hover {
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.modal-body {
|
|
padding: 2rem;
|
|
}
|
|
|
|
.detail-section {
|
|
margin-bottom: 2rem;
|
|
}
|
|
|
|
.detail-section:last-child {
|
|
margin-bottom: 0;
|
|
}
|
|
|
|
.detail-section h3 {
|
|
font-size: 1.0625rem;
|
|
font-weight: 600;
|
|
color: var(--text-primary);
|
|
margin-bottom: 1rem;
|
|
padding-bottom: 0.75rem;
|
|
border-bottom: 2px solid var(--bg-secondary);
|
|
}
|
|
|
|
.detail-row {
|
|
display: flex;
|
|
gap: 2rem;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.detail-row:last-child {
|
|
margin-bottom: 0;
|
|
}
|
|
|
|
.detail-col {
|
|
flex: 1;
|
|
}
|
|
|
|
.detail-label {
|
|
font-size: 0.875rem;
|
|
font-weight: 600;
|
|
color: var(--text-secondary);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
.detail-value {
|
|
font-size: 0.9375rem;
|
|
color: var(--text-primary);
|
|
line-height: 1.6;
|
|
word-break: break-word;
|
|
}
|
|
|
|
.detail-value a {
|
|
color: var(--primary);
|
|
text-decoration: none;
|
|
}
|
|
|
|
.detail-value a:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.modal-footer {
|
|
display: flex;
|
|
gap: 1rem;
|
|
padding: 1.5rem;
|
|
border-top: 1px solid var(--border);
|
|
background: var(--bg-secondary);
|
|
}
|
|
|
|
.modal-footer button {
|
|
flex: 1;
|
|
padding: 0.75rem;
|
|
border-radius: 0.5rem;
|
|
border: none;
|
|
font-size: 0.9375rem;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.user-menu {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.user-info {
|
|
text-align: right;
|
|
}
|
|
|
|
.user-name {
|
|
font-size: 0.875rem;
|
|
font-weight: 600;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.user-email {
|
|
font-size: 0.75rem;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.logout-btn {
|
|
padding: 0.5rem 1rem;
|
|
background: transparent;
|
|
border: 1px solid var(--border);
|
|
border-radius: 0.375rem;
|
|
font-size: 0.875rem;
|
|
font-weight: 600;
|
|
color: var(--text-secondary);
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.logout-btn:hover {
|
|
border-color: var(--primary);
|
|
color: var(--primary);
|
|
}
|
|
|
|
/* Responsive */
|
|
@media (max-width: 1024px) {
|
|
.dashboard-container {
|
|
gap: 1.5rem;
|
|
}
|
|
|
|
.sidebar {
|
|
width: 240px;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.dashboard-container {
|
|
flex-direction: column;
|
|
}
|
|
|
|
.sidebar {
|
|
width: 100%;
|
|
}
|
|
|
|
.stats-grid {
|
|
grid-template-columns: repeat(2, 1fr);
|
|
}
|
|
|
|
.tender-header {
|
|
flex-direction: column;
|
|
}
|
|
|
|
.tender-badges {
|
|
justify-content: flex-start;
|
|
}
|
|
|
|
.tender-footer {
|
|
flex-direction: column;
|
|
align-items: flex-start;
|
|
}
|
|
|
|
.tender-actions {
|
|
width: 100%;
|
|
}
|
|
|
|
.tender-actions button {
|
|
flex: 1;
|
|
}
|
|
|
|
.detail-row {
|
|
flex-direction: column;
|
|
gap: 0;
|
|
}
|
|
|
|
.modal-content {
|
|
max-width: calc(100vw - 3rem);
|
|
}
|
|
|
|
.filter-range {
|
|
flex-direction: column;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 480px) {
|
|
.stats-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.search-section,
|
|
.filters-section,
|
|
.tenders-section {
|
|
padding: 1rem;
|
|
}
|
|
|
|
.stat-card {
|
|
padding: 1rem;
|
|
}
|
|
|
|
.tender-meta {
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.modal-body {
|
|
padding: 1rem;
|
|
}
|
|
|
|
.pagination {
|
|
flex-wrap: wrap;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<!-- Header/Navigation -->
|
|
<header class="header" role="banner">
|
|
<nav class="nav container" role="navigation" aria-label="Main navigation">
|
|
<a href="/" class="nav-brand">
|
|
<img src="/logo.png" alt="TenderRadar" class="logo-icon">
|
|
</a>
|
|
<ul class="nav-menu">
|
|
<li><a href="/">Home</a></li>
|
|
<li><a href="/#features">Features</a></li>
|
|
<li><a href="/#pricing">Pricing</a></li>
|
|
</ul>
|
|
<div class="user-menu" style="display: none;" id="userMenuContainer">
|
|
<div class="user-info">
|
|
<div class="user-name" id="userName"></div>
|
|
<div class="user-email" id="userEmail"></div>
|
|
</div>
|
|
<button class="logout-btn" id="logoutBtn">Sign Out</button>
|
|
</div>
|
|
<button class="mobile-toggle" aria-label="Toggle navigation menu" aria-expanded="false">
|
|
<span></span>
|
|
<span></span>
|
|
<span></span>
|
|
</button>
|
|
</nav>
|
|
</header>
|
|
|
|
<!-- Dashboard Header -->
|
|
<section class="dashboard-header">
|
|
<div class="container">
|
|
<h1 style="font-size: 1.875rem; font-weight: 700; color: var(--text-primary); margin: 0 0 1rem 0;">Dashboard</h1>
|
|
|
|
<!-- Stats Grid -->
|
|
<div class="stats-grid">
|
|
<div class="stat-card">
|
|
<h3>Total Tenders</h3>
|
|
<div class="value" id="statTotal">-</div>
|
|
<div class="subtitle">Available to browse</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<h3>New This Week</h3>
|
|
<div class="value" id="statNew">-</div>
|
|
<div class="subtitle">Recently published</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<h3>Closing Soon</h3>
|
|
<div class="value" id="statClosing">-</div>
|
|
<div class="subtitle">Next 7 days</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<h3>Your Matches</h3>
|
|
<div class="value" id="statMatches">-</div>
|
|
<div class="subtitle">Profile matched</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Main Dashboard -->
|
|
<section style="padding: 2rem 0;">
|
|
<div class="container">
|
|
<div class="dashboard-container">
|
|
<!-- Sidebar -->
|
|
<aside class="sidebar">
|
|
<!-- Search -->
|
|
<div class="search-section">
|
|
<div class="search-box">
|
|
<svg class="search-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
|
</svg>
|
|
<input type="text" id="searchInput" placeholder="Search tenders..." autocomplete="off">
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Filters -->
|
|
<div class="filters-section">
|
|
<!-- Source Filter -->
|
|
<div class="filter-group">
|
|
<label class="filter-label">Source</label>
|
|
<div class="filter-options">
|
|
<div class="filter-option">
|
|
<input type="checkbox" id="filter-source-cf" value="Contracts Finder" class="source-filter">
|
|
<label for="filter-source-cf">Contracts Finder</label>
|
|
</div>
|
|
<div class="filter-option">
|
|
<input type="checkbox" id="filter-source-fat" value="Find a Tender" class="source-filter">
|
|
<label for="filter-source-fat">Find a Tender</label>
|
|
</div>
|
|
<div class="filter-option">
|
|
<input type="checkbox" id="filter-source-pcs" value="Public Contracts Scotland" class="source-filter">
|
|
<label for="filter-source-pcs">PCS</label>
|
|
</div>
|
|
<div class="filter-option">
|
|
<input type="checkbox" id="filter-source-s2w" value="Sell2Wales" class="source-filter">
|
|
<label for="filter-source-s2w">Sell2Wales</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Value Range Filter -->
|
|
<div class="filter-group">
|
|
<label class="filter-label">Contract Value</label>
|
|
<div class="filter-range">
|
|
<input type="number" id="filter-min-value" placeholder="Min £" min="0">
|
|
<span style="color: var(--text-light); font-size: 0.75rem;">—</span>
|
|
<input type="number" id="filter-max-value" placeholder="Max £" min="0">
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Deadline Filter -->
|
|
<div class="filter-group">
|
|
<label class="filter-label">Deadline</label>
|
|
<div class="filter-options">
|
|
<div class="filter-option">
|
|
<input type="radio" id="filter-deadline-all" name="deadline" value="all" class="deadline-filter" checked>
|
|
<label for="filter-deadline-all">Any time</label>
|
|
</div>
|
|
<div class="filter-option">
|
|
<input type="radio" id="filter-deadline-7" name="deadline" value="7" class="deadline-filter">
|
|
<label for="filter-deadline-7">Next 7 days</label>
|
|
</div>
|
|
<div class="filter-option">
|
|
<input type="radio" id="filter-deadline-30" name="deadline" value="30" class="deadline-filter">
|
|
<label for="filter-deadline-30">Next 30 days</label>
|
|
</div>
|
|
<div class="filter-option">
|
|
<input type="radio" id="filter-deadline-90" name="deadline" value="90" class="deadline-filter">
|
|
<label for="filter-deadline-90">Next 90 days</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Sector Filter -->
|
|
<div class="filter-group">
|
|
<label class="filter-label">Sector</label>
|
|
<div class="filter-options">
|
|
<div class="filter-option">
|
|
<input type="checkbox" id="filter-sector-health" value="Health" class="sector-filter">
|
|
<label for="filter-sector-health">Health</label>
|
|
</div>
|
|
<div class="filter-option">
|
|
<input type="checkbox" id="filter-sector-education" value="Education" class="sector-filter">
|
|
<label for="filter-sector-education">Education</label>
|
|
</div>
|
|
<div class="filter-option">
|
|
<input type="checkbox" id="filter-sector-construction" value="Construction" class="sector-filter">
|
|
<label for="filter-sector-construction">Construction</label>
|
|
</div>
|
|
<div class="filter-option">
|
|
<input type="checkbox" id="filter-sector-it" value="IT" class="sector-filter">
|
|
<label for="filter-sector-it">IT & Technology</label>
|
|
</div>
|
|
<div class="filter-option">
|
|
<input type="checkbox" id="filter-sector-other" value="Other" class="sector-filter">
|
|
<label for="filter-sector-other">Other</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
|
|
<!-- Main Content -->
|
|
<div class="main-content">
|
|
<!-- Tenders Section -->
|
|
<div class="tenders-section">
|
|
<div class="tenders-header">
|
|
<h2>Available Tenders</h2>
|
|
<div class="results-count">
|
|
<span id="resultsCount">0</span> results
|
|
</div>
|
|
</div>
|
|
|
|
<div id="loadingSpinner" style="display: none; padding: 2rem; text-align: center;">
|
|
<div class="loading-spinner"></div>
|
|
<p style="margin-top: 1rem; color: var(--text-secondary);">Loading tenders...</p>
|
|
</div>
|
|
|
|
<div id="emptyState" style="display: none;">
|
|
<div class="empty-state">
|
|
<svg class="empty-state-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
|
</svg>
|
|
<h3>No tenders found</h3>
|
|
<p>Try adjusting your filters or search terms</p>
|
|
</div>
|
|
</div>
|
|
|
|
<ul class="tender-list" id="tenderList"></ul>
|
|
|
|
<!-- Pagination -->
|
|
<div class="pagination" id="paginationContainer"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Tender Detail Modal -->
|
|
<div class="modal" id="tenderModal">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h2 id="modalTitle"></h2>
|
|
<button class="modal-close" id="modalCloseBtn">×</button>
|
|
</div>
|
|
<div class="modal-body" id="modalBody"></div>
|
|
<div class="modal-footer">
|
|
<button class="btn btn-primary" id="modalApplyBtn">Apply Now</button>
|
|
<button class="btn btn-secondary" id="modalSaveBtn">Save Tender</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
// Auth check and initialization
|
|
document.addEventListener('DOMContentLoaded', async () => {
|
|
const token = localStorage.getItem('token');
|
|
const user = JSON.parse(localStorage.getItem('user') || '{}');
|
|
|
|
if (!token) {
|
|
window.location.href = '/login.html';
|
|
return;
|
|
}
|
|
|
|
// Display user info
|
|
if (user.email) {
|
|
document.getElementById('userName').textContent = user.company_name || user.email.split('@')[0];
|
|
document.getElementById('userEmail').textContent = user.email;
|
|
document.getElementById('userMenuContainer').style.display = 'flex';
|
|
}
|
|
|
|
// Logout
|
|
document.getElementById('logoutBtn').addEventListener('click', () => {
|
|
localStorage.removeItem('token');
|
|
localStorage.removeItem('user');
|
|
window.location.href = '/login.html';
|
|
});
|
|
|
|
// Load initial data
|
|
await loadDashboardData();
|
|
await loadTenders();
|
|
|
|
// Event listeners
|
|
document.getElementById('searchInput').addEventListener('input', debounce(loadTenders, 500));
|
|
document.querySelectorAll('.source-filter, .sector-filter').forEach(el => {
|
|
el.addEventListener('change', () => {
|
|
currentPage = 1;
|
|
loadTenders();
|
|
});
|
|
});
|
|
document.querySelectorAll('.deadline-filter').forEach(el => {
|
|
el.addEventListener('change', () => {
|
|
currentPage = 1;
|
|
loadTenders();
|
|
});
|
|
});
|
|
document.getElementById('filter-min-value').addEventListener('change', () => {
|
|
currentPage = 1;
|
|
loadTenders();
|
|
});
|
|
document.getElementById('filter-max-value').addEventListener('change', () => {
|
|
currentPage = 1;
|
|
loadTenders();
|
|
});
|
|
|
|
// Modal close
|
|
document.getElementById('modalCloseBtn').addEventListener('click', closeModal);
|
|
document.getElementById('tenderModal').addEventListener('click', (e) => {
|
|
if (e.target.id === 'tenderModal') closeModal();
|
|
});
|
|
|
|
// Mobile menu
|
|
document.querySelector('.mobile-toggle').addEventListener('click', function() {
|
|
document.querySelector('.nav-menu').classList.toggle('active');
|
|
});
|
|
});
|
|
|
|
let currentPage = 1;
|
|
const pageSize = 20;
|
|
|
|
async function loadDashboardData() {
|
|
try {
|
|
const token = localStorage.getItem('token');
|
|
const response = await fetch('/api/tenders/stats', {
|
|
headers: { 'Authorization': `Bearer ${token}` }
|
|
});
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
document.getElementById('statTotal').textContent = data.total || '—';
|
|
document.getElementById('statNew').textContent = data.new_this_week || '—';
|
|
document.getElementById('statClosing').textContent = data.closing_soon || '—';
|
|
document.getElementById('statMatches').textContent = data.matched_to_profile || '—';
|
|
} else if (response.status === 401) {
|
|
window.location.href = '/login.html';
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading dashboard data:', error);
|
|
// Set fallback values
|
|
document.getElementById('statTotal').textContent = '212+';
|
|
document.getElementById('statNew').textContent = '8';
|
|
document.getElementById('statClosing').textContent = '15';
|
|
document.getElementById('statMatches').textContent = '0';
|
|
}
|
|
}
|
|
|
|
async function loadTenders() {
|
|
const token = localStorage.getItem('token');
|
|
if (!token) {
|
|
window.location.href = '/login.html';
|
|
return;
|
|
}
|
|
|
|
const search = document.getElementById('searchInput').value.trim();
|
|
const sources = Array.from(document.querySelectorAll('.source-filter:checked'))
|
|
.map(el => el.value);
|
|
const minValue = document.getElementById('filter-min-value').value;
|
|
const maxValue = document.getElementById('filter-max-value').value;
|
|
const deadline = document.querySelector('.deadline-filter:checked').value;
|
|
const sectors = Array.from(document.querySelectorAll('.sector-filter:checked'))
|
|
.map(el => el.value);
|
|
|
|
const params = new URLSearchParams({
|
|
limit: pageSize,
|
|
offset: (currentPage - 1) * pageSize,
|
|
...(search && { search }),
|
|
...(minValue && { min_value: minValue }),
|
|
...(maxValue && { max_value: maxValue }),
|
|
...(deadline !== 'all' && { deadline_days: deadline }),
|
|
...(sources.length > 0 && { sources: sources.join(',') }),
|
|
...(sectors.length > 0 && { sectors: sectors.join(',') })
|
|
});
|
|
|
|
document.getElementById('loadingSpinner').style.display = 'block';
|
|
document.getElementById('tenderList').innerHTML = '';
|
|
document.getElementById('emptyState').style.display = 'none';
|
|
|
|
try {
|
|
const response = await fetch(`/api/tenders?${params}`, {
|
|
headers: { 'Authorization': `Bearer ${token}` }
|
|
});
|
|
|
|
if (response.status === 401) {
|
|
window.location.href = '/login.html';
|
|
return;
|
|
}
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to load tenders');
|
|
}
|
|
|
|
const data = await response.json();
|
|
const tenders = data.tenders || [];
|
|
const total = data.total || 0;
|
|
|
|
document.getElementById('loadingSpinner').style.display = 'none';
|
|
|
|
if (tenders.length === 0) {
|
|
document.getElementById('emptyState').style.display = 'block';
|
|
document.getElementById('resultsCount').textContent = '0';
|
|
document.getElementById('paginationContainer').innerHTML = '';
|
|
return;
|
|
}
|
|
|
|
document.getElementById('resultsCount').textContent = total;
|
|
renderTenders(tenders);
|
|
renderPagination(total);
|
|
} catch (error) {
|
|
console.error('Error loading tenders:', error);
|
|
document.getElementById('loadingSpinner').style.display = 'none';
|
|
document.getElementById('emptyState').style.display = 'block';
|
|
document.getElementById('emptyState').innerHTML = `
|
|
<div class="empty-state">
|
|
<svg class="empty-state-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4v.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
|
</svg>
|
|
<h3>Error loading tenders</h3>
|
|
<p>${error.message}</p>
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
function renderTenders(tenders) {
|
|
const list = document.getElementById('tenderList');
|
|
list.innerHTML = tenders.map(tender => `
|
|
<li class="tender-item" onclick="viewTenderDetail(${tender.id})">
|
|
<div class="tender-header">
|
|
<h3 class="tender-title">${escapeHtml(tender.title)}</h3>
|
|
<div class="tender-badges">
|
|
<span class="badge-source">${escapeHtml(tender.source || 'Unknown')}</span>
|
|
${tender.relevance_score ? `<span class="badge-relevance">Match: ${Math.round(tender.relevance_score)}%</span>` : ''}
|
|
</div>
|
|
</div>
|
|
<div class="tender-meta">
|
|
<div class="meta-item">
|
|
<svg class="meta-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
|
|
</svg>
|
|
<span>${escapeHtml(tender.authority_name || tender.buyer || 'Unknown Buyer')}</span>
|
|
</div>
|
|
<div class="meta-item">
|
|
<svg class="meta-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
|
</svg>
|
|
<span>Closes: ${formatDate(tender.deadline)}</span>
|
|
</div>
|
|
</div>
|
|
<p class="tender-description">${escapeHtml((tender.description || '').substring(0, 150))}${(tender.description || '').length > 150 ? '...' : ''}</p>
|
|
<div class="tender-footer">
|
|
<div class="tender-value">${tender.value_high ? '£' + formatNumber(tender.value_high) : 'TBA'}</div>
|
|
<div class="tender-actions">
|
|
<button class="btn-expand" onclick="event.stopPropagation(); viewTenderDetail(${tender.id})">View Details</button>
|
|
<button class="btn-save" onclick="event.stopPropagation(); saveTender(${tender.id})">Save</button>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
`).join('');
|
|
}
|
|
|
|
function renderPagination(total) {
|
|
const totalPages = Math.ceil(total / pageSize);
|
|
const container = document.getElementById('paginationContainer');
|
|
|
|
if (totalPages <= 1) {
|
|
container.innerHTML = '';
|
|
return;
|
|
}
|
|
|
|
let html = '';
|
|
|
|
// Previous button
|
|
html += `<button ${currentPage === 1 ? 'disabled class="disabled"' : ''} onclick="goToPage(${currentPage - 1})">← Previous</button>`;
|
|
|
|
// Page numbers
|
|
const startPage = Math.max(1, currentPage - 2);
|
|
const endPage = Math.min(totalPages, currentPage + 2);
|
|
|
|
if (startPage > 1) {
|
|
html += `<button onclick="goToPage(1)">1</button>`;
|
|
if (startPage > 2) html += `<span style="padding: 0 0.5rem;">...</span>`;
|
|
}
|
|
|
|
for (let i = startPage; i <= endPage; i++) {
|
|
html += `<button ${i === currentPage ? 'class="active"' : ''} onclick="goToPage(${i})">${i}</button>`;
|
|
}
|
|
|
|
if (endPage < totalPages) {
|
|
if (endPage < totalPages - 1) html += `<span style="padding: 0 0.5rem;">...</span>`;
|
|
html += `<button onclick="goToPage(${totalPages})">${totalPages}</button>`;
|
|
}
|
|
|
|
// Next button
|
|
html += `<button ${currentPage === totalPages ? 'disabled class="disabled"' : ''} onclick="goToPage(${currentPage + 1})">Next →</button>`;
|
|
|
|
container.innerHTML = html;
|
|
}
|
|
|
|
function goToPage(page) {
|
|
currentPage = page;
|
|
loadTenders();
|
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
}
|
|
|
|
async function viewTenderDetail(tenderId) {
|
|
const token = localStorage.getItem('token');
|
|
try {
|
|
const response = await fetch(`/api/tenders/${tenderId}`, {
|
|
headers: { 'Authorization': `Bearer ${token}` }
|
|
});
|
|
|
|
if (!response.ok) throw new Error('Failed to load tender details');
|
|
|
|
const tender = await response.json();
|
|
displayTenderModal(tender);
|
|
} catch (error) {
|
|
console.error('Error loading tender detail:', error);
|
|
alert('Error loading tender details');
|
|
}
|
|
}
|
|
|
|
function displayTenderModal(tender) {
|
|
document.getElementById('modalTitle').textContent = escapeHtml(tender.title);
|
|
document.getElementById('modalBody').innerHTML = `
|
|
<div class="detail-section">
|
|
<h3>Overview</h3>
|
|
<div class="detail-row">
|
|
<div class="detail-col">
|
|
<div class="detail-label">Source</div>
|
|
<div class="detail-value">${escapeHtml(tender.source || 'Unknown')}</div>
|
|
</div>
|
|
<div class="detail-col">
|
|
<div class="detail-label">Buyer</div>
|
|
<div class="detail-value">${escapeHtml(tender.buyer || 'Unknown')}</div>
|
|
</div>
|
|
</div>
|
|
<div class="detail-row">
|
|
<div class="detail-col">
|
|
<div class="detail-label">Deadline</div>
|
|
<div class="detail-value">${formatDate(tender.deadline)}</div>
|
|
</div>
|
|
<div class="detail-col">
|
|
<div class="detail-label">Contract Value</div>
|
|
<div class="detail-value">${tender.value_high ? '£' + formatNumber(tender.value_high) : 'TBA'}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="detail-section">
|
|
<h3>Description</h3>
|
|
<div class="detail-value">${escapeHtml(tender.description || 'No description available')}</div>
|
|
</div>
|
|
${tender.notice_url ? `
|
|
<div class="detail-section">
|
|
<h3>Links</h3>
|
|
<div class="detail-value"><a href="${escapeHtml(tender.notice_url)}" target="_blank" rel="noopener">View on official portal →</a></div>
|
|
</div>
|
|
` : ''}
|
|
`;
|
|
|
|
document.getElementById('modalApplyBtn').onclick = () => {
|
|
if (tender.notice_url) {
|
|
window.open(tender.notice_url, '_blank');
|
|
} else {
|
|
alert('External link not available for this tender');
|
|
}
|
|
};
|
|
|
|
document.getElementById('modalSaveBtn').onclick = () => {
|
|
saveTender(tender.id);
|
|
};
|
|
|
|
document.getElementById('tenderModal').classList.add('active');
|
|
}
|
|
|
|
function closeModal() {
|
|
document.getElementById('tenderModal').classList.remove('active');
|
|
}
|
|
|
|
function saveTender(tenderId) {
|
|
alert('Tender saved! (Feature coming soon)');
|
|
}
|
|
|
|
function debounce(func, wait) {
|
|
let timeout;
|
|
return function executedFunction(...args) {
|
|
const later = () => {
|
|
clearTimeout(timeout);
|
|
func(...args);
|
|
};
|
|
clearTimeout(timeout);
|
|
timeout = setTimeout(later, wait);
|
|
};
|
|
}
|
|
|
|
function formatDate(dateString) {
|
|
if (!dateString) return 'Unknown';
|
|
const date = new Date(dateString);
|
|
return date.toLocaleDateString('en-GB', {
|
|
day: 'numeric',
|
|
month: 'short',
|
|
year: 'numeric'
|
|
});
|
|
}
|
|
|
|
function formatNumber(num) {
|
|
return new Intl.NumberFormat('en-GB').format(Math.round(num));
|
|
}
|
|
|
|
function escapeHtml(text) {
|
|
if (!text) return '';
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|