← 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
A db/migrations/008_business_photo_path.sqlA lib/photo-upload.jsM package-lock.jsonM package.jsonM public/css/public.cssA public/img/founder-steve.pngA public/uploads/business-photos/.gitignoreA public/uploads/business-photos/.gitkeepM routes/admin.jsM routes/public.jsM server.jsM views/admin/profile.ejsM views/public/business.ejsM views/public/find.ejs
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 →