[object Object]

← back to Ventura Claw Leads

yolo tick 18: per-business cover photo upload (multer + 2MB cap + type whitelist)

65c0b9132f909cbbc7ca02b437cff531809c8e3a · 2026-05-06 21:42:44 -0700 · Steve Abrams

DB:
- migrations/008_business_photo_path.sql: ADD COLUMN photo_path TEXT NULL.

Upload pipeline:
- lib/photo-upload.js: multer.diskStorage to public/uploads/business-photos
  with timestamped per-business filenames (b-<userId>-<ts>.<ext>). 2 MB cap,
  jpg/png/webp mime + extension whitelist. Errors stash on req._photoUploadError
  so the route handler can show a typed message after CSRF passes.
- server.js: multer middleware mounted on /admin/profile/photo BEFORE
  csrfMiddleware (multipart fields including _csrf must be parsed first; the
  earlier inline-multer attempt failed because csrfMiddleware ran before the
  multipart body was parsed and req.body._csrf was empty).
- routes/admin.js POST /admin/profile/photo: just reads req.file + writes
  photo_path. Best-effort cleanup of the previous photo (path-bound to
  PHOTO_DIR so a manipulated DB value can't escape that directory).
- routes/admin.js POST /admin/profile/photo/delete: clear photo + unlink file.

Public surfaces:
- views/admin/profile.ejs: 'Cover photo' section above the field form. Shows
  current photo (200x120 thumb) + 'Remove photo' button. File input
  accept='image/jpeg,image/png,image/webp' + Upload button.
- views/public/business.ejs: when photo_path is set, render <img> in the
  hero slot instead of the vertical-themed gradient. Fallback unchanged.
- views/public/find.ejs: same swap on biz-card-hero. Routes/public.js GET /find
  expanded to SELECT photo_path so cards can render it.
- public/css/public.css: .vertical-hero-photo + .biz-card-hero-photo with
  object-fit:cover for clean cropping at any aspect ratio.
- server.js: + /uploads static mount with 30d cache.

Verified e2e local: claim → upload 1x1 PNG → photo_path written, file saved
to disk, /business/:slug renders <img src='/uploads/...'>, static GET → 200.
Migration applied prod via sudo -u postgres. 46/46 tests pass.

Files touched

Diff

commit 65c0b9132f909cbbc7ca02b437cff531809c8e3a
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed May 6 21:42:44 2026 -0700

    yolo tick 18: per-business cover photo upload (multer + 2MB cap + type whitelist)
    
    DB:
    - migrations/008_business_photo_path.sql: ADD COLUMN photo_path TEXT NULL.
    
    Upload pipeline:
    - lib/photo-upload.js: multer.diskStorage to public/uploads/business-photos
      with timestamped per-business filenames (b-<userId>-<ts>.<ext>). 2 MB cap,
      jpg/png/webp mime + extension whitelist. Errors stash on req._photoUploadError
      so the route handler can show a typed message after CSRF passes.
    - server.js: multer middleware mounted on /admin/profile/photo BEFORE
      csrfMiddleware (multipart fields including _csrf must be parsed first; the
      earlier inline-multer attempt failed because csrfMiddleware ran before the
      multipart body was parsed and req.body._csrf was empty).
    - routes/admin.js POST /admin/profile/photo: just reads req.file + writes
      photo_path. Best-effort cleanup of the previous photo (path-bound to
      PHOTO_DIR so a manipulated DB value can't escape that directory).
    - routes/admin.js POST /admin/profile/photo/delete: clear photo + unlink file.
    
    Public surfaces:
    - views/admin/profile.ejs: 'Cover photo' section above the field form. Shows
      current photo (200x120 thumb) + 'Remove photo' button. File input
      accept='image/jpeg,image/png,image/webp' + Upload button.
    - views/public/business.ejs: when photo_path is set, render <img> in the
      hero slot instead of the vertical-themed gradient. Fallback unchanged.
    - views/public/find.ejs: same swap on biz-card-hero. Routes/public.js GET /find
      expanded to SELECT photo_path so cards can render it.
    - public/css/public.css: .vertical-hero-photo + .biz-card-hero-photo with
      object-fit:cover for clean cropping at any aspect ratio.
    - server.js: + /uploads static mount with 30d cache.
    
    Verified e2e local: claim → upload 1x1 PNG → photo_path written, file saved
    to disk, /business/:slug renders <img src='/uploads/...'>, static GET → 200.
    Migration applied prod via sudo -u postgres. 46/46 tests pass.
---
 db/migrations/008_business_photo_path.sql |   5 ++
 lib/photo-upload.js                       |  46 ++++++++++++++
 package-lock.json                         | 101 ++++++++++++++++++++++++++++++
 package.json                              |   1 +
 public/css/public.css                     |   4 ++
 public/img/founder-steve.png              | Bin 0 -> 1444764 bytes
 public/uploads/business-photos/.gitignore |   3 +
 public/uploads/business-photos/.gitkeep   |   1 +
 routes/admin.js                           |  48 +++++++++++++-
 routes/public.js                          |   2 +-
 server.js                                 |  15 +++--
 views/admin/profile.ejs                   |  24 +++++++
 views/public/business.ejs                 |  12 +++-
 views/public/find.ejs                     |  12 +++-
 14 files changed, 262 insertions(+), 12 deletions(-)

diff --git a/db/migrations/008_business_photo_path.sql b/db/migrations/008_business_photo_path.sql
new file mode 100644
index 0000000..7a9d327
--- /dev/null
+++ b/db/migrations/008_business_photo_path.sql
@@ -0,0 +1,5 @@
+-- Profile photo per business. Stored as a relative path under
+-- /uploads/business-photos/ — served as a static asset by Express.
+-- NULL = use the vertical-themed gradient hero (the v0.2 fallback).
+
+ALTER TABLE businesses ADD COLUMN IF NOT EXISTS photo_path TEXT;
diff --git a/lib/photo-upload.js b/lib/photo-upload.js
new file mode 100644
index 0000000..c5221d0
--- /dev/null
+++ b/lib/photo-upload.js
@@ -0,0 +1,46 @@
+// Multer multipart parser for /admin/profile/photo. Mounted as middleware
+// in server.js BEFORE csrfMiddleware so multipart fields (including _csrf)
+// land in req.body in time for CSRF verification. The actual upload route
+// in routes/admin.js then reads req.file from this middleware's output.
+
+const path = require('node:path');
+const fs = require('node:fs');
+
+module.exports = function (multer) {
+  const PHOTO_DIR = path.resolve(__dirname, '..', 'public', 'uploads', 'business-photos');
+  fs.mkdirSync(PHOTO_DIR, { recursive: true });
+
+  const photoStorage = multer.diskStorage({
+    destination: (req, file, cb) => cb(null, PHOTO_DIR),
+    filename: (req, file, cb) => {
+      const ext = (path.extname(file.originalname) || '').toLowerCase();
+      const safeExt = ['.jpg', '.jpeg', '.png', '.webp'].includes(ext) ? (ext === '.jpeg' ? '.jpg' : ext) : '.bin';
+      const bizId = (req.session && req.session.businessUserId) || 'anon';
+      cb(null, `b-${bizId}-${Date.now()}${safeExt}`);
+    }
+  });
+
+  const photoUpload = multer({
+    storage: photoStorage,
+    limits: { fileSize: 2 * 1024 * 1024 },
+    fileFilter: (req, file, cb) => {
+      if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.mimetype)) {
+        return cb(new Error('only_jpg_png_webp'));
+      }
+      cb(null, true);
+    }
+  });
+
+  return function multerWrap(req, res, next) {
+    // GET (no body) and POST /delete (urlencoded, not multipart) skip multer.
+    if (req.method !== 'POST' || req.path === '/delete') return next();
+    photoUpload.single('photo')(req, res, (err) => {
+      if (err) {
+        // Stash the error code on req so the route handler can render the
+        // right message after CSRF passes.
+        req._photoUploadError = err.code === 'LIMIT_FILE_SIZE' ? 'too_big' : (err.message || 'upload_failed');
+      }
+      next();
+    });
+  };
+};
diff --git a/package-lock.json b/package-lock.json
index c90312e..82a921b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18,6 +18,7 @@
         "helmet": "^8.1.0",
         "luxon": "^3.5.0",
         "morgan": "^1.10.0",
+        "multer": "^2.1.1",
         "nodemailer": "^8.0.7",
         "pg": "^8.13.1",
         "slugify": "^1.6.6",
@@ -37,6 +38,12 @@
         "node": ">= 0.6"
       }
     },
+    "node_modules/append-field": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
+      "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
+      "license": "MIT"
+    },
     "node_modules/array-flatten": {
       "version": "1.1.1",
       "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@@ -135,6 +142,23 @@
         "balanced-match": "^1.0.0"
       }
     },
+    "node_modules/buffer-from": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
+      "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
+      "license": "MIT"
+    },
+    "node_modules/busboy": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
+      "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
+      "dependencies": {
+        "streamsearch": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=10.16.0"
+      }
+    },
     "node_modules/bytes": {
       "version": "3.1.2",
       "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -173,6 +197,21 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/concat-stream": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
+      "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
+      "engines": [
+        "node >= 6.0"
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "buffer-from": "^1.0.0",
+        "inherits": "^2.0.3",
+        "readable-stream": "^3.0.2",
+        "typedarray": "^0.0.6"
+      }
+    },
     "node_modules/connect-pg-simple": {
       "version": "9.0.1",
       "resolved": "https://registry.npmjs.org/connect-pg-simple/-/connect-pg-simple-9.0.1.tgz",
@@ -770,6 +809,25 @@
       "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
       "license": "MIT"
     },
+    "node_modules/multer": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz",
+      "integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
+      "license": "MIT",
+      "dependencies": {
+        "append-field": "^1.0.0",
+        "busboy": "^1.6.0",
+        "concat-stream": "^2.0.0",
+        "type-is": "^1.6.18"
+      },
+      "engines": {
+        "node": ">= 10.16.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
     "node_modules/negotiator": {
       "version": "0.6.3",
       "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@@ -1051,6 +1109,20 @@
         "node": ">= 0.8"
       }
     },
+    "node_modules/readable-stream": {
+      "version": "3.6.2",
+      "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
+      "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+      "license": "MIT",
+      "dependencies": {
+        "inherits": "^2.0.3",
+        "string_decoder": "^1.1.1",
+        "util-deprecate": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
     "node_modules/safe-buffer": {
       "version": "5.2.1",
       "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -1227,6 +1299,23 @@
         "node": ">= 0.8"
       }
     },
+    "node_modules/streamsearch": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
+      "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
+      "engines": {
+        "node": ">=10.0.0"
+      }
+    },
+    "node_modules/string_decoder": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+      "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+      "license": "MIT",
+      "dependencies": {
+        "safe-buffer": "~5.2.0"
+      }
+    },
     "node_modules/stripe": {
       "version": "22.1.1",
       "resolved": "https://registry.npmjs.org/stripe/-/stripe-22.1.1.tgz",
@@ -1266,6 +1355,12 @@
         "node": ">= 0.6"
       }
     },
+    "node_modules/typedarray": {
+      "version": "0.0.6",
+      "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
+      "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
+      "license": "MIT"
+    },
     "node_modules/uid-safe": {
       "version": "2.1.5",
       "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
@@ -1287,6 +1382,12 @@
         "node": ">= 0.8"
       }
     },
+    "node_modules/util-deprecate": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+      "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+      "license": "MIT"
+    },
     "node_modules/utils-merge": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
diff --git a/package.json b/package.json
index d954198..d5bfd40 100644
--- a/package.json
+++ b/package.json
@@ -21,6 +21,7 @@
     "helmet": "^8.1.0",
     "luxon": "^3.5.0",
     "morgan": "^1.10.0",
+    "multer": "^2.1.1",
     "nodemailer": "^8.0.7",
     "pg": "^8.13.1",
     "slugify": "^1.6.6",
diff --git a/public/css/public.css b/public/css/public.css
index 85432a6..212065c 100644
--- a/public/css/public.css
+++ b/public/css/public.css
@@ -73,6 +73,10 @@
   border-radius: 4px 4px 0 0;
 }
 .biz-card-glyph { font-size: clamp(36px, 5vw, 56px); opacity: 0.9; line-height: 1; user-select: none; }
+.vertical-hero-photo { padding: 0; }
+.vertical-hero-photo img { width: 100%; height: 100%; object-fit: cover; display: block; }
+.biz-card-hero-photo { padding: 0; overflow: hidden; }
+.biz-card-hero-photo img { width: 100%; height: 100%; object-fit: cover; display: block; }
 
 /* 8-vertical palette: a saturated gradient + readable emoji on each.
    Each pair chosen to remain legible in light + dark modes. */
diff --git a/public/img/founder-steve.png b/public/img/founder-steve.png
new file mode 100644
index 0000000..123000d
Binary files /dev/null and b/public/img/founder-steve.png differ
diff --git a/public/uploads/business-photos/.gitignore b/public/uploads/business-photos/.gitignore
new file mode 100644
index 0000000..bf27f31
--- /dev/null
+++ b/public/uploads/business-photos/.gitignore
@@ -0,0 +1,3 @@
+*
+!.gitignore
+!.gitkeep
diff --git a/public/uploads/business-photos/.gitkeep b/public/uploads/business-photos/.gitkeep
new file mode 100644
index 0000000..c90b79a
--- /dev/null
+++ b/public/uploads/business-photos/.gitkeep
@@ -0,0 +1 @@
+/* keep this dir tracked but ignore uploaded files */
diff --git a/routes/admin.js b/routes/admin.js
index 8b75c9c..0890220 100644
--- a/routes/admin.js
+++ b/routes/admin.js
@@ -1,10 +1,17 @@
 const express = require('express');
+const path = require('node:path');
+const fs = require('node:fs');
 const slugify = require('slugify');
 const db = require('../lib/db');
 const auth = require('../lib/auth');
 const stripe = require('../lib/stripe');
 const router = express.Router();
 
+// Photo upload directory — actual multer parsing happens in server.js
+// BEFORE csrfMiddleware (see lib/photo-upload.js). This route handler
+// just reads req.file (the parsed result) and updates the DB.
+const PHOTO_DIR = path.resolve(__dirname, '..', 'public', 'uploads', 'business-photos');
+
 const PUBLIC_URL = (process.env.PUBLIC_URL || 'https://leads.venturaclaw.com').replace(/\/+$/, '');
 
 router.use(auth.requireBusiness);
@@ -60,7 +67,46 @@ router.get('/', async (req, res, next) => {
 router.get('/profile', async (req, res, next) => {
   try {
     const biz = await db.one(`SELECT * FROM businesses WHERE id = $1`, [res.locals.currentBusiness.id]);
-    res.render('admin/profile', { title: 'Edit profile · Ventura Claw', biz, error: null, ok: req.query.saved === '1' });
+    res.render('admin/profile', {
+      title: 'Edit profile · Ventura Claw', biz, error: null,
+      ok: req.query.saved === '1',
+      photoOk: req.query.photo === '1',
+      photoErr: req.query.photo_err || null
+    });
+  } catch (err) { next(err); }
+});
+
+router.post('/profile/photo', async (req, res, next) => {
+  try {
+    if (req._photoUploadError) {
+      return res.redirect('/admin/profile?photo_err=' + encodeURIComponent(req._photoUploadError));
+    }
+    if (!req.file) return res.redirect('/admin/profile?photo_err=no_file');
+    const bizId = res.locals.currentBusiness.id;
+    // Best-effort cleanup of the previous photo so we don't leak disk.
+    const prev = await db.one(`SELECT photo_path FROM businesses WHERE id = $1`, [bizId]);
+    if (prev && prev.photo_path) {
+      const prevAbs = path.resolve(__dirname, '..', 'public', prev.photo_path.replace(/^\/+/, ''));
+      if (prevAbs.startsWith(PHOTO_DIR)) {
+        try { fs.unlinkSync(prevAbs); } catch {}
+      }
+    }
+    const relPath = '/uploads/business-photos/' + req.file.filename;
+    await db.query(`UPDATE businesses SET photo_path = $1 WHERE id = $2`, [relPath, bizId]);
+    res.redirect('/admin/profile?photo=1');
+  } catch (err) { next(err); }
+});
+
+router.post('/profile/photo/delete', async (req, res, next) => {
+  try {
+    const bizId = res.locals.currentBusiness.id;
+    const prev = await db.one(`SELECT photo_path FROM businesses WHERE id = $1`, [bizId]);
+    if (prev && prev.photo_path) {
+      const prevAbs = path.resolve(__dirname, '..', 'public', prev.photo_path.replace(/^\/+/, ''));
+      if (prevAbs.startsWith(PHOTO_DIR)) { try { fs.unlinkSync(prevAbs); } catch {} }
+    }
+    await db.query(`UPDATE businesses SET photo_path = NULL WHERE id = $1`, [bizId]);
+    res.redirect('/admin/profile?photo=1');
   } catch (err) { next(err); }
 });
 
diff --git a/routes/public.js b/routes/public.js
index d585212..f9fb5c3 100644
--- a/routes/public.js
+++ b/routes/public.js
@@ -72,7 +72,7 @@ router.get('/find', async (req, res, next) => {
     const orderBy = orderClauses[sortKey] || orderClauses.featured;
 
     const businesses = await db.many(`
-      SELECT id, slug, business_name, vertical, headline, neighborhood, city, state, tier, claim_status, verified
+      SELECT id, slug, business_name, vertical, headline, neighborhood, city, state, tier, claim_status, verified, photo_path
         FROM businesses
        WHERE ${where.join(' AND ')}
        ORDER BY ${orderBy}
diff --git a/server.js b/server.js
index b671e23..bda4dc1 100644
--- a/server.js
+++ b/server.js
@@ -65,10 +65,11 @@ app.use('/webhooks/stripe', express.raw({ type: 'application/json', limit: '512k
 
 app.use(express.urlencoded({ extended: false, limit: '64kb' }));
 app.use(express.json({ limit: '64kb' }));
-app.use('/static', express.static(path.join(__dirname, 'public'), { maxAge: '7d' }));
-app.use('/css',    express.static(path.join(__dirname, 'public/css'),  { maxAge: '7d' }));
-app.use('/js',     express.static(path.join(__dirname, 'public/js'),   { maxAge: '7d' }));
-app.use('/img',    express.static(path.join(__dirname, 'public/img'),  { maxAge: '7d' }));
+app.use('/static',   express.static(path.join(__dirname, 'public'), { maxAge: '7d' }));
+app.use('/css',      express.static(path.join(__dirname, 'public/css'),     { maxAge: '7d' }));
+app.use('/js',       express.static(path.join(__dirname, 'public/js'),      { maxAge: '7d' }));
+app.use('/img',      express.static(path.join(__dirname, 'public/img'),     { maxAge: '7d' }));
+app.use('/uploads',  express.static(path.join(__dirname, 'public/uploads'), { maxAge: '30d', immutable: false }));
 
 // Locals exposed to every EJS template.
 app.use((req, res, next) => {
@@ -82,6 +83,12 @@ app.use(auth.attachBusiness);
 // RFC 8058 one-click unsubscribe — email clients POST without browser session.
 // The HMAC-derived token IS bearer auth; bypass CSRF.
 app.use('/unsubscribe', (req, res, next) => { req.skipCsrf = true; next(); });
+// Photo upload is multipart/form-data — multer must parse it BEFORE
+// csrfMiddleware so req.body._csrf is populated for the timing-safe compare.
+// The actual route handler (in routes/admin.js) reads req.file directly.
+const multer = require('multer');
+const photoUploadMiddleware = require('./lib/photo-upload')(multer);
+app.use('/admin/profile/photo', photoUploadMiddleware);
 app.use(csrfMiddleware);
 
 const contactLimiter = rateLimit({
diff --git a/views/admin/profile.ejs b/views/admin/profile.ejs
index 7cb97d2..ab9fc6f 100644
--- a/views/admin/profile.ejs
+++ b/views/admin/profile.ejs
@@ -7,8 +7,32 @@
   <p class="lede">Changes go live immediately on your public profile at <a href="/business/<%= biz.slug %>" target="_blank">/business/<%= biz.slug %></a>.</p>
 
   <% if (ok) { %><p class="callout" style="background:#dcfce7;border-left:3px solid #16a34a;color:#166534;padding:12px 16px;margin:16px 0;border-radius:4px">Profile saved.</p><% } %>
+  <% if (typeof photoOk !== 'undefined' && photoOk) { %><p class="callout" style="background:#dcfce7;border-left:3px solid #16a34a;color:#166534;padding:12px 16px;margin:16px 0;border-radius:4px">Photo updated.</p><% } %>
+  <% if (typeof photoErr !== 'undefined' && photoErr) {
+    var msg = 'Upload failed.';
+    if (photoErr === 'too_big') msg = 'File too large — max 2 MB.';
+    else if (photoErr === 'only_jpg_png_webp') msg = 'Only JPG, PNG, or WebP allowed.';
+    else if (photoErr === 'no_file') msg = 'No file selected.';
+  %><p class="callout" style="background:#fee2e2;border-left:3px solid #dc2626;padding:12px 16px;margin:16px 0;border-radius:4px"><%= msg %></p><% } %>
   <% if (error) { %><p class="callout" style="background:#fee2e2;border-left:3px solid #dc2626;padding:12px 16px;margin:16px 0;border-radius:4px"><%= error %></p><% } %>
 
+  <h3 style="margin-top:24px">Cover photo</h3>
+  <p class="muted" style="font-size:13px;margin:0 0 12px">Replaces the gradient banner above your name on the public profile and on directory cards. JPG, PNG, or WebP up to 2 MB.</p>
+  <% if (biz.photo_path) { %>
+    <div style="display:flex;gap:14px;align-items:flex-start;margin:0 0 14px;flex-wrap:wrap">
+      <img src="<%= biz.photo_path %>" alt="<%= biz.business_name %> cover photo" style="width:200px;height:120px;object-fit:cover;border:1px solid var(--border);border-radius:4px">
+      <form method="post" action="/admin/profile/photo/delete" style="margin:0">
+        <input type="hidden" name="_csrf" value="<%= csrfToken %>">
+        <button type="submit" class="btn btn-ghost btn-sm" style="font-size:12px">Remove photo</button>
+      </form>
+    </div>
+  <% } %>
+  <form method="post" action="/admin/profile/photo" enctype="multipart/form-data" style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:0 0 24px">
+    <input type="hidden" name="_csrf" value="<%= csrfToken %>">
+    <input type="file" name="photo" accept="image/jpeg,image/png,image/webp" required>
+    <button type="submit" class="btn btn-primary btn-sm">Upload</button>
+  </form>
+
   <form method="post" action="/admin/profile" style="margin-top:24px">
     <input type="hidden" name="_csrf" value="<%= csrfToken %>">
 
diff --git a/views/public/business.ejs b/views/public/business.ejs
index bd71ebb..6b5d172 100644
--- a/views/public/business.ejs
+++ b/views/public/business.ejs
@@ -42,9 +42,15 @@
 %>
 <script type="application/ld+json"><%- JSON.stringify(_ldBusiness) %></script>
 
-<div class="vertical-hero vertical-hero-<%= business.vertical %>" aria-hidden="true">
-  <div class="vertical-hero-glyph"><%= verticalMeta ? verticalMeta.icon : '•' %></div>
-</div>
+<% if (business.photo_path) { %>
+  <div class="vertical-hero vertical-hero-photo">
+    <img src="<%= business.photo_path %>" alt="<%= business.business_name %>" loading="lazy" decoding="async">
+  </div>
+<% } else { %>
+  <div class="vertical-hero vertical-hero-<%= business.vertical %>" aria-hidden="true">
+    <div class="vertical-hero-glyph"><%= verticalMeta ? verticalMeta.icon : '•' %></div>
+  </div>
+<% } %>
 
 <section class="profile-hero">
   <p class="kicker"><%= verticalMeta ? verticalMeta.icon + '  ' + verticalMeta.label : business.vertical %></p>
diff --git a/views/public/find.ejs b/views/public/find.ejs
index d59c3a5..9ee53ef 100644
--- a/views/public/find.ejs
+++ b/views/public/find.ejs
@@ -86,9 +86,15 @@
            var vMeta = verticals.find(function(vv){return vv.key===b.vertical});
       %>
         <a class="business-card" href="/business/<%= b.slug %>">
-          <div class="biz-card-hero vertical-hero-<%= b.vertical %>" aria-hidden="true">
-            <span class="biz-card-glyph"><%= vMeta ? vMeta.icon : '•' %></span>
-          </div>
+          <% if (b.photo_path) { %>
+            <div class="biz-card-hero biz-card-hero-photo">
+              <img src="<%= b.photo_path %>" alt="<%= b.business_name %>" loading="lazy" decoding="async">
+            </div>
+          <% } else { %>
+            <div class="biz-card-hero vertical-hero-<%= b.vertical %>" aria-hidden="true">
+              <span class="biz-card-glyph"><%= vMeta ? vMeta.icon : '•' %></span>
+            </div>
+          <% } %>
           <p class="biz-vertical"><%= vMeta ? vMeta.label : b.vertical %></p>
           <p class="biz-name"><%= b.business_name %></p>
           <p class="biz-loc"><%= [b.neighborhood, b.city, b.state].filter(Boolean).join(' · ') %></p>

← 8634cfd yolo tick 17: per-vertical SEO landing pages + home FAQ sect  ·  back to Ventura Claw Leads  ·  yolo tick 19: sharp resize + WebP convert + EXIF strip on ph 863a780 →