add image-first directory card redesign and cover image upload

- 4-tier visual ladder: Free (muted grey) → Basic (verified presence) →
  Growth (stats + quotes) → Pro (court media + full stats + green glow)
- New card layout: 16:9 cover media, frosted category badge, logo avatar
  straddling media/body border (body-relative to avoid overflow:hidden clip)
- Pro default: CSS court visualization placeholder; Growth/Basic: dark-green
  grid placeholder; Free: grey/desaturated placeholder
- Cover image upload in supplier dashboard (saves to static/uploads/covers/)
- Migration 0013: cover_image TEXT column on suppliers table
- Updated prototype (scratch/design_directory_cards.html) with Basic tier
  cards, fixed logo-wrap positioning across all tiers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-19 18:17:54 +01:00
parent 536eefffdb
commit 321d321ba9
8 changed files with 1706 additions and 150 deletions

View File

@@ -6,6 +6,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]
### Changed
- Redesigned directory cards: image-first 16:9 layout with cover photos, frosted category badge, CSS court/grid placeholders, and logo avatar straddling the media/body border
- 4-tier visual ladder: Free (62% opacity, grey placeholder, unverified chip) → Basic (full opacity, green placeholder, verified chip, description, "View Listing →") → Growth (green placeholder, Growth chip + stats, "Request Quote →") → Pro (green 2.5px top border, CSS court media, full stats, green hover glow)
- Pro card media defaults to CSS court visualization when no cover image; Growth/Basic default to dark-green grid placeholder
### Added
- Cover image upload for suppliers in the dashboard listing form (saves to `static/uploads/covers/`, 16:9 thumbnail preview)
- Migration 0013: `cover_image TEXT` column on suppliers table
### Added
- **Basic subscription tier** — verified directory listing with contact info, services checklist, social links, and enquiry form; no lead credits
- **Monthly + yearly billing** — all paid supplier tiers now offer yearly pricing with annual discount (Basic: €349/yr, Growth: €1,799/yr, Pro: €4,499/yr)

View File

@@ -5,6 +5,22 @@
{% block head %}
<meta name="description" content="Browse {{ total_suppliers }}+ padel court suppliers across {{ total_countries }} countries. Manufacturers, builders, turf, lighting, and software. Find the right partner for your project.">
<style>
:root {
--dir-green: #15803D;
--dir-green-mid: #16a34a;
--dir-green-dark: #0D5228;
--dir-blue: #1D4ED8;
--dir-text-1: #111827;
--dir-text-2: #6B7280;
--dir-text-3: #9CA3AF;
--dir-card-bg: #FFFFFF;
--dir-card-bdr: #E6E2D8;
--dir-r: 16px;
--dir-shadow: 0 1px 3px rgba(0,0,0,0.05), 0 1px 2px rgba(0,0,0,0.04);
--dir-shadow-hov: 0 14px 36px rgba(0,0,0,0.10), 0 2px 8px rgba(0,0,0,0.06);
--dir-shadow-pro: 0 14px 36px rgba(21,128,61,0.13), 0 2px 8px rgba(21,128,61,0.08);
}
.dir-hero { text-align: center; padding: 2rem 0 1.5rem; }
.dir-hero h1 { font-size: 2rem; line-height: 1.3; }
.dir-hero p { color: #64748B; margin-top: 0.5rem; }
@@ -36,94 +52,226 @@
.dir-filter-tag a { color: #1D4ED8; text-decoration: none; font-weight: 700; }
.dir-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1rem; margin-bottom: 2rem;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.25rem; margin-bottom: 2rem;
}
/* Base card */
/* ---- Base card ---- */
.dir-card {
background: white; border: 1px solid #E2E8F0; border-radius: 14px;
padding: 1.25rem; transition: box-shadow 0.15s;
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
background: var(--dir-card-bg);
border: 1px solid var(--dir-card-bdr);
border-radius: var(--dir-r);
overflow: hidden;
text-decoration: none;
color: inherit;
display: block;
position: relative;
transition: transform 0.2s ease, box-shadow 0.2s ease;
box-shadow: var(--dir-shadow);
}
.dir-card:hover { box-shadow: 0 6px 20px rgba(0,0,0,0.08); }
.dir-card__head { display: flex; justify-content: space-between; align-items: flex-start; gap: 8px; }
.dir-card__name { font-size: 0.9375rem; font-weight: 600; color: #1E293B; margin: 0; }
.dir-card__badge {
display: inline-block; padding: 2px 8px; border-radius: 4px;
font-size: 0.6875rem; font-weight: 600; white-space: nowrap;
background: #F1F5F9; color: #475569;
.dir-card:hover { transform: translateY(-3px); box-shadow: var(--dir-shadow-hov); }
/* ---- Cover media ---- */
.dir-card__media {
position: relative; width: 100%; aspect-ratio: 16 / 9; overflow: hidden;
}
.dir-card__badge--manufacturer { background: #DBEAFE; color: #1D4ED8; }
.dir-card__badge--turnkey { background: #D1FAE5; color: #065F46; }
.dir-card__badge--turf { background: #FEF3C7; color: #92400E; }
.dir-card__badge--lighting { background: #FEE2E2; color: #991B1B; }
.dir-card__badge--software { background: #EDE9FE; color: #5B21B6; }
.dir-card__badge--hall_builder { background: #FFEDD5; color: #9A3412; }
.dir-card__badge--consultant { background: #F0FDFA; color: #134E4A; }
.dir-card__badge--franchise { background: #FCE7F3; color: #9D174D; }
.dir-card__loc { font-size: 0.8125rem; color: #64748B; margin-top: 4px; }
.dir-card__desc {
font-size: 0.8125rem; color: #475569; margin-top: 0.5rem;
display: -webkit-box; -webkit-box-orient: vertical; overflow: hidden;
.dir-card__media img {
width: 100%; height: 100%; object-fit: cover; display: block;
transition: transform 0.45s ease;
}
.dir-card__desc--3line { -webkit-line-clamp: 3; }
.dir-card__foot { display: flex; justify-content: space-between; align-items: center; margin-top: 0.75rem; font-size: 0.75rem; }
.dir-card__web a { color: #1D4ED8; text-decoration: none; }
.dir-card__web a:hover { text-decoration: underline; }
.dir-card__claim { color: #94A3B8; }
.dir-card__claim a { color: #1D4ED8; text-decoration: none; font-weight: 500; }
.dir-card__logo { width: 32px; height: 32px; border-radius: 6px; object-fit: contain; }
.dir-card__logo-placeholder {
width: 32px; height: 32px; border-radius: 6px;
background: #EFF6FF; color: #1D4ED8; font-size: 0.6rem; font-weight: 700;
.dir-card:hover .dir-card__media img { transform: scale(1.04); }
.dir-card__media::after {
content: ''; position: absolute; inset: 0;
background: linear-gradient(to bottom, transparent 45%, rgba(0,0,0,0.45) 100%);
pointer-events: none; z-index: 2;
}
/* CSS court visualization (pro default placeholder) */
.dir-card__media--court {
background:
radial-gradient(ellipse at 50% 20%, rgba(74,222,128,0.15) 0%, transparent 60%),
linear-gradient(160deg, #1a5c30 0%, #2d7a45 35%, #1e6338 65%, #143d22 100%);
}
.dir-card__media--court .court-lines {
position: absolute; inset: 0; z-index: 1;
background-image:
linear-gradient(transparent calc(49.5% - 1px), rgba(255,255,255,0.92) calc(49.5% - 1px), rgba(255,255,255,0.92) calc(50.5% + 1px), transparent calc(50.5% + 1px)),
linear-gradient(transparent calc(8% - 0.5px), rgba(255,255,255,0.65) calc(8% - 0.5px), rgba(255,255,255,0.65) calc(8% + 0.75px), transparent calc(8% + 0.75px)),
linear-gradient(transparent calc(92% - 0.5px), rgba(255,255,255,0.65) calc(92% - 0.5px), rgba(255,255,255,0.65) calc(92% + 0.75px), transparent calc(92% + 0.75px)),
linear-gradient(90deg, transparent calc(10% - 0.5px), rgba(255,255,255,0.55) calc(10% - 0.5px), rgba(255,255,255,0.55) calc(10% + 0.75px), transparent calc(10% + 0.75px)),
linear-gradient(90deg, transparent calc(90% - 0.5px), rgba(255,255,255,0.55) calc(90% - 0.5px), rgba(255,255,255,0.55) calc(90% + 0.75px), transparent calc(90% + 0.75px));
}
.dir-card__media--court .court-lines::before {
content: ''; position: absolute; left: 10%; right: 10%; top: calc(50% - 1.5px); height: 3px;
background-image: repeating-linear-gradient(90deg, rgba(255,255,255,0.18), rgba(255,255,255,0.18) 3px, transparent 3px, transparent 8px);
z-index: 2;
}
.dir-card__media--court .court-lines::after {
content: ''; position: absolute; inset: 0;
background-image: repeating-linear-gradient(0deg, transparent, transparent 5px, rgba(0,0,0,0.015) 5px, rgba(0,0,0,0.015) 6px);
z-index: 0;
}
/* Dark-green grid placeholder (paid tiers without cover photo) */
.dir-card__media--placeholder {
background: linear-gradient(135deg, #0f2218 0%, #1a3828 50%, #0d1e14 100%);
}
.dir-card__media--placeholder .ph-grid {
position: absolute; inset: 0; z-index: 1;
background-image:
repeating-linear-gradient(0deg, transparent, transparent 39px, rgba(255,255,255,0.04) 39px, rgba(255,255,255,0.04) 40px),
repeating-linear-gradient(90deg, transparent, transparent 39px, rgba(255,255,255,0.04) 39px, rgba(255,255,255,0.04) 40px);
}
.dir-card__media--placeholder .ph-label {
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
z-index: 2; font-size: 1.625rem; font-weight: 800;
color: rgba(255,255,255,0.07); letter-spacing: -0.02em; text-transform: uppercase;
}
/* Free tier placeholder — grey/desaturated */
.dir-card--free .dir-card__media--placeholder {
background: linear-gradient(135deg, #1f2937 0%, #374151 50%, #1f2937 100%);
}
/* Example card media */
.dir-card__media--example {
background: linear-gradient(135deg, #1e2e4a 0%, #2d4270 50%, #1a273e 100%);
display: flex; align-items: center; justify-content: center;
}
/* Tier badges */
.dir-card__tier-badge {
display: inline-block; padding: 2px 8px; border-radius: 4px;
font-size: 0.625rem; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.04em; margin-top: 4px;
.dir-card__media--example .example-icon {
text-align: center; z-index: 3; position: relative;
}
.dir-card__tier-badge--verified { background: #D1FAE5; color: #065F46; }
.dir-card__tier-badge--growth { background: #DBEAFE; color: #1D4ED8; }
.dir-card__tier-badge--unverified { background: #F1F5F9; color: #94A3B8; }
/* Free card — muted */
.dir-card--free {
opacity: 0.7; background: #FAFBFC;
.dir-card__media--example .example-icon svg { opacity: 0.25; }
.dir-card__media--example .example-icon p {
margin-top: 6px; font-size: 0.6875rem; font-weight: 600;
color: rgba(255,255,255,0.25); letter-spacing: 0.04em;
}
.dir-card--free:hover { opacity: 0.85; }
/* Pro card — green accent */
/* ---- Overlays ---- */
/* Category badge — frosted pill, top-right of media */
.dir-card__cat {
position: absolute; top: 10px; right: 10px; z-index: 5;
font-size: 0.6375rem; font-weight: 700; letter-spacing: 0.03em;
text-transform: uppercase; padding: 3.5px 9px; border-radius: 5px;
backdrop-filter: blur(8px) saturate(180%); -webkit-backdrop-filter: blur(8px) saturate(180%);
white-space: nowrap;
}
.dir-card__cat--manufacturer { background: rgba(219,234,254,0.92); color: #1e40af; }
.dir-card__cat--turnkey { background: rgba(220,252,231,0.92); color: #065f46; }
.dir-card__cat--turf { background: rgba(254,243,199,0.92); color: #78350f; }
.dir-card__cat--lighting { background: rgba(254,226,226,0.92); color: #991b1b; }
.dir-card__cat--software { background: rgba(237,233,254,0.92); color: #4c1d95; }
.dir-card__cat--hall_builder { background: rgba(255,237,213,0.92); color: #7c2d12; }
.dir-card__cat--consultant { background: rgba(240,253,250,0.92); color: #134e4a; }
.dir-card__cat--franchise { background: rgba(252,231,243,0.92); color: #831843; }
.dir-card__cat--example { background: rgba(219,234,254,0.85); color: #1e40af; }
/* Featured ribbon — top-left */
.dir-card__featured {
position: absolute; top: 10px; left: 10px; z-index: 5;
background: var(--dir-blue); color: white;
font-size: 0.5875rem; font-weight: 700; letter-spacing: 0.08em;
text-transform: uppercase; padding: 3.5px 9px; border-radius: 5px;
box-shadow: 0 2px 8px rgba(29,78,216,0.35);
}
/* Logo avatar — body-relative so it straddles the media/body border */
.dir-card__logo-wrap {
position: absolute; top: -22px; left: 14px; z-index: 6;
}
.dir-card__logo,
.dir-card__logo-ph {
width: 44px; height: 44px; border-radius: 10px;
border: 2.5px solid white; box-shadow: 0 2px 8px rgba(0,0,0,0.18);
display: flex; align-items: center; justify-content: center;
}
.dir-card__logo { object-fit: contain; background: white; }
.dir-card__logo-ph {
background: #EFF6FF; color: var(--dir-blue);
font-size: 0.875rem; font-weight: 800; letter-spacing: -0.01em;
}
/* ---- Card body ---- */
.dir-card__body {
position: relative;
padding: 30px 16px 16px; /* 30px top = logo overhang room */
}
.dir-card__name {
font-size: 0.9375rem; font-weight: 700; color: var(--dir-text-1);
line-height: 1.25; letter-spacing: -0.01em;
}
.dir-card__loc {
display: flex; align-items: center; gap: 3px;
font-size: 0.75rem; color: var(--dir-text-2); margin-top: 3px;
}
.dir-card__loc svg { flex-shrink: 0; }
.dir-card__stats {
display: flex; flex-wrap: wrap; align-items: center; gap: 10px;
margin-top: 10px; padding-top: 10px; border-top: 1px solid #F1F5F9;
}
.dir-card__stat {
display: flex; align-items: center; gap: 3.5px;
font-size: 0.6875rem; font-weight: 600; color: var(--dir-text-2); white-space: nowrap;
}
.dir-card__stat svg { flex-shrink: 0; color: var(--dir-text-3); }
.dir-card__stat--verified { color: var(--dir-green); }
.dir-card__stat--verified svg { color: var(--dir-green); }
.dir-card__tier-chip {
font-size: 0.5875rem; font-weight: 700; letter-spacing: 0.05em;
text-transform: uppercase; padding: 2.5px 7px; border-radius: 4px;
}
.dir-card__tier-chip--growth { background: #EFF6FF; color: #1e40af; }
.dir-card__tier-chip--unverified { background: #F9FAFB; color: var(--dir-text-3); }
.dir-card__desc {
font-size: 0.8125rem; color: var(--dir-text-2); line-height: 1.55; margin-top: 8px;
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden;
}
.dir-card__foot {
display: flex; align-items: center; justify-content: space-between;
margin-top: 12px; padding-top: 12px; border-top: 1px solid #F1F5F9;
}
.dir-card__web {
font-size: 0.75rem; color: var(--dir-text-3);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 170px;
}
.dir-card__action {
font-size: 0.75rem; font-weight: 700; letter-spacing: 0.01em;
white-space: nowrap; flex-shrink: 0;
}
.dir-card__action--quote { color: var(--dir-green); }
.dir-card__action--claim { color: var(--dir-text-3); }
.dir-card__action--example { color: var(--dir-blue); }
/* ---- Tier variants ---- */
/* PRO — green top line + subtle green glow */
.dir-card--pro::before {
content: ''; position: absolute; top: 0; left: 0; right: 0; height: 2.5px;
background: linear-gradient(90deg, var(--dir-green-dark), var(--dir-green-mid));
z-index: 7; border-radius: var(--dir-r) var(--dir-r) 0 0;
}
.dir-card--pro {
border-color: #16A34A; border-width: 1.5px;
box-shadow: 0 1px 3px rgba(21,128,61,0.06), 0 1px 2px rgba(0,0,0,0.04);
}
.dir-card--pro:hover { box-shadow: var(--dir-shadow-pro); }
/* Highlight glow */
/* HIGHLIGHT — green ring (boost add-on) */
.dir-card--highlight {
box-shadow: 0 0 0 2px rgba(22,163,74,0.15), 0 4px 20px rgba(22,163,74,0.1);
box-shadow: 0 0 0 2px rgba(21,128,61,0.2), 0 6px 24px rgba(21,128,61,0.1);
}
.dir-card--highlight:hover {
box-shadow: 0 0 0 2px rgba(21,128,61,0.3), var(--dir-shadow-pro);
}
/* Sticky — blue top border */
.dir-card--sticky {
border-top: 3px solid #1D4ED8;
/* FREE — muted */
.dir-card--free { opacity: 0.62; }
.dir-card--free:hover {
opacity: 0.82; transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0,0,0,0.08);
}
/* Featured badge */
.dir-card__featured-badge {
position: absolute; top: -1px; right: 12px;
background: #1D4ED8; color: white; font-size: 0.625rem;
font-weight: 700; padding: 3px 8px; border-radius: 0 0 6px 6px;
text-transform: uppercase; letter-spacing: 0.04em;
}
/* Example card */
.dir-card--example {
border: 2px dashed #93C5FD; background: #EFF6FF;
}
/* EXAMPLE — dashed CTA card */
.dir-card--example { border: 2px dashed #93C5FD; background: #F0F7FF; }
.dir-card--example::before { display: none; }
.dir-pagination { display: flex; justify-content: center; gap: 4px; margin: 2rem 0; }
.dir-pagination a, .dir-pagination span {

View File

@@ -3,112 +3,270 @@
{% if page > 1 %} (page {{ page }}){% endif %}
</p>
{# Category abbrev for placeholder label #}
{% set ph_labels = {
'manufacturer': 'Mfr',
'turnkey': 'Turn',
'turf': 'Turf',
'lighting': 'Lgts',
'software': 'Sftw',
'hall_builder': 'Hall',
'consultant': 'Cons',
'franchise': 'Frch',
'industry_body': 'Ind',
} %}
{# Logo placeholder background/color by category #}
{% set logo_ph_styles = {
'manufacturer': 'background:#DBEAFE;color:#1e40af',
'turnkey': 'background:#D1FAE5;color:#065f46',
'turf': 'background:#FEF3C7;color:#78350f',
'lighting': 'background:#FEE2E2;color:#991b1b',
'software': 'background:#EDE9FE;color:#4c1d95',
'hall_builder': 'background:#FFEDD5;color:#7c2d12',
'consultant': 'background:#CCFBF1;color:#134e4a',
'franchise': 'background:#FCE7F3;color:#831843',
} %}
{% if suppliers %}
<div class="dir-grid">
{# Example card on first page with no active filters #}
{% if page == 1 and not q and not country and not category %}
<div class="dir-card dir-card--example">
<div class="dir-card__featured-badge">Example</div>
<div class="dir-card__head">
<div style="display:flex;align-items:center;gap:8px">
<div class="dir-card__logo-placeholder">Logo</div>
<h3 class="dir-card__name">Your Company</h3>
<a href="{{ url_for('public.suppliers') }}" class="dir-card dir-card--example">
<div class="dir-card__media dir-card__media--example">
<div class="ph-grid"></div>
<div class="example-icon">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
<p>Your project photo</p>
</div>
<span class="dir-card__badge dir-card__badge--manufacturer">Manufacturer</span>
<div class="dir-card__featured" style="background:#3B82F6">Example</div>
<div class="dir-card__cat dir-card__cat--example">Your Category</div>
</div>
<p class="dir-card__loc">Your City, Your Country</p>
<div class="dir-card__tier-badge dir-card__tier-badge--verified">Verified &#10003;</div>
<p class="dir-card__desc">Full description of your company, services, certifications, and experience. Pro listings get maximum visibility with logo, website, and priority placement.</p>
<div class="dir-card__foot">
<span class="dir-card__web"><a href="{{ url_for('public.suppliers') }}">yourcompany.com</a></span>
<div class="dir-card__body">
<div class="dir-card__logo-wrap">
<div class="dir-card__logo-ph">?</div>
</div>
<h3 class="dir-card__name">Your Company</h3>
<p class="dir-card__loc">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z"/><circle cx="12" cy="10" r="3"/></svg>
Your City, Country
</p>
<div class="dir-card__stats">
<span class="dir-card__stat dir-card__stat--verified">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
Verified
</span>
<span class="dir-card__stat">12 projects · 8 yrs</span>
</div>
<p class="dir-card__desc">Verified listings include cover photo, project stats, and a direct quote button — placed above unverified suppliers in search results.</p>
<div class="dir-card__foot">
<span></span>
<span class="dir-card__action dir-card__action--example">Get listed →</span>
</div>
</div>
<p style="text-align:center;font-size:0.75rem;color:#1D4ED8;margin-top:0.5rem;font-weight:600">
<a href="{{ url_for('public.suppliers') }}" style="color:#1D4ED8;text-decoration:none">Your listing could look like this &rarr;</a>
</p>
</div>
</a>
{% endif %}
{% for s in suppliers %}
{# --- Pro tier card --- #}
{% set logo_ph_style = logo_ph_styles.get(s.category, 'background:#EFF6FF;color:#1e40af') %}
{% set ph_label = ph_labels.get(s.category, s.category[:4] | upper) %}
{# ---- Pro tier ---- #}
{% if s.tier == 'pro' %}
<a href="{{ url_for('directory.supplier_detail', slug=s.slug) }}" class="dir-card dir-card--pro {% if s.sticky_until and s.sticky_until > now %}dir-card--sticky{% endif %} {% if s.highlight %}dir-card--highlight{% endif %}" style="text-decoration:none;color:inherit;display:block{% if card_colors.get(s.id) %};border-color:{{ card_colors[s.id] }};border-width:2px{% endif %}">
{% if s.sticky_until and s.sticky_until > now %}<div class="dir-card__featured-badge">Featured</div>{% endif %}
<div class="dir-card__head">
<div style="display:flex;align-items:center;gap:8px">
{% if s.logo_file or s.logo_url %}<img src="{{ s.logo_file or s.logo_url }}" alt="" class="dir-card__logo">{% endif %}
<h3 class="dir-card__name">{{ s.name }}</h3>
</div>
<span class="dir-card__badge dir-card__badge--{{ s.category }}">{{ category_labels.get(s.category, s.category) }}</span>
<a href="{{ url_for('directory.supplier_detail', slug=s.slug) }}"
class="dir-card dir-card--pro{% if s.sticky_until and s.sticky_until > now %} dir-card--sticky{% endif %}{% if s.highlight %} dir-card--highlight{% endif %}"{% if card_colors.get(s.id) %} style="border-color:{{ card_colors[s.id] }}"{% endif %}>
{% if s.cover_image %}
<div class="dir-card__media">
<img src="{{ s.cover_image }}" alt="{{ s.name }}">
{% if s.sticky_until and s.sticky_until > now %}<div class="dir-card__featured">Featured</div>{% endif %}
<div class="dir-card__cat dir-card__cat--{{ s.category }}">{{ category_labels.get(s.category, s.category) }}</div>
</div>
{% else %}
<div class="dir-card__media dir-card__media--court">
<div class="court-lines"></div>
{% if s.sticky_until and s.sticky_until > now %}<div class="dir-card__featured">Featured</div>{% endif %}
<div class="dir-card__cat dir-card__cat--{{ s.category }}">{{ category_labels.get(s.category, s.category) }}</div>
</div>
<p class="dir-card__loc">{{ country_labels.get(s.country_code, s.country_code) }}{% if s.city %}, {{ s.city }}{% endif %}</p>
<div class="dir-card__tier-badge dir-card__tier-badge--verified">Verified &#10003;</div>
{% if s.description %}
<p class="dir-card__desc">{{ s.description }}</p>
{% endif %}
<div class="dir-card__foot">
<span class="dir-card__web">
{% if s.website %}{{ s.website }}{% endif %}
</span>
<span style="font-size:0.6875rem;color:#1D4ED8;font-weight:600">Request Quote &rarr;</span>
<div class="dir-card__body">
<div class="dir-card__logo-wrap">
{% if s.logo_file or s.logo_url %}
<img src="{{ s.logo_file or s.logo_url }}" alt="" class="dir-card__logo">
{% else %}
<div class="dir-card__logo-ph" style="{{ logo_ph_style }}">{{ s.name[0] | upper }}</div>
{% endif %}
</div>
<h3 class="dir-card__name">{{ s.name }}</h3>
<p class="dir-card__loc">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z"/><circle cx="12" cy="10" r="3"/></svg>
{{ country_labels.get(s.country_code, s.country_code) }}{% if s.city %}, {{ s.city }}{% endif %}
</p>
<div class="dir-card__stats">
<span class="dir-card__stat dir-card__stat--verified">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
Verified
</span>
{% if s.project_count %}
<span class="dir-card__stat">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/></svg>
{{ s.project_count }} projects
</span>
{% endif %}
{% if s.years_in_business %}
<span class="dir-card__stat">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
{{ s.years_in_business }} yrs
</span>
{% endif %}
{% if s.service_area %}
<span class="dir-card__stat">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z"/></svg>
{{ s.service_area.split(',')[0] }}{% if s.service_area.split(',')|length > 1 %} +{{ s.service_area.split(',')|length - 1 }}{% endif %}
</span>
{% endif %}
</div>
{% if s.short_description or s.description %}
<p class="dir-card__desc">{{ s.short_description or s.description }}</p>
{% endif %}
<div class="dir-card__foot">
<span class="dir-card__web">{{ s.website or '' }}</span>
<span class="dir-card__action dir-card__action--quote">Request Quote →</span>
</div>
</div>
</a>
{# --- Growth tier card --- #}
{# ---- Growth tier ---- #}
{% elif s.tier == 'growth' %}
<a href="{{ url_for('directory.supplier_detail', slug=s.slug) }}" class="dir-card dir-card--growth {% if s.sticky_until and s.sticky_until > now %}dir-card--sticky{% endif %}" style="text-decoration:none;color:inherit;display:block{% if card_colors.get(s.id) %};border-color:{{ card_colors[s.id] }};border-width:2px{% endif %}">
{% if s.sticky_until and s.sticky_until > now %}<div class="dir-card__featured-badge">Featured</div>{% endif %}
<div class="dir-card__head">
<h3 class="dir-card__name">{{ s.name }}</h3>
<span class="dir-card__badge dir-card__badge--{{ s.category }}">{{ category_labels.get(s.category, s.category) }}</span>
<a href="{{ url_for('directory.supplier_detail', slug=s.slug) }}"
class="dir-card{% if s.sticky_until and s.sticky_until > now %} dir-card--sticky{% endif %}"{% if card_colors.get(s.id) %} style="border-color:{{ card_colors[s.id] }}"{% endif %}>
{% if s.cover_image %}
<div class="dir-card__media">
<img src="{{ s.cover_image }}" alt="{{ s.name }}">
{% if s.sticky_until and s.sticky_until > now %}<div class="dir-card__featured">Featured</div>{% endif %}
<div class="dir-card__cat dir-card__cat--{{ s.category }}">{{ category_labels.get(s.category, s.category) }}</div>
</div>
{% else %}
<div class="dir-card__media dir-card__media--placeholder">
<div class="ph-grid"></div>
<div class="ph-label">{{ ph_label }}</div>
{% if s.sticky_until and s.sticky_until > now %}<div class="dir-card__featured">Featured</div>{% endif %}
<div class="dir-card__cat dir-card__cat--{{ s.category }}">{{ category_labels.get(s.category, s.category) }}</div>
</div>
<p class="dir-card__loc">{{ country_labels.get(s.country_code, s.country_code) }}{% if s.city %}, {{ s.city }}{% endif %}</p>
<div class="dir-card__tier-badge dir-card__tier-badge--growth">Growth</div>
{% if s.description %}
<p class="dir-card__desc dir-card__desc--3line">{{ s.description }}</p>
{% endif %}
<div class="dir-card__foot">
<span></span>
<span style="font-size:0.6875rem;color:#1D4ED8;font-weight:600">Request Quote &rarr;</span>
</div>
</a>
{# --- Basic tier card --- #}
{% elif s.tier == 'basic' %}
<a href="{{ url_for('directory.supplier_detail', slug=s.slug) }}" class="dir-card dir-card--basic {% if s.sticky_until and s.sticky_until > now %}dir-card--sticky{% endif %}" style="text-decoration:none;color:inherit;display:block">
{% if s.sticky_until and s.sticky_until > now %}<div class="dir-card__featured-badge">Featured</div>{% endif %}
<div class="dir-card__head">
<div style="display:flex;align-items:center;gap:8px">
{% if s.logo_file or s.logo_url %}<img src="{{ s.logo_file or s.logo_url }}" alt="" class="dir-card__logo">{% endif %}
<h3 class="dir-card__name">{{ s.name }}</h3>
<div class="dir-card__body">
<div class="dir-card__logo-wrap">
{% if s.logo_file or s.logo_url %}
<img src="{{ s.logo_file or s.logo_url }}" alt="" class="dir-card__logo">
{% else %}
<div class="dir-card__logo-ph" style="{{ logo_ph_style }}">{{ s.name[0] | upper }}</div>
{% endif %}
</div>
<h3 class="dir-card__name">{{ s.name }}</h3>
<p class="dir-card__loc">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z"/><circle cx="12" cy="10" r="3"/></svg>
{{ country_labels.get(s.country_code, s.country_code) }}{% if s.city %}, {{ s.city }}{% endif %}
</p>
<div class="dir-card__stats">
<span class="dir-card__tier-chip dir-card__tier-chip--growth">Growth</span>
{% if s.project_count %}
<span class="dir-card__stat">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/></svg>
{{ s.project_count }} projects
</span>
{% elif s.years_in_business %}
<span class="dir-card__stat">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
{{ s.years_in_business }} yrs
</span>
{% endif %}
</div>
{% if s.short_description or s.description %}
<p class="dir-card__desc">{{ s.short_description or s.description }}</p>
{% endif %}
<div class="dir-card__foot">
<span class="dir-card__web">{{ s.website or '' }}</span>
<span class="dir-card__action dir-card__action--quote">Request Quote →</span>
</div>
<span class="dir-card__badge dir-card__badge--{{ s.category }}">{{ category_labels.get(s.category, s.category) }}</span>
</div>
<p class="dir-card__loc">{{ country_labels.get(s.country_code, s.country_code) }}{% if s.city %}, {{ s.city }}{% endif %}</p>
<div class="dir-card__tier-badge dir-card__tier-badge--verified">Verified &#10003;</div>
{% if s.short_description or s.description %}
<p class="dir-card__desc dir-card__desc--3line">{{ s.short_description or s.description }}</p>
{% endif %}
<div class="dir-card__foot">
<span class="dir-card__web">{% if s.website %}{{ s.website }}{% endif %}</span>
<span style="font-size:0.6875rem;color:#64748B;font-weight:600">View Listing &rarr;</span>
</div>
</a>
{# --- Free / unclaimed tier card --- #}
{% else %}
<a href="{{ url_for('directory.supplier_detail', slug=s.slug) }}" class="dir-card dir-card--free" style="text-decoration:none;color:inherit;display:block">
<div class="dir-card__head">
<h3 class="dir-card__name">{{ s.name }}</h3>
<span class="dir-card__badge dir-card__badge--{{ s.category }}">{{ category_labels.get(s.category, s.category) }}</span>
{# ---- Basic tier ---- #}
{% elif s.tier == 'basic' %}
<a href="{{ url_for('directory.supplier_detail', slug=s.slug) }}"
class="dir-card{% if s.sticky_until and s.sticky_until > now %} dir-card--sticky{% endif %}">
{% if s.cover_image %}
<div class="dir-card__media">
<img src="{{ s.cover_image }}" alt="{{ s.name }}">
{% if s.sticky_until and s.sticky_until > now %}<div class="dir-card__featured">Featured</div>{% endif %}
<div class="dir-card__cat dir-card__cat--{{ s.category }}">{{ category_labels.get(s.category, s.category) }}</div>
</div>
<p class="dir-card__loc">{{ country_labels.get(s.country_code, s.country_code) }}{% if s.city %}, {{ s.city }}{% endif %}</p>
<div class="dir-card__tier-badge dir-card__tier-badge--unverified">Unverified</div>
<div class="dir-card__foot">
<span></span>
<span class="dir-card__claim">Is this your company? &rarr;</span>
{% else %}
<div class="dir-card__media dir-card__media--placeholder">
<div class="ph-grid"></div>
<div class="ph-label">{{ ph_label }}</div>
{% if s.sticky_until and s.sticky_until > now %}<div class="dir-card__featured">Featured</div>{% endif %}
<div class="dir-card__cat dir-card__cat--{{ s.category }}">{{ category_labels.get(s.category, s.category) }}</div>
</div>
{% endif %}
<div class="dir-card__body">
<div class="dir-card__logo-wrap">
{% if s.logo_file or s.logo_url %}
<img src="{{ s.logo_file or s.logo_url }}" alt="" class="dir-card__logo">
{% else %}
<div class="dir-card__logo-ph" style="{{ logo_ph_style }}">{{ s.name[0] | upper }}</div>
{% endif %}
</div>
<h3 class="dir-card__name">{{ s.name }}</h3>
<p class="dir-card__loc">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z"/><circle cx="12" cy="10" r="3"/></svg>
{{ country_labels.get(s.country_code, s.country_code) }}{% if s.city %}, {{ s.city }}{% endif %}
</p>
<div class="dir-card__stats">
<span class="dir-card__stat dir-card__stat--verified">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
Verified
</span>
</div>
{% if s.short_description or s.description %}
<p class="dir-card__desc">{{ s.short_description or s.description }}</p>
{% endif %}
<div class="dir-card__foot">
<span class="dir-card__web">{{ s.website or '' }}</span>
<span class="dir-card__action dir-card__action--claim">View Listing →</span>
</div>
</div>
</a>
{# ---- Free / unclaimed tier ---- #}
{% else %}
<a href="{{ url_for('directory.supplier_detail', slug=s.slug) }}"
class="dir-card dir-card--free">
<div class="dir-card__media dir-card__media--placeholder">
<div class="ph-grid"></div>
<div class="ph-label">{{ ph_label }}</div>
<div class="dir-card__cat dir-card__cat--{{ s.category }}">{{ category_labels.get(s.category, s.category) }}</div>
</div>
<div class="dir-card__body">
<div class="dir-card__logo-wrap">
<div class="dir-card__logo-ph" style="{{ logo_ph_style }}">{{ s.name[0] | upper }}</div>
</div>
<h3 class="dir-card__name">{{ s.name }}</h3>
<p class="dir-card__loc">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z"/><circle cx="12" cy="10" r="3"/></svg>
{{ country_labels.get(s.country_code, s.country_code) }}{% if s.city %}, {{ s.city }}{% endif %}
</p>
<div class="dir-card__stats">
<span class="dir-card__tier-chip dir-card__tier-chip--unverified">Unverified</span>
</div>
<div class="dir-card__foot">
<span></span>
<span class="dir-card__action dir-card__action--claim">Is this yours? →</span>
</div>
</div>
</a>
{% endif %}
{% endfor %}
</div>

View File

@@ -223,7 +223,10 @@ def up(conn):
contact_role TEXT,
linkedin_url TEXT,
instagram_url TEXT,
youtube_url TEXT
youtube_url TEXT,
-- Phase 4: Directory card cover image
cover_image TEXT
);
CREATE INDEX IF NOT EXISTS idx_suppliers_country ON suppliers(country_code);

View File

@@ -0,0 +1,8 @@
"""Add cover_image column to suppliers table."""
def up(conn):
cols = [r[1] for r in conn.execute("PRAGMA table_info(suppliers)").fetchall()]
if "cover_image" not in cols:
conn.execute("ALTER TABLE suppliers ADD COLUMN cover_image TEXT")
conn.commit()

View File

@@ -712,7 +712,7 @@ async def dashboard_listing_save():
supplier = g.supplier
form = await request.form
# Handle file upload
# Handle file uploads
files = await request.files
logo_file = files.get("logo_file")
logo_path = supplier.get("logo_file") or ""
@@ -724,6 +724,16 @@ async def dashboard_listing_save():
await logo_file.save(str(save_path))
logo_path = f"/static/uploads/logos/{filename}"
cover_file = files.get("cover_file")
cover_path = supplier.get("cover_image") or ""
if cover_file and cover_file.filename:
upload_dir = Path(__file__).parent.parent / "static" / "uploads" / "covers"
upload_dir.mkdir(parents=True, exist_ok=True)
filename = secure_filename(f"{supplier['id']}_{cover_file.filename}")
save_path = upload_dir / filename
await cover_file.save(str(save_path))
cover_path = f"/static/uploads/covers/{filename}"
# Multi-select categories
categories = form.getlist("service_categories")
categories_str = ",".join(categories)
@@ -742,7 +752,7 @@ async def dashboard_listing_save():
website = ?, contact_name = ?, contact_email = ?, contact_phone = ?,
service_categories = ?, service_area = ?,
years_in_business = ?, project_count = ?,
logo_file = ?,
logo_file = ?, cover_image = ?,
services_offered = ?, contact_role = ?,
linkedin_url = ?, instagram_url = ?, youtube_url = ?
WHERE id = ?""",
@@ -760,6 +770,7 @@ async def dashboard_listing_save():
int(form.get("years_in_business", 0) or 0),
int(form.get("project_count", 0) or 0),
logo_path,
cover_path,
services_str,
form.get("contact_role", ""),
form.get("linkedin_url", ""),

View File

@@ -100,6 +100,16 @@
</div>
</div>
<div class="lst-full">
<label class="lst-label">Cover Photo
<span style="font-weight:400;color:#94A3B8"> — 16:9, min 640px wide. Shown in directory search results.</span>
</label>
{% if supplier.cover_image %}
<img src="{{ supplier.cover_image }}" alt="Current cover" style="width:100%;aspect-ratio:16/9;object-fit:cover;border-radius:8px;margin-bottom:6px;border:1px solid #E2E8F0">
{% endif %}
<input type="file" name="cover_file" accept="image/*" class="lst-input" style="padding:6px 8px">
</div>
<div class="lst-row">
<div>
<label class="lst-label">Contact Name</label>

File diff suppressed because it is too large Load Diff