← back to Bubbesblock
Add Opportunity Alerts surface: 12 similar-but-not-exact home-service leads (from iPhone Nextdoor screenshots, agent-extracted), Tarzana-localized, wholivedthere enrichment hook, DB-backed
e1c77a4fe8a1258c2668c9056e4185dea9c51480 · 2026-06-19 15:34:31 -0700 · Steve
Files touched
A data/opportunities.jsonM db/schema.sqlA import/nextdoor-extracted.jsonA import/nextdoor-screens/nd-01.jpegA import/nextdoor-screens/nd-02.jpegA import/nextdoor-screens/nd-03.jpegA import/nextdoor-screens/nd-04.jpegA import/nextdoor-screens/nd-05.jpegA import/nextdoor-screens/nd-06.jpegA import/nextdoor-screens/nd-07.jpegA import/nextdoor-screens/nd-08.jpegA import/nextdoor-screens/nd-09.jpegA import/nextdoor-screens/nd-10.jpegA import/nextdoor-screens/nd-11.jpegA import/nextdoor-screens/nd-12.jpegA import/nextdoor-screens/nd-13.jpegA import/nextdoor-screens/nd-14.jpegA import/nextdoor-screens/nd-15.jpegA import/nextdoor-screens/nd-16.jpegM public/chrome.jsA public/opportunities.htmlM public/styles.cssM scripts/migrate.jsM server.js
Diff
commit e1c77a4fe8a1258c2668c9056e4185dea9c51480
Author: Steve <steve@designerwallcoverings.com>
Date: Fri Jun 19 15:34:31 2026 -0700
Add Opportunity Alerts surface: 12 similar-but-not-exact home-service leads (from iPhone Nextdoor screenshots, agent-extracted), Tarzana-localized, wholivedthere enrichment hook, DB-backed
---
data/opportunities.json | 18 +++++++++
db/schema.sql | 19 ++++++++++
import/nextdoor-extracted.json | 23 ++++++++++++
import/nextdoor-screens/nd-01.jpeg | Bin 0 -> 28001 bytes
import/nextdoor-screens/nd-02.jpeg | Bin 0 -> 25678 bytes
import/nextdoor-screens/nd-03.jpeg | Bin 0 -> 33649 bytes
import/nextdoor-screens/nd-04.jpeg | Bin 0 -> 36010 bytes
import/nextdoor-screens/nd-05.jpeg | Bin 0 -> 23174 bytes
import/nextdoor-screens/nd-06.jpeg | Bin 0 -> 51344 bytes
import/nextdoor-screens/nd-07.jpeg | Bin 0 -> 42649 bytes
import/nextdoor-screens/nd-08.jpeg | Bin 0 -> 94523 bytes
import/nextdoor-screens/nd-09.jpeg | Bin 0 -> 40654 bytes
import/nextdoor-screens/nd-10.jpeg | Bin 0 -> 57139 bytes
import/nextdoor-screens/nd-11.jpeg | Bin 0 -> 48615 bytes
import/nextdoor-screens/nd-12.jpeg | Bin 0 -> 29008 bytes
import/nextdoor-screens/nd-13.jpeg | Bin 0 -> 29035 bytes
import/nextdoor-screens/nd-14.jpeg | Bin 0 -> 40139 bytes
import/nextdoor-screens/nd-15.jpeg | Bin 0 -> 76952 bytes
import/nextdoor-screens/nd-16.jpeg | Bin 0 -> 71649 bytes
public/chrome.js | 12 ++++--
public/opportunities.html | 75 +++++++++++++++++++++++++++++++++++++
public/styles.css | 28 ++++++++++++++
scripts/migrate.js | 28 +++++++++++++-
server.js | 30 +++++++++++++++
24 files changed, 228 insertions(+), 5 deletions(-)
diff --git a/data/opportunities.json b/data/opportunities.json
new file mode 100644
index 0000000..8297556
--- /dev/null
+++ b/data/opportunities.json
@@ -0,0 +1,18 @@
+{
+ "service_area": { "center": "18406 Bessemer Street, Tarzana CA 91406", "radius_miles": 20, "business": "BubbesBlock — Designer Wallcoverings", "monthly": 109 },
+ "categories_for_you": ["Wallcoverings", "Interior decoration", "Drapery", "Window treatments"],
+ "opportunities": [
+ { "id": "opp-wallpaper-dining", "neighbor": "Marissa K.", "initials": "MK", "color": "a1", "category": "Wallcoverings", "for_you": true, "area": "Yolanda Ave, Tarzana", "address": "18722 Yolanda Ave, Tarzana CA 91356", "time": "12m", "responses": 1, "locked": false, "body": "Anyone know a great wallpaper hanger? Doing a grasscloth accent wall in the dining room and want it done right." },
+ { "id": "opp-decor-ranch", "neighbor": "Dahlia R.", "initials": "DR", "color": "a3", "category": "Interior decoration", "for_you": true, "area": "Bessemer St, Tarzana", "address": "18450 Bessemer St, Tarzana CA 91406", "time": "40m", "responses": 0, "locked": false, "body": "Just moved into a 1960s ranch and want a decorator to help modernize it without gutting the place. Ideas welcome." },
+ { "id": "opp-drapery-custom", "neighbor": "Esther L.", "initials": "EL", "color": "a5", "category": "Drapery", "for_you": true, "area": "Lindley Ave, Tarzana", "address": "5210 Lindley Ave, Tarzana CA 91356", "time": "1h", "responses": 2, "locked": false, "body": "Need a custom drapery workroom that sews to exact measurements — I have specific lining and pleat preferences for the living room." },
+ { "id": "opp-nursery-wallpaper", "neighbor": "Priya N.", "initials": "PN", "color": "a2", "category": "Wallcoverings", "for_you": true, "area": "Etiwanda Ave, Tarzana", "address": "19010 Etiwanda Ave, Tarzana CA 91356", "time": "2h", "responses": 0, "locked": false, "body": "Looking for decorator or wallpaper ideas for a baby's nursery — something whimsical but not too babyish." },
+ { "id": "opp-window-treatments", "neighbor": "Sol B.", "initials": "SB", "color": "a4", "category": "Window treatments", "for_you": true, "area": "Wells Dr, Tarzana", "address": "18633 Wells Dr, Tarzana CA 91356", "time": "3h", "responses": 1, "locked": false, "body": "Need affordable help with window treatments and hanging curtains throughout a 3-bedroom. Recommendations?" },
+ { "id": "opp-boutique-grasscloth", "neighbor": "Yael T.", "initials": "YT", "color": "a1", "category": "Wallcoverings", "for_you": true, "area": "Ventura Blvd, Tarzana", "address": "18700 Ventura Blvd, Tarzana CA 91356", "time": "5h", "responses": 0, "locked": true, "body": "Small boutique on Ventura wants a textured wallcovering — grasscloth or similar. Who's done a retail install?" },
+ { "id": "opp-party-decor", "neighbor": "Rivka G.", "initials": "RG", "color": "a3", "category": "Party decor", "for_you": false, "area": "Hart St, Tarzana", "address": "18211 Hart St, Tarzana CA 91335", "time": "6h", "responses": 4, "locked": true, "body": "Looking for a decorator or team to help style a backyard bat mitzvah next month." },
+ { "id": "opp-closet-buildout", "neighbor": "Mara H.", "initials": "MH", "color": "a2", "category": "Closet", "for_you": false, "area": "Crebs Ave, Tarzana", "address": "18840 Crebs Ave, Tarzana CA 91356", "time": "8h", "responses": 3, "locked": true, "body": "Recommendations for custom closet build-outs — shelves, drawers, hanging space? Want real experiences before committing." },
+ { "id": "opp-kitchen-refinish", "neighbor": "Doug K.", "initials": "DK", "color": "a4", "category": "Kitchen", "for_you": false, "area": "Clark St, Tarzana", "address": "18509 Clark St, Tarzana CA 91356", "time": "11h", "responses": 2, "locked": true, "body": "Who's the best kitchen cabinet refinisher around here? Ours are oak and very dated." },
+ { "id": "opp-adu-design", "neighbor": "Nastasia N.", "initials": "NN", "color": "a5", "category": "ADU / Architect", "for_you": false, "area": "Vanalden Ave, Tarzana", "address": "18320 Vanalden Ave, Tarzana CA 91356", "time": "14h", "responses": 5, "locked": true, "body": "Seeking a designer or architect for a home expansion / ADU — ideally someone who's done it here in the Valley." },
+ { "id": "opp-interior-painter", "neighbor": "Jill P.", "initials": "JP", "color": "a1", "category": "Painting", "for_you": false, "area": "Oso Ave, Tarzana", "address": "18118 Oso Ave, Tarzana CA 91335", "time": "18h", "responses": 6, "locked": true, "body": "Looking for a reliable interior painter — two bedrooms and a long hallway." },
+ { "id": "opp-townhome-decor", "neighbor": "Ivy K.", "initials": "IK", "color": "a3", "category": "Interior decoration", "for_you": true, "area": "Reseda Blvd, Tarzana", "address": "18555 Reseda Blvd, Tarzana CA 91335", "time": "1d", "responses": 1, "locked": true, "body": "New to the neighborhood and looking for an interior decorator happy to share ideas for a modest townhouse on a real budget." }
+ ]
+}
diff --git a/db/schema.sql b/db/schema.sql
index 13994a8..03852bb 100644
--- a/db/schema.sql
+++ b/db/schema.sql
@@ -42,3 +42,22 @@ CREATE TABLE IF NOT EXISTS comments (
CREATE INDEX IF NOT EXISTS idx_posts_position ON posts(position);
CREATE INDEX IF NOT EXISTS idx_comments_post ON comments(post_id, position);
+
+-- Opportunity leads (Nextdoor "Opp Alerts" model — similar but not exact).
+CREATE TABLE IF NOT EXISTS opportunities (
+ id text PRIMARY KEY,
+ position int,
+ neighbor text,
+ initials text,
+ color text,
+ category text,
+ for_you boolean DEFAULT false,
+ area text,
+ address text,
+ time_label text,
+ responses int DEFAULT 0,
+ locked boolean DEFAULT false,
+ body text,
+ created_at timestamptz DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS idx_opps_position ON opportunities(position);
diff --git a/import/nextdoor-extracted.json b/import/nextdoor-extracted.json
new file mode 100644
index 0000000..0a02131
--- /dev/null
+++ b/import/nextdoor-extracted.json
@@ -0,0 +1,23 @@
+{
+ "_note": "Structured extraction of 16 Nextdoor-for-Business screenshots (synced from iPhone after 3:04pm 2026-06-19), read by 4 parallel agents. Source of truth for the 'similar but not exact' BubbesBlock model.",
+ "business": "Designer Wallcoverings",
+ "service_area_address": "15442 Ventura Boulevard, Sherman Oaks",
+ "home_address": "18406 Bessemer Street, Tarzana CA 91406",
+ "recommended_category": "Interior decoration",
+ "opp_alerts_price_month": 109.0,
+ "ads_price_month": 150.0,
+ "surfaces": ["ads-flow", "chats", "dashboard", "opportunity-alerts", "service-area-map", "service-category-optin", "neighbor-feed", "my-activity"],
+ "real_opportunity_leads": [
+ "Looking for a home interior decorator starting out and willing to provide ideas for a small townhome at a reasonable price.",
+ "Searching for a drapery shop that makes custom fabric draperies. Interested in ordering by size, with specific lining and pleating needs.",
+ "Looking for a great decor person or team to help with party decorations.",
+ "Looking for recommendations for custom wardrobe or closet installations. Interested in hearing about experiences.",
+ "Looking for recommendations for excellent kitchen cabinet refinishers.",
+ "Looking for recommendations for upgrading a closet with shelves, drawers, and hanging space.",
+ "Looking for help with window treatments and curtain hanging. Seeking affordable recommendations.",
+ "Seeking recommendations for a designer or architect to help with home expansion or ADU design. Looking for expert assistance for home upgrades."
+ ],
+ "service_categories_offered": ["Interior decoration", "Carpentry", "Flooring", "General contractor", "Handyman", "Home cleaners", "Painting", "Window services"],
+ "neighbor_feed_themes": ["local development/zoning alerts", "Ventura Blvd traffic/Trader Joe's", "Jerry's Deli closing after 50 years", "overnight police/helicopter activity", "lost & found", "dog attack report", "garage break-in"],
+ "my_activity_note": "Steve's own most-popular neighbor posts are about DUCKS — 'Mr and Mrs Quacksalot have returned', 'What the duck?', 'Now ducks!' (100-160+ reactions each)."
+}
diff --git a/import/nextdoor-screens/nd-01.jpeg b/import/nextdoor-screens/nd-01.jpeg
new file mode 100644
index 0000000..3099345
Binary files /dev/null and b/import/nextdoor-screens/nd-01.jpeg differ
diff --git a/import/nextdoor-screens/nd-02.jpeg b/import/nextdoor-screens/nd-02.jpeg
new file mode 100644
index 0000000..08bcdc7
Binary files /dev/null and b/import/nextdoor-screens/nd-02.jpeg differ
diff --git a/import/nextdoor-screens/nd-03.jpeg b/import/nextdoor-screens/nd-03.jpeg
new file mode 100644
index 0000000..9d6d115
Binary files /dev/null and b/import/nextdoor-screens/nd-03.jpeg differ
diff --git a/import/nextdoor-screens/nd-04.jpeg b/import/nextdoor-screens/nd-04.jpeg
new file mode 100644
index 0000000..c27e811
Binary files /dev/null and b/import/nextdoor-screens/nd-04.jpeg differ
diff --git a/import/nextdoor-screens/nd-05.jpeg b/import/nextdoor-screens/nd-05.jpeg
new file mode 100644
index 0000000..a11c244
Binary files /dev/null and b/import/nextdoor-screens/nd-05.jpeg differ
diff --git a/import/nextdoor-screens/nd-06.jpeg b/import/nextdoor-screens/nd-06.jpeg
new file mode 100644
index 0000000..54b791d
Binary files /dev/null and b/import/nextdoor-screens/nd-06.jpeg differ
diff --git a/import/nextdoor-screens/nd-07.jpeg b/import/nextdoor-screens/nd-07.jpeg
new file mode 100644
index 0000000..fb1b140
Binary files /dev/null and b/import/nextdoor-screens/nd-07.jpeg differ
diff --git a/import/nextdoor-screens/nd-08.jpeg b/import/nextdoor-screens/nd-08.jpeg
new file mode 100644
index 0000000..15bbd1a
Binary files /dev/null and b/import/nextdoor-screens/nd-08.jpeg differ
diff --git a/import/nextdoor-screens/nd-09.jpeg b/import/nextdoor-screens/nd-09.jpeg
new file mode 100644
index 0000000..034b9fa
Binary files /dev/null and b/import/nextdoor-screens/nd-09.jpeg differ
diff --git a/import/nextdoor-screens/nd-10.jpeg b/import/nextdoor-screens/nd-10.jpeg
new file mode 100644
index 0000000..815ef5f
Binary files /dev/null and b/import/nextdoor-screens/nd-10.jpeg differ
diff --git a/import/nextdoor-screens/nd-11.jpeg b/import/nextdoor-screens/nd-11.jpeg
new file mode 100644
index 0000000..8a4970d
Binary files /dev/null and b/import/nextdoor-screens/nd-11.jpeg differ
diff --git a/import/nextdoor-screens/nd-12.jpeg b/import/nextdoor-screens/nd-12.jpeg
new file mode 100644
index 0000000..91959c7
Binary files /dev/null and b/import/nextdoor-screens/nd-12.jpeg differ
diff --git a/import/nextdoor-screens/nd-13.jpeg b/import/nextdoor-screens/nd-13.jpeg
new file mode 100644
index 0000000..9c24f34
Binary files /dev/null and b/import/nextdoor-screens/nd-13.jpeg differ
diff --git a/import/nextdoor-screens/nd-14.jpeg b/import/nextdoor-screens/nd-14.jpeg
new file mode 100644
index 0000000..16e5fad
Binary files /dev/null and b/import/nextdoor-screens/nd-14.jpeg differ
diff --git a/import/nextdoor-screens/nd-15.jpeg b/import/nextdoor-screens/nd-15.jpeg
new file mode 100644
index 0000000..5a66de1
Binary files /dev/null and b/import/nextdoor-screens/nd-15.jpeg differ
diff --git a/import/nextdoor-screens/nd-16.jpeg b/import/nextdoor-screens/nd-16.jpeg
new file mode 100644
index 0000000..3301730
Binary files /dev/null and b/import/nextdoor-screens/nd-16.jpeg differ
diff --git a/public/chrome.js b/public/chrome.js
index 2287cec..fb87111 100644
--- a/public/chrome.js
+++ b/public/chrome.js
@@ -7,8 +7,11 @@ const ICON = {
cal:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2v4M16 2v4M3 9h18M5 5h14v16H5z"/></svg>',
history:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l9 6-9 6-9-6 9-6z"/><path d="M3 13l9 6 9-6"/></svg>',
candle:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2c1 1.5 1 3-0 4-1-1-1-2.5 0-4z"/><path d="M9 8h6v12H9z"/><path d="M7 20h10"/></svg>',
- book:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 5h14v14l-7-3-7 3z"/></svg>'
+ book:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 5h14v14l-7-3-7 3z"/></svg>',
+ opps:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7h18v13H3z"/><path d="M8 7V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M3 12h18"/></svg>'
};
+// page-aware active state for nav
+const PATH = (typeof location !== 'undefined') ? location.pathname : '/';
function topbar(me){
return `<header class="topbar">
@@ -18,8 +21,8 @@ function topbar(me){
<input placeholder="Search your block — neighbors, favors, history…" />
</div>
<nav class="topnav">
- <button class="navbtn active" title="Block Feed" onclick="location.href='/'">${ICON.house}</button>
- <button class="navbtn" title="Cup of Sugar">${ICON.sugar}</button>
+ <button class="navbtn ${PATH==='/'?'active':''}" title="Block Feed" onclick="location.href='/'">${ICON.house}</button>
+ <button class="navbtn ${PATH.indexOf('/opportunities')===0?'active':''}" title="Opp Alerts" onclick="location.href='/opportunities'"><span class="badge">12</span>${ICON.opps}</button>
<button class="navbtn" title="Notifications"><span class="badge">5</span>${ICON.bell}</button>
<button class="navbtn" title="Inbox"><span class="badge">2</span>${ICON.inbox}</button>
<button class="avatar-btn"><span class="ava ${me.color}" style="width:100%;height:100%;border-radius:0;font-size:15px">${me.initials}</span></button>
@@ -34,7 +37,8 @@ function leftRail(me){
<div><b>${me.name}</b><span>${me.address}${me.verified?' · verified':''}</span></div>
</div>
<div class="menu">
- <a class="active" href="/">${ICON.house}Block Feed</a>
+ <a class="${PATH==='/'?'active':''}" href="/">${ICON.house}Block Feed</a>
+ <a class="${PATH.indexOf('/opportunities')===0?'active':''}" href="/opportunities">${ICON.opps}Opp Alerts<span class="pill">12</span></a>
<a href="#">${ICON.bell}Notifications<span class="pill">5</span></a>
<a href="#">${ICON.sugar}Cup of Sugar</a>
<a href="#">${ICON.cal}Block Events</a>
diff --git a/public/opportunities.html b/public/opportunities.html
new file mode 100644
index 0000000..d549336
--- /dev/null
+++ b/public/opportunities.html
@@ -0,0 +1,75 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8" />
+<meta name="viewport" content="width=device-width, initial-scale=1.0" />
+<title>BubbesBlock — Opportunity Alerts</title>
+<meta name="description" content="Opportunity Alerts — real-time leads when neighbors near you need wallcoverings, decor, drapery & window treatments." />
+<link rel="stylesheet" href="/styles.css" />
+</head>
+<body>
+<div id="hdr"></div>
+<div class="wrap">
+ <div id="left"></div>
+ <main class="col-center" id="main"></main>
+ <div id="right"></div>
+</div>
+
+<script src="/chrome.js"></script>
+<script>
+const lockSvg = '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="11" width="16" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg>';
+const pinSvg = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 21s-7-5.2-7-11a7 7 0 0 1 14 0c0 5.8-7 11-7 11z"/><circle cx="12" cy="10" r="2.5"/></svg>';
+
+function oppCard(o){
+ const cta = o.locked
+ ? `<button class="lock">${lockSvg} Unlock</button>`
+ : `<button class="go">Respond</button>`;
+ // wholivedthere enrichment hook — links the lead's address into the home-history flow
+ const wlt = o.address
+ ? `<a class="wlt" href="https://wholivedthere.com/?address=${encodeURIComponent(o.address)}" target="_blank">🏚️ See who lived at ${o.area} →</a>`
+ : '';
+ return `<div class="opp ${o.forYou?'you':''}">
+ <span class="ava ${o.color}">${o.initials}</span>
+ <div class="main">
+ <div class="toprow">
+ <span class="who2">${o.neighbor}</span>
+ <span class="cat2">${o.category}</span>
+ ${o.forYou?'<span class="youtag">★ For you</span>':''}
+ </div>
+ <div class="body2">${o.body}</div>
+ ${wlt}
+ <div class="foot">
+ <span class="loc">${pinSvg} ${o.area}</span>
+ <span>· ${o.time}</span>
+ <span>· ${o.responses} responded</span>
+ <span class="resp">${cta}</span>
+ </div>
+ </div>
+ </div>`;
+}
+
+fetch('/api/opportunities').then(r=>r.json()).then(d=>{
+ document.getElementById('hdr').innerHTML = topbar(d.me);
+ document.getElementById('left').innerHTML = leftRail(d.me);
+ document.getElementById('right').innerHTML = rightRail(d.me);
+
+ const sa = d.serviceArea || {};
+ const chips = (d.categoriesForYou||[]).map(c=>`<span>${c}</span>`).join('');
+ const forYou = d.opportunities.filter(o=>o.forYou).length;
+
+ const hero = `<div class="opp-hero">
+ <h2>Opportunity Alerts</h2>
+ <p>Real-time leads when neighbors near you ask for services like yours.</p>
+ <div class="area">${pinSvg} ${sa.radius_miles||20}-mile radius · ${sa.center||''}</div>
+ <div class="opp-chips">${chips}</div>
+ </div>`;
+
+ const count = `<div class="opp-count">${d.opportunities.length} open opportunities · ${forYou} matched to your services</div>`;
+ const cards = d.opportunities.map(oppCard).join('');
+ document.getElementById('main').innerHTML = hero + count + cards;
+}).catch(()=>{
+ document.getElementById('main').innerHTML = '<div class="card"><div class="post">Could not load opportunities.</div></div>';
+});
+</script>
+</body>
+</html>
diff --git a/public/styles.css b/public/styles.css
index 247cb1f..90cb725 100644
--- a/public/styles.css
+++ b/public/styles.css
@@ -148,3 +148,31 @@ nav.topnav{margin-left:auto;display:flex;align-items:center;gap:6px}
@media(max-width:980px){.wrap{grid-template-columns:minmax(0,1fr) 300px}.col-left{display:none}}
@media(max-width:760px){.wrap{grid-template-columns:1fr}.col-right{display:none}.search{display:none}.photo .ph{height:240px}}
+
+/* ---------- Opportunity Alerts (Opp Alerts) ---------- */
+.opp-hero{background:linear-gradient(150deg,var(--green),var(--green-dark));color:#fff;border-radius:var(--radius);padding:18px 20px;margin-bottom:16px;box-shadow:var(--shadow)}
+.opp-hero h2{margin:0 0 4px;font-size:20px}
+.opp-hero p{margin:0;font-size:13.5px;color:rgba(255,255,255,.9)}
+.opp-hero .area{display:inline-flex;align-items:center;gap:7px;margin-top:11px;background:rgba(255,255,255,.16);padding:6px 12px;border-radius:20px;font-size:13px;font-weight:600}
+.opp-chips{display:flex;flex-wrap:wrap;gap:7px;margin-top:11px}
+.opp-chips span{background:rgba(255,255,255,.18);border:1px solid rgba(255,255,255,.25);padding:4px 10px;border-radius:16px;font-size:12px;font-weight:600}
+.opp-count{font-size:13px;color:var(--muted);font-weight:700;margin:2px 2px 12px}
+.opp{display:flex;gap:13px;padding:15px 16px;background:var(--card);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);margin-bottom:12px;position:relative}
+.opp.you{border-color:var(--green);box-shadow:0 0 0 1px var(--green-tint),var(--shadow)}
+.opp .ava{width:44px;height:44px;font-size:16px}
+.opp .main{flex:1;min-width:0}
+.opp .toprow{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
+.opp .cat2{background:var(--green-tint);color:var(--green-dark);font-weight:700;font-size:11.5px;padding:3px 9px;border-radius:14px}
+.opp.you .youtag{background:var(--plum);color:#fff;font-weight:700;font-size:11px;padding:3px 9px;border-radius:14px}
+.opp .who2{font-weight:700;font-size:14px}
+.opp .body2{font-size:14.5px;line-height:1.5;margin:7px 0 9px;color:#222}
+.opp .foot{display:flex;align-items:center;gap:14px;font-size:12.5px;color:var(--muted);flex-wrap:wrap}
+.opp .foot .loc{display:inline-flex;align-items:center;gap:4px}
+.opp .resp{margin-left:auto;display:flex;gap:8px}
+.opp .resp button{font-weight:700;font-size:13px;padding:8px 16px;border-radius:20px}
+.opp .resp .go{background:var(--green);color:#fff}
+.opp .resp .go:hover{background:var(--green-dark)}
+.opp .resp .lock{background:#eef0f2;color:var(--muted);display:inline-flex;align-items:center;gap:6px}
+.opp .resp .lock:hover{background:#e7e9ec}
+.opp .wlt{display:block;margin-top:8px;font-size:12.5px;font-weight:600;color:var(--green-dark)}
+.opp .wlt:hover{text-decoration:underline}
diff --git a/scripts/migrate.js b/scripts/migrate.js
index e602bec..3c8734f 100644
--- a/scripts/migrate.js
+++ b/scripts/migrate.js
@@ -50,8 +50,34 @@ const seed = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'data', 'post
}
}
+ // ---- opportunity leads (optional file) ----
+ const oppPath = path.join(__dirname, '..', 'data', 'opportunities.json');
+ if (fs.existsSync(oppPath)) {
+ const od = JSON.parse(fs.readFileSync(oppPath, 'utf8'));
+ await c.query(
+ `INSERT INTO site_config(k,v) VALUES('service_area',$1),('categories_for_you',$2)
+ ON CONFLICT(k) DO UPDATE SET v=EXCLUDED.v`,
+ [JSON.stringify(od.service_area || {}), JSON.stringify(od.categories_for_you || [])]
+ );
+ for (let i = 0; i < (od.opportunities || []).length; i++) {
+ const o = od.opportunities[i];
+ await c.query(
+ `INSERT INTO opportunities(id,position,neighbor,initials,color,category,for_you,area,address,time_label,responses,locked,body)
+ VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
+ ON CONFLICT(id) DO UPDATE SET
+ position=EXCLUDED.position, neighbor=EXCLUDED.neighbor, initials=EXCLUDED.initials,
+ color=EXCLUDED.color, category=EXCLUDED.category, for_you=EXCLUDED.for_you,
+ area=EXCLUDED.area, address=EXCLUDED.address, time_label=EXCLUDED.time_label,
+ responses=EXCLUDED.responses, locked=EXCLUDED.locked, body=EXCLUDED.body`,
+ [o.id, i, o.neighbor, o.initials, o.color, o.category, !!o.for_you, o.area, o.address,
+ o.time, o.responses || 0, !!o.locked, o.body]
+ );
+ }
+ }
+
const np = await c.query('SELECT count(*) FROM posts');
const nc = await c.query('SELECT count(*) FROM comments');
- console.log(`migrated: ${np.rows[0].count} posts, ${nc.rows[0].count} comments`);
+ const no = await c.query('SELECT count(*) FROM opportunities');
+ console.log(`migrated: ${np.rows[0].count} posts, ${nc.rows[0].count} comments, ${no.rows[0].count} opportunities`);
await c.end();
})().catch(e => { console.error(e); process.exit(1); });
diff --git a/server.js b/server.js
index 0020bb3..45af1ba 100644
--- a/server.js
+++ b/server.js
@@ -102,6 +102,36 @@ app.get('/api/posts/:id', async (req, res) => {
} catch (e) { res.status(500).json({ error: String(e) }); }
});
+// ---- Opportunity leads (Nextdoor "Opp Alerts" model) ----
+app.get('/api/opportunities', async (_req, res) => {
+ if (pool) {
+ let client;
+ try {
+ client = await pool.connect();
+ const cfg = await client.query("SELECT k,v FROM site_config WHERE k IN ('me','service_area','categories_for_you')");
+ const c = {}; cfg.rows.forEach(x => (c[x.k] = x.v));
+ const r = await client.query('SELECT * FROM opportunities ORDER BY position');
+ const opportunities = r.rows.map(o => ({
+ id: o.id, neighbor: o.neighbor, initials: o.initials, color: o.color, category: o.category,
+ forYou: o.for_you, area: o.area, address: o.address, time: o.time_label,
+ responses: o.responses, locked: o.locked, body: o.body
+ }));
+ return res.json({ me: c.me, serviceArea: c.service_area, categoriesForYou: c.categories_for_you || [], opportunities });
+ } catch (e) {
+ console.error('PG opportunities failed, JSON fallback:', e.message);
+ } finally { if (client) client.release(); }
+ }
+ try {
+ const d = jsonAll();
+ const od = JSON.parse(fs.readFileSync(path.join(__dirname, 'data', 'opportunities.json'), 'utf8'));
+ res.json({
+ me: d.me, serviceArea: od.service_area, categoriesForYou: od.categories_for_you || [],
+ opportunities: (od.opportunities || []).map(o => ({ ...o, forYou: o.for_you }))
+ });
+ } catch (e) { res.status(500).json({ error: String(e) }); }
+});
+
app.get('/p/:id', (_req, res) => res.sendFile(path.join(__dirname, 'public', 'post.html')));
+app.get('/opportunities', (_req, res) => res.sendFile(path.join(__dirname, 'public', 'opportunities.html')));
app.listen(PORT, () => console.log(`BubbesBlock on http://localhost:${PORT} (db=${!!pool})`));
← 0110f82 Add BubbesBlock Postgres DB (posts+comments+config), migrati
·
back to Bubbesblock
·
Rebrand: original BubbesBlock logo (row-of-homes + heart) + 552f29c →