[object Object]

← back to Bubbesblock

Build out every button with real UX: sessions + claim gate, write endpoints (posts/comments/reactions/respond/unlock/rsvp/join/bookmark), search, notifications, inbox, events, groups, content pages; live home-history wired; interaction layer (app.js) + modals/toasts

7cbd55e9042382d3001dc3403161e2ae2e10d583 · 2026-06-19 16:19:19 -0700 · Steve

Files touched

Diff

commit 7cbd55e9042382d3001dc3403161e2ae2e10d583
Author: Steve <steve@designerwallcoverings.com>
Date:   Fri Jun 19 16:19:19 2026 -0700

    Build out every button with real UX: sessions + claim gate, write endpoints (posts/comments/reactions/respond/unlock/rsvp/join/bookmark), search, notifications, inbox, events, groups, content pages; live home-history wired; interaction layer (app.js) + modals/toasts
---
 .gitignore                         |   1 +
 data/community.json                |  15 ++
 db/schema.sql                      |  94 ++++++++++
 import/nextdoor-extracted.json     |  23 ---
 import/nextdoor-screens/nd-01.jpeg | Bin 28001 -> 0 bytes
 import/nextdoor-screens/nd-02.jpeg | Bin 25678 -> 0 bytes
 import/nextdoor-screens/nd-03.jpeg | Bin 33649 -> 0 bytes
 import/nextdoor-screens/nd-04.jpeg | Bin 36010 -> 0 bytes
 import/nextdoor-screens/nd-05.jpeg | Bin 23174 -> 0 bytes
 import/nextdoor-screens/nd-06.jpeg | Bin 51344 -> 0 bytes
 import/nextdoor-screens/nd-07.jpeg | Bin 42649 -> 0 bytes
 import/nextdoor-screens/nd-08.jpeg | Bin 94523 -> 0 bytes
 import/nextdoor-screens/nd-09.jpeg | Bin 40654 -> 0 bytes
 import/nextdoor-screens/nd-10.jpeg | Bin 57139 -> 0 bytes
 import/nextdoor-screens/nd-11.jpeg | Bin 48615 -> 0 bytes
 import/nextdoor-screens/nd-12.jpeg | Bin 29008 -> 0 bytes
 import/nextdoor-screens/nd-13.jpeg | Bin 29035 -> 0 bytes
 import/nextdoor-screens/nd-14.jpeg | Bin 40139 -> 0 bytes
 import/nextdoor-screens/nd-15.jpeg | Bin 76952 -> 0 bytes
 import/nextdoor-screens/nd-16.jpeg | Bin 71649 -> 0 bytes
 package-lock.json                  | 182 +++++++++++++++++-
 package.json                       |   1 +
 public/app.js                      | 256 +++++++++++++++++++++++++
 public/bookmarks.html              |  20 ++
 public/chrome.js                   |  34 ++--
 public/content.html                |  26 +++
 public/events.html                 |  34 ++++
 public/groups.html                 |  34 ++++
 public/inbox.html                  |  41 ++++
 public/index.html                  |  20 +-
 public/notifications.html          |  23 +++
 public/opportunities.html          |  73 +++++---
 public/post.html                   |  43 +++--
 public/search.html                 |  29 +++
 public/styles.css                  |  61 ++++++
 scripts/migrate.js                 |  54 +++++-
 server.js                          | 370 +++++++++++++++++++++++++++++--------
 37 files changed, 1268 insertions(+), 166 deletions(-)

diff --git a/.gitignore b/.gitignore
index 26ff4df..ed0646d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,3 +7,4 @@ dist/
 build/
 .next/
 .deploy.conf
+import/
diff --git a/data/community.json b/data/community.json
new file mode 100644
index 0000000..7a782de
--- /dev/null
+++ b/data/community.json
@@ -0,0 +1,15 @@
+{
+  "groups": [
+    { "slug": "babka-bagel-swap", "name": "Babka & Bagel Swap", "emoji": "🥧", "color": "#3a7d5d", "members": 48, "blurb": "Bake too much? Swap it. Standing Saturday-morning trade on Ruth's stoop." },
+    { "slug": "tools-ladders", "name": "Tools & Ladders to Borrow", "emoji": "🔧", "color": "#b8862f", "members": 61, "blurb": "The block's shared shed — borrow a ladder, drill, or folding table before you buy one." },
+    { "slug": "block-dog-walkers", "name": "Block Dog Walkers", "emoji": "🐕", "color": "#5566b5", "members": 27, "blurb": "Morning and evening walking crews, lost-dog alerts, and the good-treat list." },
+    { "slug": "shabbat-dinner-club", "name": "Shabbat Dinner Club", "emoji": "🕯️", "color": "#c2557a", "members": 34, "blurb": "Open table every Friday at sundown. New faces always welcome — just RSVP." },
+    { "slug": "holiday-sukkah-help", "name": "Holiday & Sukkah Help", "emoji": "🌿", "color": "#a05236", "members": 22, "blurb": "Hands, bamboo, and decorations for sukkah season — and every holiday after." }
+  ],
+  "events": [
+    { "id": "ev-shabbat", "title": "Shabbat Dinner at the Abrams'", "emoji": "🕯️", "when_label": "Fri Jun 20 · 6:30 PM", "location": "112 Beekman Ave", "going": 12, "body": "Open table, candles at sundown. Bring wine if you like, bring yourself either way.", "position": 0 },
+    { "id": "ev-sukkah", "title": "Sukkah Build & Decorate", "emoji": "🌿", "when_label": "Sun Jun 22 · 9:00 AM", "location": "119 Beekman Ave (side yard)", "going": 8, "body": "Hour of bamboo-roof raising, then the kids decorate. Tools + lunch provided.", "position": 1 },
+    { "id": "ev-blockparty", "title": "Beekman Ave Block Party", "emoji": "🎉", "when_label": "Sun Jun 22 · 1:00 PM", "location": "Beekman Ave (street closed)", "going": 32, "body": "Potluck, grill, Ruth's dessert table, Mr. Reuben's speaker. Bring a dish + a chair.", "position": 2 },
+    { "id": "ev-babka", "title": "Monthly Babka & Bagel Swap", "emoji": "🥯", "when_label": "Sat Jun 28 · 10:00 AM", "location": "108 Beekman stoop", "going": 19, "body": "Bring a baked thing, leave with a different baked thing. Foil provided.", "position": 3 }
+  ]
+}
diff --git a/db/schema.sql b/db/schema.sql
index 03852bb..7952e26 100644
--- a/db/schema.sql
+++ b/db/schema.sql
@@ -61,3 +61,97 @@ CREATE TABLE IF NOT EXISTS opportunities (
   created_at  timestamptz DEFAULT now()
 );
 CREATE INDEX IF NOT EXISTS idx_opps_position ON opportunities(position);
+
+-- ============ Phase A: identity + interaction tables ============
+CREATE TABLE IF NOT EXISTS users (
+  id         text PRIMARY KEY,
+  name       text NOT NULL,
+  initials   text,
+  color      text,
+  address    text,
+  verified   boolean DEFAULT false,
+  created_at timestamptz DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS reactions (
+  id          bigserial PRIMARY KEY,
+  target_type text NOT NULL,           -- post | comment | opportunity
+  target_id   text NOT NULL,
+  user_id     text NOT NULL,
+  kind        text DEFAULT 'thank',
+  created_at  timestamptz DEFAULT now(),
+  UNIQUE(target_type, target_id, user_id, kind)
+);
+
+CREATE TABLE IF NOT EXISTS opp_responses (
+  id            bigserial PRIMARY KEY,
+  opportunity_id text NOT NULL,
+  user_id       text NOT NULL,
+  body          text,
+  created_at    timestamptz DEFAULT now()
+);
+CREATE TABLE IF NOT EXISTS opp_unlocks (
+  opportunity_id text NOT NULL,
+  user_id        text NOT NULL,
+  created_at     timestamptz DEFAULT now(),
+  PRIMARY KEY (opportunity_id, user_id)
+);
+
+CREATE TABLE IF NOT EXISTS notifications (
+  id         bigserial PRIMARY KEY,
+  user_id    text NOT NULL,
+  type       text,
+  actor      text,
+  text       text,
+  link       text,
+  read       boolean DEFAULT false,
+  created_at timestamptz DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS conversations (
+  id         bigserial PRIMARY KEY,
+  user_id    text NOT NULL,
+  peer       text,
+  peer_initials text,
+  peer_color text,
+  created_at timestamptz DEFAULT now()
+);
+CREATE TABLE IF NOT EXISTS messages (
+  id         bigserial PRIMARY KEY,
+  conv_id    bigint NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
+  from_me    boolean DEFAULT false,
+  body       text,
+  created_at timestamptz DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS events (
+  id         text PRIMARY KEY,
+  title      text,
+  emoji      text,
+  when_label text,
+  location   text,
+  going      int DEFAULT 0,
+  body       text,
+  position   int
+);
+CREATE TABLE IF NOT EXISTS event_rsvps (
+  event_id text NOT NULL, user_id text NOT NULL, created_at timestamptz DEFAULT now(),
+  PRIMARY KEY (event_id, user_id)
+);
+
+CREATE TABLE IF NOT EXISTS groups (
+  slug text PRIMARY KEY, name text, emoji text, color text, members int DEFAULT 0, blurb text, position int
+);
+CREATE TABLE IF NOT EXISTS group_members (
+  slug text NOT NULL, user_id text NOT NULL, created_at timestamptz DEFAULT now(),
+  PRIMARY KEY (slug, user_id)
+);
+
+CREATE TABLE IF NOT EXISTS bookmarks (
+  user_id text NOT NULL, post_id text NOT NULL, created_at timestamptz DEFAULT now(),
+  PRIMARY KEY (user_id, post_id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_react_target ON reactions(target_type, target_id);
+CREATE INDEX IF NOT EXISTS idx_notif_user ON notifications(user_id, read);
+CREATE INDEX IF NOT EXISTS idx_oppresp_opp ON opp_responses(opportunity_id);
diff --git a/import/nextdoor-extracted.json b/import/nextdoor-extracted.json
deleted file mode 100644
index 0a02131..0000000
--- a/import/nextdoor-extracted.json
+++ /dev/null
@@ -1,23 +0,0 @@
-{
-  "_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
deleted file mode 100644
index 3099345..0000000
Binary files a/import/nextdoor-screens/nd-01.jpeg and /dev/null differ
diff --git a/import/nextdoor-screens/nd-02.jpeg b/import/nextdoor-screens/nd-02.jpeg
deleted file mode 100644
index 08bcdc7..0000000
Binary files a/import/nextdoor-screens/nd-02.jpeg and /dev/null differ
diff --git a/import/nextdoor-screens/nd-03.jpeg b/import/nextdoor-screens/nd-03.jpeg
deleted file mode 100644
index 9d6d115..0000000
Binary files a/import/nextdoor-screens/nd-03.jpeg and /dev/null differ
diff --git a/import/nextdoor-screens/nd-04.jpeg b/import/nextdoor-screens/nd-04.jpeg
deleted file mode 100644
index c27e811..0000000
Binary files a/import/nextdoor-screens/nd-04.jpeg and /dev/null differ
diff --git a/import/nextdoor-screens/nd-05.jpeg b/import/nextdoor-screens/nd-05.jpeg
deleted file mode 100644
index a11c244..0000000
Binary files a/import/nextdoor-screens/nd-05.jpeg and /dev/null differ
diff --git a/import/nextdoor-screens/nd-06.jpeg b/import/nextdoor-screens/nd-06.jpeg
deleted file mode 100644
index 54b791d..0000000
Binary files a/import/nextdoor-screens/nd-06.jpeg and /dev/null differ
diff --git a/import/nextdoor-screens/nd-07.jpeg b/import/nextdoor-screens/nd-07.jpeg
deleted file mode 100644
index fb1b140..0000000
Binary files a/import/nextdoor-screens/nd-07.jpeg and /dev/null differ
diff --git a/import/nextdoor-screens/nd-08.jpeg b/import/nextdoor-screens/nd-08.jpeg
deleted file mode 100644
index 15bbd1a..0000000
Binary files a/import/nextdoor-screens/nd-08.jpeg and /dev/null differ
diff --git a/import/nextdoor-screens/nd-09.jpeg b/import/nextdoor-screens/nd-09.jpeg
deleted file mode 100644
index 034b9fa..0000000
Binary files a/import/nextdoor-screens/nd-09.jpeg and /dev/null differ
diff --git a/import/nextdoor-screens/nd-10.jpeg b/import/nextdoor-screens/nd-10.jpeg
deleted file mode 100644
index 815ef5f..0000000
Binary files a/import/nextdoor-screens/nd-10.jpeg and /dev/null differ
diff --git a/import/nextdoor-screens/nd-11.jpeg b/import/nextdoor-screens/nd-11.jpeg
deleted file mode 100644
index 8a4970d..0000000
Binary files a/import/nextdoor-screens/nd-11.jpeg and /dev/null differ
diff --git a/import/nextdoor-screens/nd-12.jpeg b/import/nextdoor-screens/nd-12.jpeg
deleted file mode 100644
index 91959c7..0000000
Binary files a/import/nextdoor-screens/nd-12.jpeg and /dev/null differ
diff --git a/import/nextdoor-screens/nd-13.jpeg b/import/nextdoor-screens/nd-13.jpeg
deleted file mode 100644
index 9c24f34..0000000
Binary files a/import/nextdoor-screens/nd-13.jpeg and /dev/null differ
diff --git a/import/nextdoor-screens/nd-14.jpeg b/import/nextdoor-screens/nd-14.jpeg
deleted file mode 100644
index 16e5fad..0000000
Binary files a/import/nextdoor-screens/nd-14.jpeg and /dev/null differ
diff --git a/import/nextdoor-screens/nd-15.jpeg b/import/nextdoor-screens/nd-15.jpeg
deleted file mode 100644
index 5a66de1..0000000
Binary files a/import/nextdoor-screens/nd-15.jpeg and /dev/null differ
diff --git a/import/nextdoor-screens/nd-16.jpeg b/import/nextdoor-screens/nd-16.jpeg
deleted file mode 100644
index 3301730..0000000
Binary files a/import/nextdoor-screens/nd-16.jpeg and /dev/null differ
diff --git a/package-lock.json b/package-lock.json
index 55d6274..f166220 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,7 +8,10 @@
       "name": "bubbesblock",
       "version": "0.1.0",
       "dependencies": {
-        "express": "^4.19.2"
+        "cookie-parser": "^1.4.6",
+        "dotenv": "^16.4.5",
+        "express": "^4.19.2",
+        "pg": "^8.12.0"
       }
     },
     "node_modules/accepts": {
@@ -122,6 +125,25 @@
         "node": ">= 0.6"
       }
     },
+    "node_modules/cookie-parser": {
+      "version": "1.4.7",
+      "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
+      "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
+      "license": "MIT",
+      "dependencies": {
+        "cookie": "0.7.2",
+        "cookie-signature": "1.0.6"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/cookie-parser/node_modules/cookie-signature": {
+      "version": "1.0.6",
+      "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
+      "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
+      "license": "MIT"
+    },
     "node_modules/cookie-signature": {
       "version": "1.0.7",
       "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
@@ -156,6 +178,18 @@
         "npm": "1.2.8000 || >= 1.4.16"
       }
     },
+    "node_modules/dotenv": {
+      "version": "16.6.1",
+      "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
+      "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://dotenvx.com"
+      }
+    },
     "node_modules/dunder-proto": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -564,6 +598,134 @@
       "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
       "license": "MIT"
     },
+    "node_modules/pg": {
+      "version": "8.22.0",
+      "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
+      "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
+      "license": "MIT",
+      "dependencies": {
+        "pg-connection-string": "^2.14.0",
+        "pg-pool": "^3.14.0",
+        "pg-protocol": "^1.15.0",
+        "pg-types": "2.2.0",
+        "pgpass": "1.0.5"
+      },
+      "engines": {
+        "node": ">= 16.0.0"
+      },
+      "optionalDependencies": {
+        "pg-cloudflare": "^1.4.0"
+      },
+      "peerDependencies": {
+        "pg-native": ">=3.0.1"
+      },
+      "peerDependenciesMeta": {
+        "pg-native": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/pg-cloudflare": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
+      "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
+      "license": "MIT",
+      "optional": true
+    },
+    "node_modules/pg-connection-string": {
+      "version": "2.14.0",
+      "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
+      "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
+      "license": "MIT"
+    },
+    "node_modules/pg-int8": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+      "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=4.0.0"
+      }
+    },
+    "node_modules/pg-pool": {
+      "version": "3.14.0",
+      "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
+      "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
+      "license": "MIT",
+      "peerDependencies": {
+        "pg": ">=8.0"
+      }
+    },
+    "node_modules/pg-protocol": {
+      "version": "1.15.0",
+      "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz",
+      "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==",
+      "license": "MIT"
+    },
+    "node_modules/pg-types": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+      "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+      "license": "MIT",
+      "dependencies": {
+        "pg-int8": "1.0.1",
+        "postgres-array": "~2.0.0",
+        "postgres-bytea": "~1.0.0",
+        "postgres-date": "~1.0.4",
+        "postgres-interval": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/pgpass": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+      "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+      "license": "MIT",
+      "dependencies": {
+        "split2": "^4.1.0"
+      }
+    },
+    "node_modules/postgres-array": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+      "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/postgres-bytea": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+      "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/postgres-date": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+      "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/postgres-interval": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+      "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+      "license": "MIT",
+      "dependencies": {
+        "xtend": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
     "node_modules/proxy-addr": {
       "version": "2.0.7",
       "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -765,6 +927,15 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/split2": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+      "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+      "license": "ISC",
+      "engines": {
+        "node": ">= 10.x"
+      }
+    },
     "node_modules/statuses": {
       "version": "2.0.2",
       "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -822,6 +993,15 @@
       "engines": {
         "node": ">= 0.8"
       }
+    },
+    "node_modules/xtend": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+      "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.4"
+      }
     }
   }
 }
diff --git a/package.json b/package.json
index 06d8ffe..57f787a 100644
--- a/package.json
+++ b/package.json
@@ -8,6 +8,7 @@
     "dev": "node server.js"
   },
   "dependencies": {
+    "cookie-parser": "^1.4.6",
     "dotenv": "^16.4.5",
     "express": "^4.19.2",
     "pg": "^8.12.0"
diff --git a/public/app.js b/public/app.js
new file mode 100644
index 0000000..a38e805
--- /dev/null
+++ b/public/app.js
@@ -0,0 +1,256 @@
+/* BubbesBlock interaction layer — makes every control real. Loaded after chrome.js. */
+(function () {
+  const BB = window.BB = { you: null };
+  const api = {
+    get: (u) => fetch(u, { credentials: 'same-origin' }).then(r => r.json()),
+    post: (u, body) => fetch(u, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) }).then(async r => { const j = await r.json().catch(() => ({})); if (!r.ok) throw Object.assign(new Error(j.error || r.status), { status: r.status, data: j }); return j; })
+  };
+  BB.api = api;
+
+  /* ---------- toast ---------- */
+  function toast(msg) {
+    let t = document.getElementById('bb-toast');
+    if (!t) { t = document.createElement('div'); t.id = 'bb-toast'; document.body.appendChild(t); }
+    t.textContent = msg; t.className = 'show';
+    clearTimeout(t._t); t._t = setTimeout(() => t.className = '', 2600);
+  }
+  BB.toast = toast;
+
+  /* ---------- modal ---------- */
+  function modal(title, inner, opts = {}) {
+    close();
+    const back = document.createElement('div'); back.className = 'bb-modal-back'; back.id = 'bb-modal';
+    back.innerHTML = `<div class="bb-modal" role="dialog" aria-modal="true">
+      <div class="bb-modal-hd"><b>${title}</b><button class="bb-x" aria-label="Close">&times;</button></div>
+      <div class="bb-modal-bd">${inner}</div></div>`;
+    document.body.appendChild(back);
+    back.querySelector('.bb-x').onclick = close;
+    back.onclick = e => { if (e.target === back) close(); };
+    document.addEventListener('keydown', escClose);
+    if (opts.onOpen) opts.onOpen(back);
+    const f = back.querySelector('input,textarea'); if (f) f.focus();
+    return back;
+  }
+  function close() { const m = document.getElementById('bb-modal'); if (m) m.remove(); document.removeEventListener('keydown', escClose); }
+  function escClose(e) { if (e.key === 'Escape') close(); }
+  BB.modal = modal; BB.closeModal = close;
+
+  /* ---------- session / auth gates ---------- */
+  BB.refresh = async () => { const s = await api.get('/api/session'); BB.you = s.you; paintYou(); return BB.you; };
+  function paintYou() {
+    // reflect verified state on any composer placeholders, etc.
+    document.querySelectorAll('[data-when-auth]').forEach(el => { el.style.display = BB.you ? '' : 'none'; });
+    document.querySelectorAll('[data-when-anon]').forEach(el => { el.style.display = BB.you ? 'none' : ''; });
+  }
+
+  BB.ensureAuth = function () {
+    return new Promise(resolve => {
+      if (BB.you) return resolve(BB.you);
+      modal('Sign in to your block', `
+        <p class="bb-sub">Join your neighbors on Beekman Ave. Enter your name to continue.</p>
+        <input id="bb-name" placeholder="Your name (e.g. Steve Abrams)" />
+        <button class="bb-btn" id="bb-login">Continue</button>
+        <p class="bb-fine">Posting also needs a claimed address — one tap after this.</p>`, {
+        onOpen(m) {
+          const go = async () => {
+            const name = m.querySelector('#bb-name').value.trim(); if (!name) return;
+            try { const r = await api.post('/api/session/login', { name }); BB.you = r.you; paintYou(); close(); toast('Welcome, ' + r.you.name.split(' ')[0] + '!'); resolve(r.you); }
+            catch (e) { toast('Sign-in failed'); }
+          };
+          m.querySelector('#bb-login').onclick = go;
+          m.querySelector('#bb-name').addEventListener('keydown', e => { if (e.key === 'Enter') go(); });
+        }
+      });
+    });
+  };
+
+  BB.ensureVerified = async function () {
+    const you = await BB.ensureAuth(); if (!you) return null;
+    if (you.verified) return you;
+    return new Promise(resolve => {
+      modal('Claim your address', `
+        <p class="bb-sub">Verify you live on the block to post & reply. (One-tap demo of the claimmyaddress.com flow.)</p>
+        <input id="bb-addr" placeholder="Your address" value="${you.address || ''}" />
+        <button class="bb-btn" id="bb-claim">Claim &amp; continue</button>
+        <a class="bb-fine" href="https://claimmyaddress.com" target="_blank">Powered by claimmyaddress.com →</a>`, {
+        onOpen(m) {
+          m.querySelector('#bb-claim').onclick = async () => {
+            const address = m.querySelector('#bb-addr').value.trim(); if (!address) return;
+            try { const r = await api.post('/api/claim', { address }); BB.you = r.you; paintYou(); close(); toast('Address claimed ✓'); resolve(r.you); }
+            catch (e) { toast('Claim failed'); }
+          };
+        }
+      });
+    });
+  };
+
+  /* ---------- composer ---------- */
+  const CATS = [['📣 Block Post', 'plum'], ['🍯 Cup of Sugar', 'green'], ['🕯️ Shabbat Dinner Club', 'plum'], ['🌿 Block Events', 'green'], ['🐱 Lost & Found', 'amber'], ['🏚️ Who Lived Here', 'green']];
+  BB.composer = async function () {
+    const you = await BB.ensureVerified(); if (!you) return;
+    modal('Share with the block', `
+      <select id="bb-cat" class="bb-input">${CATS.map(c => `<option value="${c[0]}|${c[1]}">${c[0]}</option>`).join('')}</select>
+      <textarea id="bb-body" rows="5" placeholder="What's happening on the block, ${you.name.split(' ')[0]}?"></textarea>
+      <button class="bb-btn" id="bb-post">Post to the block</button>`, {
+      onOpen(m) {
+        m.querySelector('#bb-post').onclick = async () => {
+          const body = m.querySelector('#bb-body').value.trim(); if (!body) return;
+          const [category, catColor] = m.querySelector('#bb-cat').value.split('|');
+          try { await api.post('/api/posts', { body, category, catColor }); close(); toast('Posted ✓'); if (location.pathname === '/') location.reload(); }
+          catch (e) { toast(e.status === 403 ? 'Claim your address first' : 'Post failed'); }
+        };
+      }
+    });
+  };
+
+  /* ---------- reactions / share / bookmark ---------- */
+  BB.react = async function (btn) {
+    const you = await BB.ensureAuth(); if (!you) return;
+    const [targetType, targetId] = btn.dataset.react.split(':');
+    try {
+      const r = await api.post('/api/react', { targetType, targetId, kind: 'thank' });
+      btn.classList.toggle('on', r.on);
+      const c = btn.querySelector('.cnt'); if (c) c.textContent = r.count;
+      else if (r.count) btn.insertAdjacentHTML('beforeend', ` <span class="cnt">${r.count}</span>`);
+    } catch (e) { toast('Try again'); }
+  };
+  BB.share = async function (id) {
+    try { const r = await api.post(`/api/posts/${id}/share`, {}); } catch {}
+    const url = location.origin + '/p/' + id;
+    modal('Share this post', `
+      <p class="bb-sub">Copy the link or send it to a neighbor.</p>
+      <input id="bb-url" class="bb-input" readonly value="${url}" />
+      <button class="bb-btn" id="bb-copy">Copy link</button>`, {
+      onOpen(m) { m.querySelector('#bb-copy').onclick = () => { m.querySelector('#bb-url').select(); navigator.clipboard?.writeText(url); toast('Link copied'); close(); }; }
+    });
+  };
+
+  /* ---------- comments ---------- */
+  BB.sendComment = async function (postId, input, isReply) {
+    const you = await BB.ensureVerified(); if (!you) { return; }
+    const body = input.value.trim(); if (!body) return;
+    try {
+      const r = await api.post(`/api/posts/${postId}/comments`, { body, reply: !!isReply });
+      input.value = '';
+      const list = document.getElementById('bb-comments');
+      if (list && window.renderComment) list.insertAdjacentHTML('beforeend', window.renderComment(r.comment));
+      toast('Reply posted ✓');
+    } catch (e) { toast(e.status === 403 ? 'Claim your address first' : 'Reply failed'); }
+  };
+
+  /* ---------- opportunities ---------- */
+  BB.respond = async function (id, neighbor) {
+    const you = await BB.ensureVerified(); if (!you) return;
+    modal('Respond to ' + (neighbor || 'this neighbor'), `
+      <p class="bb-sub">Introduce yourself and your services. They'll see this in their inbox.</p>
+      <textarea id="bb-msg" rows="4" placeholder="Hi! I'd love to help with this…"></textarea>
+      <button class="bb-btn" id="bb-send">Send response</button>`, {
+      onOpen(m) {
+        m.querySelector('#bb-send').onclick = async () => {
+          const body = m.querySelector('#bb-msg').value.trim(); if (!body) return;
+          try { const r = await api.post(`/api/opportunities/${id}/respond`, { body }); close(); toast('Response sent ✓'); document.dispatchEvent(new CustomEvent('opp-responded', { detail: { id, responses: r.responses } })); }
+          catch (e) { toast(e.status === 403 ? 'Claim your address first' : 'Failed'); }
+        };
+      }
+    });
+  };
+  BB.unlock = async function (id, card) {
+    const you = await BB.ensureAuth(); if (!you) return;
+    try { await api.post(`/api/opportunities/${id}/unlock`, {}); document.dispatchEvent(new CustomEvent('opp-unlocked', { detail: { id } })); toast('Lead unlocked ✓'); }
+    catch (e) { toast('Failed'); }
+  };
+
+  /* ---------- search ---------- */
+  let searchT;
+  BB.wireSearch = function (input) {
+    if (!input) return;
+    let dd = document.createElement('div'); dd.className = 'bb-search-dd'; input.parentNode.appendChild(dd);
+    input.addEventListener('input', () => {
+      clearTimeout(searchT); const term = input.value.trim();
+      if (term.length < 2) { dd.classList.remove('show'); return; }
+      searchT = setTimeout(async () => {
+        const r = await api.get('/api/search?q=' + encodeURIComponent(term));
+        const rows = [
+          ...r.posts.map(p => `<a href="/p/${p.id}"><b>${p.category || 'Post'}</b> — ${(Array.isArray(p.body) ? p.body[0] : p.body || '').slice(0, 60)}</a>`),
+          ...r.opportunities.map(o => `<a href="/opportunities"><b>${o.category}</b> — ${o.body.slice(0, 60)}</a>`),
+          ...r.neighbors.map(n => `<a href="/search?q=${encodeURIComponent(n.name)}"><b>👤 ${n.name}</b> ${n.address || ''}</a>`)
+        ];
+        dd.innerHTML = rows.length ? rows.join('') : '<div class="bb-empty">No matches</div>';
+        dd.classList.add('show');
+      }, 220);
+    });
+    input.addEventListener('keydown', e => { if (e.key === 'Enter' && input.value.trim()) location.href = '/search?q=' + encodeURIComponent(input.value.trim()); });
+    document.addEventListener('click', e => { if (!input.parentNode.contains(e.target)) dd.classList.remove('show'); });
+  };
+
+  /* ---------- notifications + inbox dropdowns ---------- */
+  function dropdown(btn, html) {
+    document.querySelectorAll('.bb-dd-pop').forEach(x => x.remove());
+    const pop = document.createElement('div'); pop.className = 'bb-dd-pop'; pop.innerHTML = html;
+    document.body.appendChild(pop);
+    const r = btn.getBoundingClientRect();
+    pop.style.top = (r.bottom + 6) + 'px'; pop.style.right = (window.innerWidth - r.right) + 'px';
+    setTimeout(() => document.addEventListener('click', function h(e) { if (!pop.contains(e.target) && e.target !== btn) { pop.remove(); document.removeEventListener('click', h); } }), 0);
+    return pop;
+  }
+  BB.openNotifications = async function (btn) {
+    const you = await BB.ensureAuth(); if (!you) return;
+    const r = await api.get('/api/notifications');
+    const items = r.items.length ? r.items.map(n => `<a href="${n.link || '#'}" class="bb-noti ${n.read ? '' : 'unread'}"><b>${n.actor}</b> ${n.text}</a>`).join('') : '<div class="bb-empty">No notifications yet</div>';
+    dropdown(btn, `<div class="bb-dd-hd">Notifications<button id="bb-mark">Mark all read</button></div>${items}`);
+    const mk = document.getElementById('bb-mark'); if (mk) mk.onclick = async () => { await api.post('/api/notifications/read', {}); document.querySelectorAll('.bb-noti').forEach(x => x.classList.remove('unread')); BB.updateBadges(); };
+  };
+  BB.openInbox = async function (btn) {
+    const you = await BB.ensureAuth(); if (!you) return;
+    const r = await api.get('/api/inbox');
+    const items = r.conversations.length ? r.conversations.map(c => `<a href="/inbox" class="bb-noti"><b>${c.peer}</b> <span>${(c.last || '').slice(0, 48)}</span></a>`).join('') : '<div class="bb-empty">Your inbox is empty</div>';
+    dropdown(btn, `<div class="bb-dd-hd">Messages</div>${items}`);
+  };
+
+  BB.updateBadges = async function () {
+    if (!BB.you) return;
+    const n = await api.get('/api/notifications');
+    document.querySelectorAll('[data-badge="notifications"]').forEach(b => { b.textContent = n.unread || ''; b.style.display = n.unread ? '' : 'none'; });
+  };
+
+  /* ---------- avatar menu ---------- */
+  BB.avatarMenu = async function (btn) {
+    const you = BB.you;
+    const html = you
+      ? `<div class="bb-dd-hd">${you.name}</div><a href="/bookmarks" class="bb-noti">🔖 Bookmarks</a><a href="#" class="bb-noti" id="bb-logout">↪ Log out</a>`
+      : `<div class="bb-dd-hd">Welcome</div><a href="#" class="bb-noti" id="bb-signin">Sign in</a>`;
+    dropdown(btn, html);
+    const lo = document.getElementById('bb-logout'); if (lo) lo.onclick = async e => { e.preventDefault(); await api.post('/api/session/logout', {}); location.reload(); };
+    const si = document.getElementById('bb-signin'); if (si) si.onclick = e => { e.preventDefault(); BB.ensureAuth(); };
+  };
+
+  /* ---------- global delegation ---------- */
+  document.addEventListener('click', e => {
+    const t = e.target.closest('[data-react],[data-share],[data-reply],[data-respond],[data-unlock],[data-composer],[data-panel],[data-avatar],[data-more]');
+    if (!t) return;
+    if (t.dataset.react !== undefined) { e.preventDefault(); BB.react(t); }
+    else if (t.dataset.share !== undefined) { e.preventDefault(); BB.share(t.dataset.share); }
+    else if (t.dataset.reply !== undefined) { e.preventDefault(); const ci = document.getElementById('bb-comment-input'); if (ci) ci.focus(); }
+    else if (t.dataset.respond !== undefined) { e.preventDefault(); BB.respond(t.dataset.respond, t.dataset.neighbor); }
+    else if (t.dataset.unlock !== undefined) { e.preventDefault(); BB.unlock(t.dataset.unlock, t.closest('.opp')); }
+    else if (t.dataset.composer !== undefined) { e.preventDefault(); BB.composer(); }
+    else if (t.dataset.panel === 'notifications') { e.preventDefault(); BB.openNotifications(t); }
+    else if (t.dataset.panel === 'inbox') { e.preventDefault(); BB.openInbox(t); }
+    else if (t.dataset.avatar !== undefined) { e.preventDefault(); BB.avatarMenu(t); }
+    else if (t.dataset.more !== undefined) { e.preventDefault(); BB.moreMenu(t); }
+  });
+
+  BB.moreMenu = function (btn) {
+    const id = (btn.dataset.more || '').split(':')[1];
+    dropdown(btn, `<a href="#" class="bb-noti" data-bm="${id}">🔖 Save post</a><a href="#" class="bb-noti" data-share="${id}">↗ Share</a><a href="/p/${id}" class="bb-noti">🔗 Open post</a>`);
+    const bm = document.querySelector(`[data-bm="${id}"]`); if (bm) bm.onclick = async e => { e.preventDefault(); const you = await BB.ensureAuth(); if (!you) return; try { const r = await BB.api.post(`/api/posts/${id}/bookmark`, {}); toast(r.on ? 'Saved 🔖' : 'Removed'); } catch { toast('Try again'); } };
+  };
+
+  /* ---------- init ---------- */
+  BB.init = async function () {
+    await BB.refresh();
+    BB.wireSearch(document.querySelector('.search input'));
+    BB.updateBadges();
+  };
+  document.addEventListener('DOMContentLoaded', () => { if (window.__bbAutoInit !== false) BB.init(); });
+})();
diff --git a/public/bookmarks.html b/public/bookmarks.html
new file mode 100644
index 0000000..e127083
--- /dev/null
+++ b/public/bookmarks.html
@@ -0,0 +1,20 @@
+<!DOCTYPE html>
+<html lang="en"><head>
+<meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" />
+<title>BubbesBlock — Bookmarks</title><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>window.__bbAutoInit = false;</script>
+<script src="/chrome.js"></script><script src="/app.js"></script>
+<script>
+Promise.all([fetch('/api/me',{credentials:'same-origin'}).then(r=>r.json()), fetch('/api/bookmarks',{credentials:'same-origin'}).then(r=>r.json())]).then(([m,d])=>{
+  document.getElementById('hdr').innerHTML=topbar(m.me);
+  document.getElementById('left').innerHTML=leftRail(m.me);
+  document.getElementById('right').innerHTML=rightRail(m.me);
+  const cards = (d.posts||[]).map(p=>`<article class="card feedcard" onclick="location.href='/p/${p.id}'"><div class="post">${postHead(p)}${postBody(p)}</div>${photoBlock(p.photo)}</article>`).join('');
+  const empty = `<div class="prose"><h1>🔖 No bookmarks yet</h1><p>Tap the ⋯ menu on any post and choose <b>Save post</b> to keep it here.${m.you?'':' Sign in first.'}</p></div>`;
+  document.getElementById('main').innerHTML='<div class="listhd">🔖 Bookmarks</div>'+(d.posts&&d.posts.length?cards:empty);
+  BB.init();
+});
+</script></body></html>
diff --git a/public/chrome.js b/public/chrome.js
index d88f29e..e575e75 100644
--- a/public/chrome.js
+++ b/public/chrome.js
@@ -25,9 +25,9 @@ function topbar(me){
     <nav class="topnav">
       <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>
+      <button class="navbtn" title="Notifications" data-panel="notifications"><span class="badge" data-badge="notifications" style="display:none"></span>${ICON.bell}</button>
+      <button class="navbtn" title="Inbox" data-panel="inbox">${ICON.inbox}</button>
+      <button class="avatar-btn" data-avatar><span class="ava ${me.color}" style="width:100%;height:100%;border-radius:0;font-size:15px">${me.initials}</span></button>
     </nav>
   </header>`;
 }
@@ -41,20 +41,20 @@ function leftRail(me){
     <div class="menu">
       <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>
+      <a class="${PATH==='/notifications'?'active':''}" href="/notifications">${ICON.bell}Notifications</a>
+      <a class="${PATH==='/groups'?'active':''}" href="/groups">${ICON.sugar}Groups</a>
+      <a class="${PATH==='/events'?'active':''}" href="/events">${ICON.cal}Block Events</a>
       <a href="https://wholivedthere.com">${ICON.history}Who Lived Here</a>
-      <a href="#">${ICON.book}Bookmarks</a>
+      <a class="${PATH==='/bookmarks'?'active':''}" href="/bookmarks">${ICON.book}Bookmarks</a>
     </div>
     <div class="menu">
       <div class="grouphd">Your Block · Beekman Ave</div>
       <div class="grp">
-        <a href="#"><span class="dot" style="background:#3a7d5d">🥧</span> Babka &amp; Bagel Swap</a>
-        <a href="#"><span class="dot" style="background:#b8862f">🔧</span> Tools &amp; Ladders to Borrow</a>
-        <a href="#"><span class="dot" style="background:#5566b5">🐕</span> Block Dog Walkers</a>
-        <a href="#"><span class="dot" style="background:#c2557a">🕯️</span> Shabbat Dinner Club</a>
-        <a href="#"><span class="dot" style="background:#a05236">🌿</span> Holiday &amp; Sukkah Help</a>
+        <a href="/groups"><span class="dot" style="background:#3a7d5d">🥧</span> Babka &amp; Bagel Swap</a>
+        <a href="/groups"><span class="dot" style="background:#b8862f">🔧</span> Tools &amp; Ladders to Borrow</a>
+        <a href="/groups"><span class="dot" style="background:#5566b5">🐕</span> Block Dog Walkers</a>
+        <a href="/groups"><span class="dot" style="background:#c2557a">🕯️</span> Shabbat Dinner Club</a>
+        <a href="/groups"><span class="dot" style="background:#a05236">🌿</span> Holiday &amp; Sukkah Help</a>
       </div>
     </div>
   </aside>`;
@@ -94,11 +94,11 @@ function reactStack(reactions){
   const cls=['#f3603c','#f7b500','#00874b','#5566b5'];
   return `<div class="react">${reactions.map((r,i)=>`<span style="background:${cls[i%cls.length]}">${r}</span>`).join('')}</div>`;
 }
-function actionBar(){
+function actionBar(id, youThanked, thanks){
   return `<div class="actions">
-    <button class="on"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.7l-1-1a5.5 5.5 0 0 0-7.8 7.8l1 1L12 21l7.8-7.6 1-1a5.5 5.5 0 0 0 0-7.8z"/></svg>Thank</button>
-    <button><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.4 8.4 0 0 1-9 8.4 9 9 0 0 1-4-1L3 20l1.2-4A8.4 8.4 0 1 1 21 11.5z"/></svg>Reply</button>
-    <button><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12v8h16v-8"/><path d="M12 16V4"/><path d="M8 8l4-4 4 4"/></svg>Share</button>
+    <button class="${youThanked?'on':''}" data-react="post:${id}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.7l-1-1a5.5 5.5 0 0 0-7.8 7.8l1 1L12 21l7.8-7.6 1-1a5.5 5.5 0 0 0 0-7.8z"/></svg>Thank <span class="cnt">${thanks||''}</span></button>
+    <button data-reply="${id}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.4 8.4 0 0 1-9 8.4 9 9 0 0 1-4-1L3 20l1.2-4A8.4 8.4 0 1 1 21 11.5z"/></svg>Reply</button>
+    <button data-share="${id}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12v8h16v-8"/><path d="M12 16V4"/><path d="M8 8l4-4 4 4"/></svg>Share</button>
   </div>`;
 }
 function postHead(p){
@@ -107,7 +107,7 @@ function postHead(p){
     <div class="who"><b>${p.author}</b>
       <div class="meta"><span class="gd">${p.address}</span> · ${p.time}${p.edited?' · <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg> Edited':''}</div>
     </div>
-    <button class="more"><svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg></button>
+    <button class="more" data-more="post:${p.id}"><svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg></button>
   </div>`;
 }
 function postBody(p){
diff --git a/public/content.html b/public/content.html
new file mode 100644
index 0000000..ca71631
--- /dev/null
+++ b/public/content.html
@@ -0,0 +1,26 @@
+<!DOCTYPE html>
+<html lang="en"><head>
+<meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" />
+<title>BubbesBlock</title><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>window.__bbAutoInit = false;</script>
+<script src="/chrome.js"></script><script src="/app.js"></script>
+<script>
+const page = location.pathname.replace('/','') || 'about';
+const C = {
+  about: ['About BubbesBlock', `<p>BubbesBlock is your block, the warm way — a neighborhood community where neighbors trade a cup of sugar, host Shabbat dinners, raise a sukkah, find a lost cat, and discover who lived in their home before them.</p><p>It's the community corner of the home-history family: <a href="https://wholivedthere.com" target="_blank">wholivedthere.com</a> tells your home's story and <a href="https://claimmyaddress.com" target="_blank">claimmyaddress.com</a> verifies you live here.</p><p>Local pros get <b>Opportunity Alerts</b> — real leads when a neighbor needs wallcoverings, decor, drapery, or window treatments.</p>`],
+  guidelines: ['Block Guidelines', `<p>Be a good neighbor. Keep it kind, keep it local, keep it real.</p><p>1. Real neighbors only — claim your address to post.<br>2. No spam, no resale of leads, no harassment.<br>3. Lost &amp; found, favors, events, and recommendations welcome.<br>4. Businesses: respond to opportunities honestly and once.</p>`],
+  privacy: ['Privacy', `<p>BubbesBlock stores only what you share to make the block work: your name, claimed address, posts, comments, and reactions.</p><p>Home-history lookups use public records via wholivedthere.com. We never sell your data.</p>`],
+  help: ['Help', `<p>Need a hand? Common things:</p><p>• <b>Post</b> — tap "Share something with the block" (claim your address first).<br>• <b>Claim your address</b> — verifies you live here; one tap.<br>• <b>Opportunity Alerts</b> — leads matched to your services.<br>• Questions? Email <a href="mailto:info@bubbesblock.com">info@bubbesblock.com</a>.</p>`]
+};
+const c = C[page] || C.about;
+fetch('/api/me',{credentials:'same-origin'}).then(r=>r.json()).then(m=>{
+  document.getElementById('hdr').innerHTML=topbar(m.me);
+  document.getElementById('left').innerHTML=leftRail(m.me);
+  document.getElementById('right').innerHTML=rightRail(m.me);
+  document.getElementById('main').innerHTML=`<div class="prose"><h1>${c[0]}</h1>${c[1]}</div>`;
+  BB.init();
+});
+</script></body></html>
diff --git a/public/events.html b/public/events.html
new file mode 100644
index 0000000..b9c1bd6
--- /dev/null
+++ b/public/events.html
@@ -0,0 +1,34 @@
+<!DOCTYPE html>
+<html lang="en"><head>
+<meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" />
+<title>BubbesBlock — Block Events</title><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>window.__bbAutoInit = false;</script>
+<script src="/chrome.js"></script><script src="/app.js"></script>
+<script>
+function tile(e){
+  return `<div class="tile" data-ev="${e.id}">
+    <span class="lg" style="background:var(--green)">${e.emoji||'📅'}</span>
+    <div class="main"><b>${e.title}</b><div class="meta2">${e.when_label} · ${e.location} · <span class="going">${e.going} going</span></div>
+      <div class="meta2" style="margin-top:4px">${e.body||''}</div></div>
+    <div class="act"><button class="${e.youGoing?'on':''}" data-ev-btn="${e.id}">${e.youGoing?'Going ✓':'RSVP'}</button></div>
+  </div>`;
+}
+Promise.all([fetch('/api/me',{credentials:'same-origin'}).then(r=>r.json()), fetch('/api/events',{credentials:'same-origin'}).then(r=>r.json())]).then(([m,d])=>{
+  document.getElementById('hdr').innerHTML=topbar(m.me);
+  document.getElementById('left').innerHTML=leftRail(m.me);
+  document.getElementById('right').innerHTML=rightRail(m.me);
+  document.getElementById('main').innerHTML='<div class="listhd">🎉 Block Events</div>'+(d.events||[]).map(tile).join('');
+  BB.init();
+});
+document.addEventListener('click', async e=>{
+  const b=e.target.closest('[data-ev-btn]'); if(!b) return;
+  if(!(await BB.ensureAuth())) return;
+  try{ const r=await BB.api.post(`/api/events/${b.dataset.evBtn}/rsvp`,{});
+    b.classList.toggle('on',r.going); b.textContent=r.going?'Going ✓':'RSVP';
+    const g=b.closest('.tile').querySelector('.going'); if(g) g.textContent=r.count+' going';
+  }catch{ BB.toast('Try again'); }
+});
+</script></body></html>
diff --git a/public/groups.html b/public/groups.html
new file mode 100644
index 0000000..fb3ef22
--- /dev/null
+++ b/public/groups.html
@@ -0,0 +1,34 @@
+<!DOCTYPE html>
+<html lang="en"><head>
+<meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" />
+<title>BubbesBlock — Groups</title><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>window.__bbAutoInit = false;</script>
+<script src="/chrome.js"></script><script src="/app.js"></script>
+<script>
+function tile(g){
+  return `<div class="tile" data-grp="${g.slug}">
+    <span class="lg" style="background:${g.color||'#3a7d5d'}">${g.emoji||'👥'}</span>
+    <div class="main"><b>${g.name}</b><div class="meta2"><span class="mem">${g.members}</span> members</div>
+      <div class="meta2" style="margin-top:4px">${g.blurb||''}</div></div>
+    <div class="act"><button class="${g.youJoined?'on':''}" data-join="${g.slug}">${g.youJoined?'Joined ✓':'Join'}</button></div>
+  </div>`;
+}
+Promise.all([fetch('/api/me',{credentials:'same-origin'}).then(r=>r.json()), fetch('/api/groups',{credentials:'same-origin'}).then(r=>r.json())]).then(([m,d])=>{
+  document.getElementById('hdr').innerHTML=topbar(m.me);
+  document.getElementById('left').innerHTML=leftRail(m.me);
+  document.getElementById('right').innerHTML=rightRail(m.me);
+  document.getElementById('main').innerHTML='<div class="listhd">👥 Your Block · Groups</div>'+(d.groups||[]).map(tile).join('');
+  BB.init();
+});
+document.addEventListener('click', async e=>{
+  const b=e.target.closest('[data-join]'); if(!b) return;
+  if(!(await BB.ensureAuth())) return;
+  try{ const r=await BB.api.post(`/api/groups/${b.dataset.join}/join`,{});
+    b.classList.toggle('on',r.joined); b.textContent=r.joined?'Joined ✓':'Join';
+    const m=b.closest('.tile').querySelector('.mem'); if(m) m.textContent=r.members;
+  }catch{ BB.toast('Try again'); }
+});
+</script></body></html>
diff --git a/public/inbox.html b/public/inbox.html
new file mode 100644
index 0000000..51f7667
--- /dev/null
+++ b/public/inbox.html
@@ -0,0 +1,41 @@
+<!DOCTYPE html>
+<html lang="en"><head>
+<meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" />
+<title>BubbesBlock — Inbox</title><link rel="stylesheet" href="/styles.css" />
+<style>
+.thread{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:14px;margin-top:12px}
+.msg{max-width:75%;padding:9px 13px;border-radius:16px;margin:6px 0;font-size:14.5px;line-height:1.4}
+.msg.them{background:#f0f2f4}
+.msg.me{background:var(--green);color:#fff;margin-left:auto}
+.threadbox{display:flex;gap:8px;margin-top:10px}
+.threadbox input{flex:1;border:1px solid var(--line);border-radius:22px;padding:10px 15px;outline:none}
+.threadbox button{background:var(--green);color:#fff;font-weight:700;padding:0 18px;border-radius:22px}
+</style>
+</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>window.__bbAutoInit = false;</script>
+<script src="/chrome.js"></script><script src="/app.js"></script>
+<script>
+let convId=null;
+async function openThread(id, peer){
+  convId=id;
+  const r=await BB.api.get('/api/inbox/'+id);
+  document.getElementById('thread').innerHTML=`<b>${peer}</b>`+r.messages.map(m=>`<div class="msg ${m.from_me?'me':'them'}">${m.body}</div>`).join('')+
+    `<div class="threadbox"><input id="bb-msg" placeholder="Message…" /><button id="bb-msend">Send</button></div>`;
+  const inp=document.getElementById('bb-msg'); const send=async()=>{ const b=inp.value.trim(); if(!b) return; await BB.api.post(`/api/inbox/${id}/messages`,{body:b}); openThread(id,peer); };
+  document.getElementById('bb-msend').onclick=send; inp.addEventListener('keydown',e=>{if(e.key==='Enter')send();});
+}
+Promise.all([fetch('/api/me',{credentials:'same-origin'}).then(r=>r.json()), fetch('/api/inbox',{credentials:'same-origin'}).then(r=>r.json())]).then(([m,d])=>{
+  document.getElementById('hdr').innerHTML=topbar(m.me);
+  document.getElementById('left').innerHTML=leftRail(m.me);
+  document.getElementById('right').innerHTML=rightRail(m.me);
+  let body;
+  if(!m.you){ body='<div class="prose"><h1>✉️ Messages</h1><p>Sign in to see your inbox.</p><button class="bb-btn" style="max-width:220px" onclick="BB.ensureAuth().then(()=>location.reload())">Sign in</button></div>'; }
+  else if(!d.conversations.length){ body='<div class="prose"><h1>✉️ Messages</h1><p>Your inbox is empty. Respond to an Opportunity Alert to start a conversation.</p></div>'; }
+  else { body='<div class="listhd">✉️ Messages</div>'+d.conversations.map(c=>`<div class="tile" data-conv="${c.id}" data-peer="${c.peer}"><span class="ava ${c.peer_color||'a3'}" style="width:48px;height:48px;font-size:17px">${c.peer_initials||'?'}</span><div class="main"><b>${c.peer}</b><div class="meta2">${(c.last||'').slice(0,60)}</div></div></div><div id="thread-${c.id}"></div>`).join('')+'<div class="thread" id="thread" style="display:none"></div>'; }
+  document.getElementById('main').innerHTML=body;
+  BB.init();
+});
+document.addEventListener('click', e=>{ const t=e.target.closest('[data-conv]'); if(!t) return; const th=document.getElementById('thread'); th.style.display='block'; openThread(t.dataset.conv, t.dataset.peer); th.scrollIntoView({behavior:'smooth'}); });
+</script></body></html>
diff --git a/public/index.html b/public/index.html
index ec674b1..3ba2a5d 100644
--- a/public/index.html
+++ b/public/index.html
@@ -15,33 +15,31 @@
   <div id="right"></div>
 </div>
 
+<script>window.__bbAutoInit = false;</script>
 <script src="/chrome.js"></script>
+<script src="/app.js"></script>
 <script>
-fetch('/api/posts').then(r=>r.json()).then(d=>{
+fetch('/api/posts', { credentials: 'same-origin' }).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 newpost = `<div class="newpost">
     <span class="ava ${d.me.color}" style="width:42px;height:42px;font-size:15px">${d.me.initials}</span>
-    <div class="fake">Share something with the block, ${d.me.name.split(' ')[0]}…</div>
+    <div class="fake" data-composer>Share something with the block, ${d.me.name.split(' ')[0]}…</div>
   </div>`;
 
   const cards = d.posts.map(p=>`
-    <article class="card feedcard" onclick="location.href='/p/${p.id}'">
-      <div class="post">
-        ${postHead(p)}
-        ${postBody(p)}
-      </div>
+    <article class="card feedcard" onclick="if(!event.target.closest('button,a,[data-react],[data-share],[data-reply],.actions,.more')) location.href='/p/${p.id}'">
+      <div class="post">${postHead(p)}${postBody(p)}</div>
       ${photoBlock(p.photo)}
       ${statRow(p, p.commentCount)}
-      ${actionBar()}
+      ${actionBar(p.id, p.youThanked, p.thanks)}
     </article>`).join('');
 
   document.getElementById('feed').innerHTML = newpost + cards;
-}).catch(e=>{
-  document.getElementById('feed').innerHTML = '<div class="card"><div class="post">Could not load the block feed.</div></div>';
-});
+  BB.init();
+}).catch(()=>{ document.getElementById('feed').innerHTML = '<div class="card"><div class="post">Could not load the block feed.</div></div>'; });
 </script>
 </body>
 </html>
diff --git a/public/notifications.html b/public/notifications.html
new file mode 100644
index 0000000..04a6b5b
--- /dev/null
+++ b/public/notifications.html
@@ -0,0 +1,23 @@
+<!DOCTYPE html>
+<html lang="en"><head>
+<meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" />
+<title>BubbesBlock — Notifications</title><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>window.__bbAutoInit = false;</script>
+<script src="/chrome.js"></script><script src="/app.js"></script>
+<script>
+Promise.all([fetch('/api/me',{credentials:'same-origin'}).then(r=>r.json()), fetch('/api/notifications',{credentials:'same-origin'}).then(r=>r.json())]).then(([m,d])=>{
+  document.getElementById('hdr').innerHTML=topbar(m.me);
+  document.getElementById('left').innerHTML=leftRail(m.me);
+  document.getElementById('right').innerHTML=rightRail(m.me);
+  let body;
+  if(!m.you){ body='<div class="prose"><h1>🔔 Notifications</h1><p>Sign in to see your notifications.</p><button class="bb-btn" style="max-width:220px" onclick="BB.ensureAuth().then(()=>location.reload())">Sign in</button></div>'; }
+  else if(!d.items.length){ body='<div class="prose"><h1>🔔 Notifications</h1><p>Nothing new yet — check back after the next block party.</p></div>'; }
+  else { body='<div class="listhd">🔔 Notifications</div>'+d.items.map(n=>`<a class="tile ${n.read?'':'unread'}" href="${n.link||'#'}" style="${n.read?'':'background:var(--green-tint)'}"><span class="lg" style="background:var(--plum)">${({react:'❤️',comment:'💬',opportunity:'💼',event:'📅'})[n.type]||'🔔'}</span><div class="main"><b>${n.actor}</b> <span class="meta2" style="display:inline">${n.text}</span></div></a>`).join(''); }
+  document.getElementById('main').innerHTML=body;
+  BB.init();
+  if(m.you && d.items.length) BB.api.post('/api/notifications/read',{}).then(()=>BB.updateBadges());
+});
+</script></body></html>
diff --git a/public/opportunities.html b/public/opportunities.html
index d549336..d24e6e9 100644
--- a/public/opportunities.html
+++ b/public/opportunities.html
@@ -15,40 +15,50 @@
   <div id="right"></div>
 </div>
 
+<script>window.__bbAutoInit = false;</script>
 <script src="/chrome.js"></script>
+<script src="/app.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 respCta(o){
+  return o.youResponded
+    ? `<button class="go done" disabled>Responded ✓</button>`
+    : `<button class="go" data-respond="${o.id}" data-neighbor="${o.neighbor}">Respond</button>`;
+}
 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 action = o.locked ? `<button class="lock" data-unlock="${o.id}">${lockSvg} Unlock</button>` : respCta(o);
   const wlt = o.address
-    ? `<a class="wlt" href="https://wholivedthere.com/?address=${encodeURIComponent(o.address)}" target="_blank">🏚️ See who lived at ${o.area} →</a>`
+    ? `<button class="wlt" data-wlt="${encodeURIComponent(o.address)}">🏚️ See who lived at ${o.area} →</button><div class="wlt-mount"></div>`
     : '';
-  return `<div class="opp ${o.forYou?'you':''}">
+  return `<div class="opp ${o.forYou?'you':''}" data-opp="${o.id}" data-neighbor="${o.neighbor}">
     <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="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>
+        <span class="loc">${pinSvg} ${o.area}</span><span>· ${o.time}</span>
+        <span class="rc">· ${o.responses} responded</span>
+        <span class="resp">${action}</span>
       </div>
     </div>
   </div>`;
 }
+function renderHistory(h){
+  if(!h || h.source==='unavailable') return `<div class="wlt-panel"><h5>🏚️ ${h.normalized||h.address||''}</h5><p class="res">Full home history isn't in yet. <a href="${h.link||'https://wholivedthere.com'}" target="_blank">Search wholivedthere.com →</a></p></div>`;
+  const res = (h.residents||[]).map(r=>`${r.name}${r.years?` (${r.years})`:''}`).join(' · ');
+  return `<div class="wlt-panel"><h5>🏚️ ${h.normalized||h.address}</h5>
+    ${h.summary?`<p>${h.summary}</p>`:''}
+    ${h.builtYear?`<p class="res">Built ${h.builtYear}${h.sqft?` · ${h.sqft} sqft`:''}${h.beds?` · ${h.beds}bd/${h.baths||'?'}ba`:''}</p>`:''}
+    ${h.lastSold?`<p class="res">Last sold ${h.lastSold.date||''}${h.lastSold.price?` · ${h.lastSold.price}`:''}</p>`:''}
+    ${res?`<p class="res">Past residents: ${res}</p>`:''}
+    <a class="wlt" href="https://wholivedthere.com/?address=${encodeURIComponent(h.address||'')}" target="_blank">Full record on wholivedthere.com →</a>
+  </div>`;
+}
 
-fetch('/api/opportunities').then(r=>r.json()).then(d=>{
+fetch('/api/opportunities', { credentials:'same-origin' }).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);
@@ -56,19 +66,34 @@ fetch('/api/opportunities').then(r=>r.json()).then(d=>{
   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>`;
-
+    <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>';
+  document.getElementById('main').innerHTML = hero + count + d.opportunities.map(oppCard).join('');
+  BB.init();
+}).catch(()=>{ document.getElementById('main').innerHTML = '<div class="card"><div class="post">Could not load opportunities.</div></div>'; });
+
+// inline home-history toggle
+document.addEventListener('click', async e=>{
+  const b = e.target.closest('[data-wlt]'); if(!b) return;
+  const mount = b.nextElementSibling;
+  if(b.dataset.open){ mount.innerHTML=''; delete b.dataset.open; b.textContent = b.textContent.replace('Hide','See'); return; }
+  b.dataset.open='1'; mount.innerHTML='<div class="wlt-panel loading">Looking up home history…</div>';
+  try{ const h = await BB.api.get('/api/home-history?address='+b.dataset.wlt); mount.innerHTML = renderHistory(h); }
+  catch{ mount.innerHTML='<div class="wlt-panel">Could not load history.</div>'; }
+});
+// reflect respond/unlock results
+document.addEventListener('opp-responded', e=>{
+  const card = document.querySelector(`.opp[data-opp="${e.detail.id}"]`); if(!card) return;
+  const rc = card.querySelector('.rc'); if(rc) rc.textContent = `· ${e.detail.responses} responded`;
+  card.querySelector('.resp').innerHTML = `<button class="go done" disabled>Responded ✓</button>`;
+});
+document.addEventListener('opp-unlocked', e=>{
+  const card = document.querySelector(`.opp[data-opp="${e.detail.id}"]`); if(!card) return;
+  card.querySelector('.resp').innerHTML = `<button class="go" data-respond="${e.detail.id}" data-neighbor="${card.dataset.neighbor||''}">Respond</button>`;
 });
 </script>
 </body>
diff --git a/public/post.html b/public/post.html
index 0981cdf..9f1e2cc 100644
--- a/public/post.html
+++ b/public/post.html
@@ -14,25 +14,26 @@
   <div id="right"></div>
 </div>
 
+<script>window.__bbAutoInit = false;</script>
 <script src="/chrome.js"></script>
+<script src="/app.js"></script>
 <script>
-// id from /p/:id path, or ?id= fallback
 const parts = location.pathname.split('/').filter(Boolean);
 const id = (parts[0]==='p' && parts[1]) ? parts[1] : new URLSearchParams(location.search).get('id');
 
-function comment(c){
+// global so app.js can append a freshly-posted comment
+window.renderComment = function (c){
   return `<div class="cmt${c.reply?' reply':''}">
     <span class="ava ${c.color}" style="width:36px;height:36px;font-size:${c.reply?13:14}px">${c.initials}</span>
     <div>
-      <div class="bub"><b>${c.author}</b> <span class="nb">· ${c.nb}</span><p>${c.body}</p></div>
-      <div class="meta"><span>${c.time}</span><a>Thank · ${c.thanks}</a><a>Reply</a></div>
+      <div class="bub"><b>${c.author}</b> <span class="nb">· ${c.nb||''}</span><p>${c.body}</p></div>
+      <div class="meta"><span>${c.time}</span><a data-react="comment:${c.id}">Thank <span class="cnt">${c.thanks||''}</span></a><a data-reply="${id}">Reply</a></div>
     </div>
   </div>`;
-}
+};
 
-fetch('/api/posts/'+encodeURIComponent(id)).then(r=>{
-  if(!r.ok) throw new Error('not found');
-  return r.json();
+fetch('/api/posts/'+encodeURIComponent(id), { credentials:'same-origin' }).then(r=>{
+  if(!r.ok) throw new Error('not found'); return r.json();
 }).then(d=>{
   const p = d.post;
   document.title = `BubbesBlock — ${p.author}`;
@@ -40,8 +41,7 @@ fetch('/api/posts/'+encodeURIComponent(id)).then(r=>{
   document.getElementById('left').innerHTML  = leftRail(d.me);
   document.getElementById('right').innerHTML = rightRail(d.me);
 
-  const cmts = (p.comments||[]).map(comment).join('');
-  const more = p.moreComments ? `<div class="morecmt">View ${p.moreComments} more comments</div>` : '';
+  const cmts = (p.comments||[]).map(window.renderComment).join('');
 
   document.getElementById('main').innerHTML = `
     <div class="backbar"><a href="/">
@@ -51,19 +51,32 @@ fetch('/api/posts/'+encodeURIComponent(id)).then(r=>{
       <div class="post">${postHead(p)}${postBody(p)}</div>
       ${photoBlock(p.photo)}
       ${statRow(p, (p.comments?p.comments.length:0)+(p.moreComments||0))}
-      ${actionBar()}
+      ${actionBar(p.id, p.youThanked, p.thanks)}
       <div class="comments">
         <div class="compose">
           <span class="ava ${d.me.color}" style="width:36px;height:36px;font-size:13px">${d.me.initials}</span>
           <div class="field">
-            <input placeholder="Add a neighborly reply…" />
-            <button title="Photo"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="5" width="18" height="14" rx="2"/><circle cx="9" cy="11" r="2"/><path d="M21 17l-5-5-9 7"/></svg></button>
-            <button title="Send"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 2L11 13"/><path d="M22 2l-7 20-4-9-9-4 20-7z"/></svg></button>
+            <input id="bb-comment-input" placeholder="Add a neighborly reply…" />
+            <button id="bb-comment-send" title="Send"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 2L11 13"/><path d="M22 2l-7 20-4-9-9-4 20-7z"/></svg></button>
           </div>
         </div>
-        ${cmts}${more}
+        <div id="bb-comments">${cmts}</div>
+        ${p.moreComments ? `<div class="morecmt" id="bb-morecmt">View ${p.moreComments} more comments</div>` : ''}
       </div>
     </article>`;
+
+  // wire comment send (button + Enter)
+  const input = document.getElementById('bb-comment-input');
+  const send = () => BB.sendComment(id, input, false);
+  document.getElementById('bb-comment-send').onclick = send;
+  input.addEventListener('keydown', e => { if (e.key === 'Enter') send(); });
+  const mc = document.getElementById('bb-morecmt');
+  if (mc) mc.onclick = async () => {
+    const r = await BB.api.get(`/api/posts/${id}/comments`);
+    document.getElementById('bb-comments').innerHTML = r.comments.map(window.renderComment).join('');
+    mc.remove();
+  };
+  BB.init();
 }).catch(()=>{
   document.getElementById('hdr').innerHTML = '';
   document.getElementById('main').innerHTML = '<div class="card"><div class="post"><b>Post not found.</b><br><a href="/" style="color:var(--green-dark);font-weight:700">← Back to the block feed</a></div></div>';
diff --git a/public/search.html b/public/search.html
new file mode 100644
index 0000000..0360ce9
--- /dev/null
+++ b/public/search.html
@@ -0,0 +1,29 @@
+<!DOCTYPE html>
+<html lang="en"><head>
+<meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" />
+<title>BubbesBlock — Search</title><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>window.__bbAutoInit = false;</script>
+<script src="/chrome.js"></script><script src="/app.js"></script>
+<script>
+const term = new URLSearchParams(location.search).get('q') || '';
+Promise.all([
+  fetch('/api/me',{credentials:'same-origin'}).then(r=>r.json()),
+  fetch('/api/search?q='+encodeURIComponent(term),{credentials:'same-origin'}).then(r=>r.json())
+]).then(([m,d])=>{
+  document.getElementById('hdr').innerHTML=topbar(m.me);
+  document.getElementById('left').innerHTML=leftRail(m.me);
+  document.getElementById('right').innerHTML=rightRail(m.me);
+  const sec=(title,rows)=> rows.length?`<div class="listhd" style="font-size:16px">${title}</div>${rows.join('')}`:'';
+  const posts=(d.posts||[]).map(p=>`<a class="tile" href="/p/${p.id}"><span class="lg" style="background:var(--plum)">📣</span><div class="main"><b>${p.category||'Post'}</b><div class="meta2">${(Array.isArray(p.body)?p.body[0]:p.body||'').slice(0,90)}</div></div></a>`);
+  const opps=(d.opportunities||[]).map(o=>`<a class="tile" href="/opportunities"><span class="lg" style="background:var(--green)">💼</span><div class="main"><b>${o.category}</b><div class="meta2">${o.body.slice(0,90)}</div></div></a>`);
+  const nb=(d.neighbors||[]).map(n=>`<div class="tile"><span class="ava ${n.color||'a1'}" style="width:48px;height:48px;font-size:17px">${n.initials||'?'}</span><div class="main"><b>${n.name}</b><div class="meta2">${n.address||'neighbor'}</div></div></div>`);
+  const any = posts.length+opps.length+nb.length;
+  document.getElementById('main').innerHTML=`<div class="listhd">Search${term?`: “${term}”`:''}</div>`+
+    (any? sec('Posts',posts)+sec('Opportunities',opps)+sec('Neighbors',nb) : '<div class="prose"><h1>No matches</h1><p>Try a different term.</p></div>');
+  BB.init();
+  const si=document.querySelector('.search input'); if(si) si.value=term;
+});
+</script></body></html>
diff --git a/public/styles.css b/public/styles.css
index d676f6f..fbbc317 100644
--- a/public/styles.css
+++ b/public/styles.css
@@ -177,3 +177,64 @@ nav.topnav{margin-left:auto;display: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}
+
+/* ---------- interaction layer: toast / modal / dropdowns / buttons ---------- */
+#bb-toast{position:fixed;left:50%;bottom:28px;transform:translateX(-50%) translateY(20px);background:#1b1b1b;color:#fff;
+  padding:11px 20px;border-radius:24px;font-size:14px;font-weight:600;z-index:300;opacity:0;pointer-events:none;transition:.25s;box-shadow:0 6px 24px rgba(0,0,0,.25)}
+#bb-toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
+.bb-modal-back{position:fixed;inset:0;background:rgba(20,12,18,.55);z-index:250;display:grid;place-items:center;padding:18px;animation:bbfade .15s}
+@keyframes bbfade{from{opacity:0}to{opacity:1}}
+.bb-modal{background:#fff;border-radius:16px;width:100%;max-width:440px;box-shadow:0 20px 60px rgba(0,0,0,.3);overflow:hidden;animation:bbpop .18s}
+@keyframes bbpop{from{transform:translateY(10px) scale(.98);opacity:.6}to{transform:none;opacity:1}}
+.bb-modal-hd{display:flex;align-items:center;justify-content:space-between;padding:15px 18px;border-bottom:1px solid var(--line)}
+.bb-modal-hd b{font-size:16px}
+.bb-x{font-size:24px;color:var(--muted);width:32px;height:32px;border-radius:50%;line-height:1}
+.bb-x:hover{background:#f0f2f4}
+.bb-modal-bd{padding:18px}
+.bb-modal-bd .bb-sub{margin:0 0 14px;color:var(--muted);font-size:14px}
+.bb-modal-bd input,.bb-modal-bd textarea,.bb-modal-bd select,.bb-input{width:100%;border:1px solid var(--line);border-radius:10px;padding:11px 13px;font-size:15px;font-family:inherit;outline:none;margin-bottom:12px;background:#fff}
+.bb-modal-bd input:focus,.bb-modal-bd textarea:focus,.bb-modal-bd select:focus{border-color:var(--green)}
+.bb-modal-bd textarea{resize:vertical}
+.bb-btn{width:100%;background:var(--green);color:#fff;font-weight:800;font-size:15px;padding:12px;border-radius:24px}
+.bb-btn:hover{background:var(--green-dark)}
+.bb-fine{display:block;margin:12px 0 0;font-size:12px;color:var(--muted);text-align:center}
+a.bb-fine:hover{text-decoration:underline}
+/* dropdown popovers (notifications, inbox, avatar, more) */
+.bb-dd-pop{position:fixed;background:#fff;border:1px solid var(--line);border-radius:12px;box-shadow:0 14px 40px rgba(0,0,0,.18);z-index:240;min-width:300px;max-width:360px;max-height:70vh;overflow:auto;padding:6px}
+.bb-dd-hd{display:flex;align-items:center;justify-content:space-between;font-weight:800;font-size:13px;padding:9px 11px;color:var(--ink)}
+.bb-dd-hd button{font-size:12px;font-weight:700;color:var(--green)}
+.bb-noti{display:block;padding:10px 11px;border-radius:9px;font-size:13.5px;color:#3a3b3c;line-height:1.4}
+.bb-noti:hover{background:#f0f2f4}
+.bb-noti.unread{background:var(--green-tint)}
+.bb-noti b{color:var(--ink)}
+.bb-noti span{color:var(--muted)}
+.bb-empty{padding:16px;text-align:center;color:var(--muted);font-size:13px}
+/* search dropdown */
+.search{position:relative}
+.bb-search-dd{position:absolute;top:46px;left:0;right:0;background:#fff;border:1px solid var(--line);border-radius:12px;box-shadow:0 14px 40px rgba(0,0,0,.16);z-index:60;overflow:hidden;display:none}
+.bb-search-dd.show{display:block}
+.bb-search-dd a{display:block;padding:10px 14px;font-size:13.5px;border-bottom:1px solid var(--line);color:#3a3b3c}
+.bb-search-dd a:last-child{border-bottom:none}
+.bb-search-dd a:hover{background:#f0f2f4}
+/* reaction/thank "on" state + counts */
+.actions button.on,.cmt .meta a.on{color:var(--green)}
+.actions .cnt{font-weight:700}
+.opp .resp .go.done{background:#eef0f2;color:var(--muted)}
+/* inline home-history panel */
+.wlt-panel{margin-top:9px;background:#faf6f0;border:1px solid var(--line);border-radius:10px;padding:11px 13px;font-size:13px}
+.wlt-panel h5{margin:0 0 5px;font-size:13px;color:var(--green-dark)}
+.wlt-panel .res{color:var(--muted)}
+.wlt-panel .loading{color:var(--muted)}
+/* generic list pages (events/groups/bookmarks/search/content) */
+.listhd{font-size:22px;font-weight:800;margin:2px 2px 14px}
+.tile{display:flex;gap:13px;align-items:center;background:var(--card);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:15px 16px;margin-bottom:12px}
+.tile .lg{width:48px;height:48px;border-radius:12px;display:grid;place-items:center;font-size:22px;flex:none;color:#fff}
+.tile .main{flex:1;min-width:0}
+.tile b{font-size:15px}
+.tile .meta2{font-size:13px;color:var(--muted);margin-top:2px}
+.tile .act{margin-left:auto}
+.tile .act button{font-weight:700;font-size:13px;padding:8px 16px;border-radius:20px;background:var(--green);color:#fff}
+.tile .act button.on{background:#eef0f2;color:var(--muted)}
+.prose{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:24px 26px}
+.prose h1{margin:0 0 12px;font-size:24px}
+.prose p{color:#333;line-height:1.6;margin:0 0 12px}
diff --git a/scripts/migrate.js b/scripts/migrate.js
index 3c8734f..4be35fe 100644
--- a/scripts/migrate.js
+++ b/scripts/migrate.js
@@ -75,9 +75,61 @@ const seed = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'data', 'post
     }
   }
 
+  // ---- users: me + every author/neighbor seen across content (idempotent) ----
+  const uid = name => 'u-' + String(name).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
+  const users = new Map();
+  const addUser = (name, initials, color, address, verified) => {
+    if (!name) return; const id = uid(name);
+    if (!users.has(id)) users.set(id, { id, name, initials: initials || name.slice(0, 2).toUpperCase(), color: color || 'a1', address: address || null, verified: !!verified });
+  };
+  const me = seed.me; addUser(me.name, me.initials, me.color, me.address, me.verified);
+  for (const p of seed.posts) { addUser(p.author, p.initials, p.color, p.address, true); for (const m of (p.comments || [])) addUser(m.author, m.initials, m.color, null, true); }
+  if (fs.existsSync(path.join(__dirname, '..', 'data', 'opportunities.json'))) {
+    const od = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'data', 'opportunities.json'), 'utf8'));
+    for (const o of (od.opportunities || [])) addUser(o.neighbor, o.initials, o.color, o.address, true);
+  }
+  for (const u of users.values()) {
+    await c.query(`INSERT INTO users(id,name,initials,color,address,verified) VALUES($1,$2,$3,$4,$5,$6)
+      ON CONFLICT(id) DO UPDATE SET name=EXCLUDED.name,initials=EXCLUDED.initials,color=EXCLUDED.color,
+        address=COALESCE(EXCLUDED.address,users.address),verified=users.verified OR EXCLUDED.verified`,
+      [u.id, u.name, u.initials, u.color, u.address, u.verified]);
+  }
+
+  // ---- groups + events ----
+  const commPath = path.join(__dirname, '..', 'data', 'community.json');
+  if (fs.existsSync(commPath)) {
+    const cm = JSON.parse(fs.readFileSync(commPath, 'utf8'));
+    for (let i = 0; i < (cm.groups || []).length; i++) { const g = cm.groups[i];
+      await c.query(`INSERT INTO groups(slug,name,emoji,color,members,blurb,position) VALUES($1,$2,$3,$4,$5,$6,$7)
+        ON CONFLICT(slug) DO UPDATE SET name=EXCLUDED.name,emoji=EXCLUDED.emoji,color=EXCLUDED.color,members=EXCLUDED.members,blurb=EXCLUDED.blurb,position=EXCLUDED.position`,
+        [g.slug, g.name, g.emoji, g.color, g.members, g.blurb, i]); }
+    for (let i = 0; i < (cm.events || []).length; i++) { const e = cm.events[i];
+      await c.query(`INSERT INTO events(id,title,emoji,when_label,location,going,body,position) VALUES($1,$2,$3,$4,$5,$6,$7,$8)
+        ON CONFLICT(id) DO UPDATE SET title=EXCLUDED.title,emoji=EXCLUDED.emoji,when_label=EXCLUDED.when_label,location=EXCLUDED.location,going=EXCLUDED.going,body=EXCLUDED.body,position=EXCLUDED.position`,
+        [e.id, e.title, e.emoji, e.when_label, e.location, e.going, e.body, i]); }
+  }
+
+  // ---- starter notifications + one inbox conversation for the demo user (only if none yet) ----
+  const meId = uid(me.name);
+  if ((await c.query('SELECT count(*) FROM notifications WHERE user_id=$1', [meId])).rows[0].count === '0') {
+    const notifs = [
+      ['react', 'Goldie Fein', 'thanked your comment on “Honey cake on the stoop”', '/p/honey-cake'],
+      ['comment', 'Sol Katz', 'replied to the Block Party post', '/p/block-party'],
+      ['opportunity', 'BubbesBlock', 'New lead matched to Wallcoverings near you', '/opportunities'],
+      ['event', 'Shabbat Dinner Club', 'reminded you: Shabbat dinner Friday 6:30', '/events']
+    ];
+    for (const [t, a, x, l] of notifs) await c.query('INSERT INTO notifications(user_id,type,actor,text,link) VALUES($1,$2,$3,$4,$5)', [meId, t, a, x, l]);
+    const conv = await c.query('INSERT INTO conversations(user_id,peer,peer_initials,peer_color) VALUES($1,$2,$3,$4) RETURNING id', [meId, 'Ruth Bernstein', 'RB', 'a3']);
+    const cid = conv.rows[0].id;
+    await c.query('INSERT INTO messages(conv_id,from_me,body) VALUES($1,$2,$3),($1,$4,$5)', [cid, false, 'Steve! Can I still borrow that folding table for Sunday? 🙏', true, 'Of course — I’ll drop it by Saturday. Save me a slice of honey cake 😉']);
+  }
+
   const np = await c.query('SELECT count(*) FROM posts');
   const nc = await c.query('SELECT count(*) FROM 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`);
+  const nu = await c.query('SELECT count(*) FROM users');
+  const ng = await c.query('SELECT count(*) FROM groups');
+  const ne = await c.query('SELECT count(*) FROM events');
+  console.log(`migrated: ${np.rows[0].count} posts, ${nc.rows[0].count} comments, ${no.rows[0].count} opportunities, ${nu.rows[0].count} users, ${ng.rows[0].count} groups, ${ne.rows[0].count} events`);
   await c.end();
 })().catch(e => { console.error(e); process.exit(1); });
diff --git a/server.js b/server.js
index 45af1ba..c6dd115 100644
--- a/server.js
+++ b/server.js
@@ -2,136 +2,358 @@
 const express = require('express');
 const fs = require('fs');
 const path = require('path');
+const cookieParser = require('cookie-parser');
 require('dotenv').config({ path: path.join(__dirname, '.env') });
 
 const app = express();
 const PORT = process.env.PORT || 3201;
 const DATA = path.join(__dirname, 'data', 'posts.json');
+const SECRET = process.env.SESSION_SECRET || 'bubbesblock-dev-secret';
 
 let pool = null;
 if (process.env.DATABASE_URL) {
   const { Pool } = require('pg');
-  pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 4 });
+  pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 6 });
   pool.on('error', e => console.error('pg pool error:', e.message));
 }
+// optional home-history module (built by la-research-agent); tolerate absence
+let homeHistory = null;
+try { homeHistory = require('./lib/home-history.js'); } catch { /* not built yet */ }
 
 app.use(express.json());
+app.use(cookieParser(SECRET));
 app.use(express.static(path.join(__dirname, 'public')));
 app.get('/healthz', (_req, res) => res.json({ ok: true, service: 'bubbesblock', db: !!pool }));
 
-// ---- JSON fallback (used if the DB is unreachable) ----
+// ---------- helpers ----------
+const q = (text, params) => pool.query(text, params);
 function jsonAll() { return JSON.parse(fs.readFileSync(DATA, 'utf8')); }
+const uid = name => 'u-' + String(name).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
 
-// ---- DB shapers (map snake_case rows -> the camelCase the front end expects) ----
-function shapePost(p, commentCount) {
-  return {
-    id: p.id, author: p.author, initials: p.initials, color: p.color,
-    address: p.address, time: p.time_label, edited: p.edited,
-    category: p.category, catColor: p.cat_color, body: p.body, photo: p.photo,
-    reactions: p.reactions, reactedBy: p.reacted_by, shares: p.shares,
-    moreComments: p.more_comments, commentCount
-  };
+async function currentUser(req) {
+  if (!pool) return null;
+  const id = req.signedCookies && req.signedCookies.bb_uid;
+  if (!id) return null;
+  const r = await q('SELECT * FROM users WHERE id=$1', [id]);
+  return r.rows[0] || null;
 }
-function shapeComment(m) {
+function setSession(res, userId) {
+  res.cookie('bb_uid', userId, { signed: true, httpOnly: true, sameSite: 'lax', maxAge: 1000 * 60 * 60 * 24 * 90 });
+}
+function need(res, code, msg) { res.status(code).json({ error: msg }); return null; }
+
+function shapePost(p, commentCount, extra = {}) {
   return {
-    author: m.author, initials: m.initials, color: m.color, nb: m.nb,
-    time: m.time_label, thanks: m.thanks, reply: m.is_reply, body: m.body
+    id: p.id, author: p.author, initials: p.initials, color: p.color, address: p.address,
+    time: p.time_label, edited: p.edited, category: p.category, catColor: p.cat_color,
+    body: p.body, photo: p.photo, reactions: p.reactions, reactedBy: p.reacted_by,
+    shares: p.shares, moreComments: p.more_comments, commentCount, ...extra
   };
 }
+const shapeComment = (m, extra = {}) => ({ id: m.id, author: m.author, initials: m.initials, color: m.color, nb: m.nb, time: m.time_label, thanks: m.thanks, reply: m.is_reply, body: m.body, ...extra });
 async function config(client) {
-  const r = await client.query("SELECT k, v FROM site_config WHERE k IN ('me','neighborhood')");
+  const r = await client.query("SELECT k,v FROM site_config WHERE k IN ('me','neighborhood','service_area','categories_for_you')");
   const o = {}; r.rows.forEach(x => (o[x.k] = x.v)); return o;
 }
+async function reactionCount(client, type, id) {
+  const r = await client.query("SELECT count(*) FROM reactions WHERE target_type=$1 AND target_id=$2", [type, String(id)]);
+  return Number(r.rows[0].count);
+}
 
-// ---- Feed ----
-app.get('/api/posts', async (_req, res) => {
+// ===================== READ: feed / post / opportunities =====================
+app.get('/api/posts', async (req, res) => {
   if (pool) {
     let client;
     try {
       client = await pool.connect();
+      const me = await currentUser(req);
       const cfg = await config(client);
       const r = await client.query(
-        `SELECT p.*, (SELECT count(*) FROM comments c WHERE c.post_id = p.id) AS live
+        `SELECT p.*, (SELECT count(*) FROM comments c WHERE c.post_id=p.id) AS live,
+                (SELECT count(*) FROM reactions x WHERE x.target_type='post' AND x.target_id=p.id) AS thanks
            FROM posts p ORDER BY p.position`);
-      const posts = r.rows.map(p => {
-        const out = shapePost(p, Number(p.live) + (p.more_comments || 0));
-        delete out.moreComments; // feed only needs the total
-        return out;
-      });
-      return res.json({ neighborhood: cfg.neighborhood, me: cfg.me, posts });
-    } catch (e) {
-      console.error('PG feed failed, JSON fallback:', e.message);
-    } finally { if (client) client.release(); }
+      let mine = new Set();
+      if (me) { const yr = await client.query("SELECT target_id FROM reactions WHERE target_type='post' AND user_id=$1", [me.id]); mine = new Set(yr.rows.map(x => x.target_id)); }
+      const posts = r.rows.map(p => shapePost(p, Number(p.live) + (p.more_comments || 0), { thanks: Number(p.thanks), youThanked: mine.has(p.id) }));
+      posts.forEach(p => delete p.moreComments);
+      return res.json({ neighborhood: cfg.neighborhood, me: cfg.me, you: me, posts });
+    } catch (e) { console.error('feed PG fail:', e.message); } finally { if (client) client.release(); }
   }
-  // fallback
-  try {
-    const d = jsonAll();
-    const posts = d.posts.map(p => ({
-      id: p.id, author: p.author, initials: p.initials, color: p.color, address: p.address,
-      time: p.time, edited: !!p.edited, category: p.category, catColor: p.catColor, body: p.body,
-      photo: p.photo, reactions: p.reactions, reactedBy: p.reactedBy, shares: p.shares,
-      commentCount: (p.comments ? p.comments.length : 0) + (p.moreComments || 0)
-    }));
-    res.json({ neighborhood: d.neighborhood, me: d.me, posts });
-  } catch (e) { res.status(500).json({ error: String(e) }); }
-});
-
-// ---- Single post + thread ----
+  try { const d = jsonAll(); res.json({ neighborhood: d.neighborhood, me: d.me, you: null,
+    posts: d.posts.map(p => ({ id: p.id, author: p.author, initials: p.initials, color: p.color, address: p.address, time: p.time, edited: !!p.edited, category: p.category, catColor: p.catColor, body: p.body, photo: p.photo, reactions: p.reactions, reactedBy: p.reactedBy, shares: p.shares, thanks: 0, commentCount: (p.comments ? p.comments.length : 0) + (p.moreComments || 0) })) }); }
+  catch (e) { res.status(500).json({ error: String(e) }); }
+});
+
 app.get('/api/posts/:id', async (req, res) => {
   if (pool) {
     let client;
     try {
       client = await pool.connect();
+      const me = await currentUser(req);
       const cfg = await config(client);
       const pr = await client.query('SELECT * FROM posts WHERE id=$1', [req.params.id]);
       if (!pr.rows.length) return res.status(404).json({ error: 'not found' });
-      const cr = await client.query(
-        'SELECT * FROM comments WHERE post_id=$1 ORDER BY position', [req.params.id]);
-      const post = shapePost(pr.rows[0], cr.rows.length + (pr.rows[0].more_comments || 0));
+      const cr = await client.query('SELECT * FROM comments WHERE post_id=$1 ORDER BY position, id', [req.params.id]);
+      const thanks = await reactionCount(client, 'post', req.params.id);
+      let youThanked = false;
+      if (me) youThanked = (await client.query("SELECT 1 FROM reactions WHERE target_type='post' AND target_id=$1 AND user_id=$2", [req.params.id, me.id])).rows.length > 0;
+      const post = shapePost(pr.rows[0], cr.rows.length, { thanks, youThanked });
       post.comments = cr.rows.map(shapeComment);
-      return res.json({ neighborhood: cfg.neighborhood, me: cfg.me, post });
-    } catch (e) {
-      console.error('PG post failed, JSON fallback:', e.message);
-    } finally { if (client) client.release(); }
+      return res.json({ neighborhood: cfg.neighborhood, me: cfg.me, you: me, post });
+    } catch (e) { console.error('post PG fail:', e.message); } finally { if (client) client.release(); }
   }
-  try {
-    const d = jsonAll();
-    const post = d.posts.find(p => p.id === req.params.id);
-    if (!post) return res.status(404).json({ error: 'not found' });
-    res.json({ neighborhood: d.neighborhood, me: d.me, post });
-  } catch (e) { res.status(500).json({ error: String(e) }); }
+  try { const d = jsonAll(); const post = d.posts.find(p => p.id === req.params.id); if (!post) return res.status(404).json({ error: 'not found' }); res.json({ neighborhood: d.neighborhood, me: d.me, you: null, post }); }
+  catch (e) { res.status(500).json({ error: String(e) }); }
+});
+
+app.get('/api/posts/:id/comments', async (req, res) => {
+  if (!pool) return res.json({ comments: [] });
+  try { const r = await q('SELECT * FROM comments WHERE post_id=$1 ORDER BY position, id', [req.params.id]); res.json({ comments: r.rows.map(c => shapeComment(c)) }); }
+  catch (e) { res.status(500).json({ error: String(e) }); }
 });
 
-// ---- Opportunity leads (Nextdoor "Opp Alerts" model) ----
-app.get('/api/opportunities', async (_req, res) => {
+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 me = await currentUser(req);
+      const cfg = await config(client);
       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(); }
+      let unlocked = new Set(), responded = new Set();
+      if (me) {
+        unlocked = new Set((await client.query('SELECT opportunity_id FROM opp_unlocks WHERE user_id=$1', [me.id])).rows.map(x => x.opportunity_id));
+        responded = new Set((await client.query('SELECT DISTINCT opportunity_id FROM opp_responses WHERE user_id=$1', [me.id])).rows.map(x => x.opportunity_id));
+      }
+      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 && !unlocked.has(o.id), youResponded: responded.has(o.id), body: o.body }));
+      return res.json({ me: cfg.me, you: me, serviceArea: cfg.service_area, categoriesForYou: cfg.categories_for_you || [], opportunities });
+    } catch (e) { console.error('opps PG fail:', 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, you: null, 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) }); }
+});
+
+// ===================== SESSION / AUTH / CLAIM =====================
+app.get('/api/session', async (req, res) => { res.json({ you: await currentUser(req) }); });
+
+// chrome bootstrap for auxiliary pages (me for avatar/rails + current session)
+app.get('/api/me', async (req, res) => {
+  let me = { name: 'BubbesBlock', initials: 'BB', color: 'a1', address: 'Beekman Ave' };
+  if (pool) { try { const r = await q("SELECT v FROM site_config WHERE k='me'"); if (r.rows.length) me = r.rows[0].v; } catch {} }
+  res.json({ me, you: await currentUser(req) });
+});
+
+app.post('/api/session/login', async (req, res) => {
+  if (!pool) return need(res, 503, 'db required');
+  const name = (req.body.name || '').trim();
+  if (!name) return need(res, 400, 'name required');
+  const id = uid(name);
+  const initials = name.split(/\s+/).map(s => s[0]).join('').slice(0, 2).toUpperCase();
+  await q(`INSERT INTO users(id,name,initials,color) VALUES($1,$2,$3,'a1') ON CONFLICT(id) DO NOTHING`, [id, name, initials]);
+  setSession(res, id);
+  res.json({ you: (await q('SELECT * FROM users WHERE id=$1', [id])).rows[0] });
+});
+app.post('/api/session/logout', (_req, res) => { res.clearCookie('bb_uid'); res.json({ ok: true }); });
+
+app.post('/api/claim', async (req, res) => {
+  if (!pool) return need(res, 503, 'db required');
+  const me = await currentUser(req); if (!me) return need(res, 401, 'sign in first');
+  const address = (req.body.address || '').trim(); if (!address) return need(res, 400, 'address required');
+  await q('UPDATE users SET address=$1, verified=true WHERE id=$2', [address, me.id]);
+  res.json({ you: (await q('SELECT * FROM users WHERE id=$1', [me.id])).rows[0] });
+});
+
+// ===================== WRITES: posts / comments / reactions =====================
+app.post('/api/posts', async (req, res) => {
+  if (!pool) return need(res, 503, 'db required');
+  const me = await currentUser(req); if (!me) return need(res, 401, 'sign in first');
+  if (!me.verified) return need(res, 403, 'claim your address to post');
+  const body = (req.body.body || '').trim(); if (!body) return need(res, 400, 'empty');
+  const category = req.body.category || '📣 Block Post';
+  const catColor = req.body.catColor || 'plum';
+  const id = 'p-' + Date.now().toString(36);
   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) }); }
+    const pos = (await q('SELECT coalesce(min(position),0)-1 AS n FROM posts')).rows[0].n; // new posts sort to top
+    await q(`INSERT INTO posts(id,position,author,initials,color,address,time_label,edited,category,cat_color,body,photo,reactions,reacted_by,shares,more_comments,source)
+             VALUES($1,$2,$3,$4,$5,$6,'just now',false,$7,$8,$9,$10,'[]'::jsonb,'',0,0,'user')`,
+      [id, pos, me.name, me.initials, me.color, me.address, category, catColor,
+       JSON.stringify(body.split(/\n\n+/).filter(Boolean)), req.body.photo ? JSON.stringify(req.body.photo) : null]);
+    res.json({ ok: true, id });
+  } catch (e) { console.error('create post fail:', e.message); need(res, 500, 'could not post'); }
+});
+
+app.post('/api/posts/:id/comments', async (req, res) => {
+  if (!pool) return need(res, 503, 'db required');
+  const me = await currentUser(req); if (!me) return need(res, 401, 'sign in first');
+  if (!me.verified) return need(res, 403, 'claim your address to reply');
+  const body = (req.body.body || '').trim(); if (!body) return need(res, 400, 'empty');
+  const pos = (await q('SELECT coalesce(max(position),-1)+1 AS n FROM comments WHERE post_id=$1', [req.params.id])).rows[0].n;
+  const r = await q(`INSERT INTO comments(post_id,position,author,initials,color,nb,time_label,thanks,is_reply,body)
+                     VALUES($1,$2,$3,$4,$5,$6,'just now',0,$7,$8) RETURNING *`,
+    [req.params.id, pos, me.name, me.initials, me.color, me.address || 'neighbor', !!req.body.reply, body]);
+  res.json({ ok: true, comment: shapeComment(r.rows[0]) });
+});
+
+// toggle a reaction (Thank/like) on a post|comment|opportunity
+app.post('/api/react', async (req, res) => {
+  if (!pool) return need(res, 503, 'db required');
+  const me = await currentUser(req); if (!me) return need(res, 401, 'sign in first');
+  const { targetType, targetId } = req.body; const kind = req.body.kind || 'thank';
+  if (!['post', 'comment', 'opportunity'].includes(targetType) || !targetId) return need(res, 400, 'bad target');
+  const ex = await q('SELECT id FROM reactions WHERE target_type=$1 AND target_id=$2 AND user_id=$3 AND kind=$4', [targetType, String(targetId), me.id, kind]);
+  let on;
+  if (ex.rows.length) { await q('DELETE FROM reactions WHERE id=$1', [ex.rows[0].id]); on = false; }
+  else { await q('INSERT INTO reactions(target_type,target_id,user_id,kind) VALUES($1,$2,$3,$4)', [targetType, String(targetId), me.id, kind]); on = true; }
+  const count = Number((await q('SELECT count(*) FROM reactions WHERE target_type=$1 AND target_id=$2', [targetType, String(targetId)])).rows[0].count);
+  res.json({ ok: true, on, count });
+});
+
+app.post('/api/posts/:id/share', async (req, res) => {
+  if (!pool) return need(res, 503, 'db required');
+  await q('UPDATE posts SET shares=shares+1 WHERE id=$1', [req.params.id]);
+  const r = await q('SELECT shares FROM posts WHERE id=$1', [req.params.id]);
+  res.json({ ok: true, shares: r.rows.length ? r.rows[0].shares : 0, url: `/p/${req.params.id}` });
+});
+
+app.post('/api/posts/:id/bookmark', async (req, res) => {
+  if (!pool) return need(res, 503, 'db required');
+  const me = await currentUser(req); if (!me) return need(res, 401, 'sign in first');
+  const ex = await q('SELECT 1 FROM bookmarks WHERE user_id=$1 AND post_id=$2', [me.id, req.params.id]);
+  let on; if (ex.rows.length) { await q('DELETE FROM bookmarks WHERE user_id=$1 AND post_id=$2', [me.id, req.params.id]); on = false; }
+  else { await q('INSERT INTO bookmarks(user_id,post_id) VALUES($1,$2)', [me.id, req.params.id]); on = true; }
+  res.json({ ok: true, on });
+});
+app.get('/api/bookmarks', async (req, res) => {
+  if (!pool) return res.json({ posts: [] });
+  const me = await currentUser(req); if (!me) return res.json({ posts: [] });
+  const r = await q(`SELECT p.* FROM bookmarks b JOIN posts p ON p.id=b.post_id WHERE b.user_id=$1 ORDER BY b.created_at DESC`, [me.id]);
+  res.json({ posts: r.rows.map(p => shapePost(p, 0)) });
+});
+
+// ===================== OPPORTUNITIES: respond / unlock =====================
+app.post('/api/opportunities/:id/respond', async (req, res) => {
+  if (!pool) return need(res, 503, 'db required');
+  const me = await currentUser(req); if (!me) return need(res, 401, 'sign in first');
+  if (!me.verified) return need(res, 403, 'claim your address to respond');
+  const body = (req.body.body || '').trim(); if (!body) return need(res, 400, 'empty');
+  await q('INSERT INTO opp_responses(opportunity_id,user_id,body) VALUES($1,$2,$3)', [req.params.id, me.id, body]);
+  await q('UPDATE opportunities SET responses=responses+1 WHERE id=$1', [req.params.id]);
+  const r = await q('SELECT responses FROM opportunities WHERE id=$1', [req.params.id]);
+  res.json({ ok: true, responses: r.rows.length ? r.rows[0].responses : 0 });
+});
+app.post('/api/opportunities/:id/unlock', async (req, res) => {
+  if (!pool) return need(res, 503, 'db required');
+  const me = await currentUser(req); if (!me) return need(res, 401, 'sign in first');
+  await q('INSERT INTO opp_unlocks(opportunity_id,user_id) VALUES($1,$2) ON CONFLICT DO NOTHING', [req.params.id, me.id]);
+  res.json({ ok: true, unlocked: true });
+});
+
+// ===================== SEARCH =====================
+app.get('/api/search', async (req, res) => {
+  const term = (req.query.q || '').trim(); if (!term) return res.json({ posts: [], opportunities: [], neighbors: [] });
+  if (!pool) return res.json({ posts: [], opportunities: [], neighbors: [] });
+  const like = '%' + term + '%';
+  const posts = (await q(`SELECT id,author,category,body FROM posts WHERE body::text ILIKE $1 OR author ILIKE $1 OR category ILIKE $1 ORDER BY position LIMIT 8`, [like])).rows;
+  const opps = (await q(`SELECT id,neighbor,category,body FROM opportunities WHERE body ILIKE $1 OR category ILIKE $1 OR neighbor ILIKE $1 ORDER BY position LIMIT 8`, [like])).rows;
+  const neighbors = (await q(`SELECT id,name,initials,color,address FROM users WHERE name ILIKE $1 LIMIT 8`, [like])).rows;
+  res.json({ posts, opportunities: opps, neighbors });
+});
+
+// ===================== NOTIFICATIONS / INBOX =====================
+app.get('/api/notifications', async (req, res) => {
+  if (!pool) return res.json({ items: [], unread: 0 });
+  const me = await currentUser(req); if (!me) return res.json({ items: [], unread: 0 });
+  const r = await q('SELECT * FROM notifications WHERE user_id=$1 ORDER BY created_at DESC LIMIT 30', [me.id]);
+  const unread = r.rows.filter(n => !n.read).length;
+  res.json({ items: r.rows, unread });
+});
+app.post('/api/notifications/read', async (req, res) => {
+  if (!pool) return res.json({ ok: true });
+  const me = await currentUser(req); if (!me) return res.json({ ok: true });
+  await q('UPDATE notifications SET read=true WHERE user_id=$1', [me.id]);
+  res.json({ ok: true });
+});
+app.get('/api/inbox', async (req, res) => {
+  if (!pool) return res.json({ conversations: [] });
+  const me = await currentUser(req); if (!me) return res.json({ conversations: [] });
+  const r = await q(`SELECT c.*, (SELECT body FROM messages m WHERE m.conv_id=c.id ORDER BY id DESC LIMIT 1) AS last
+                     FROM conversations c WHERE c.user_id=$1 ORDER BY c.id DESC`, [me.id]);
+  res.json({ conversations: r.rows });
+});
+app.get('/api/inbox/:id', async (req, res) => {
+  if (!pool) return res.json({ messages: [] });
+  const me = await currentUser(req); if (!me) return res.json({ messages: [] });
+  const r = await q('SELECT m.* FROM messages m JOIN conversations c ON c.id=m.conv_id WHERE m.conv_id=$1 AND c.user_id=$2 ORDER BY m.id', [req.params.id, me.id]);
+  res.json({ messages: r.rows });
+});
+app.post('/api/inbox/:id/messages', async (req, res) => {
+  if (!pool) return need(res, 503, 'db required');
+  const me = await currentUser(req); if (!me) return need(res, 401, 'sign in first');
+  const body = (req.body.body || '').trim(); if (!body) return need(res, 400, 'empty');
+  await q('INSERT INTO messages(conv_id,from_me,body) VALUES($1,true,$2)', [req.params.id, body]);
+  res.json({ ok: true });
 });
 
+// ===================== EVENTS / GROUPS =====================
+app.get('/api/events', async (req, res) => {
+  if (!pool) return res.json({ events: [] });
+  const me = await currentUser(req);
+  const r = await q('SELECT * FROM events ORDER BY position', []);
+  let going = new Set();
+  if (me) going = new Set((await q('SELECT event_id FROM event_rsvps WHERE user_id=$1', [me.id])).rows.map(x => x.event_id));
+  res.json({ you: me, events: r.rows.map(e => ({ ...e, youGoing: going.has(e.id) })) });
+});
+app.post('/api/events/:id/rsvp', async (req, res) => {
+  if (!pool) return need(res, 503, 'db required');
+  const me = await currentUser(req); if (!me) return need(res, 401, 'sign in first');
+  const ex = await q('SELECT 1 FROM event_rsvps WHERE event_id=$1 AND user_id=$2', [req.params.id, me.id]);
+  let going; if (ex.rows.length) { await q('DELETE FROM event_rsvps WHERE event_id=$1 AND user_id=$2', [req.params.id, me.id]); await q('UPDATE events SET going=greatest(going-1,0) WHERE id=$1', [req.params.id]); going = false; }
+  else { await q('INSERT INTO event_rsvps(event_id,user_id) VALUES($1,$2)', [req.params.id, me.id]); await q('UPDATE events SET going=going+1 WHERE id=$1', [req.params.id]); going = true; }
+  const r = await q('SELECT going FROM events WHERE id=$1', [req.params.id]);
+  res.json({ ok: true, going, count: r.rows[0].going });
+});
+app.get('/api/groups', async (req, res) => {
+  if (!pool) return res.json({ groups: [] });
+  const me = await currentUser(req);
+  const r = await q('SELECT * FROM groups ORDER BY position', []);
+  let joined = new Set();
+  if (me) joined = new Set((await q('SELECT slug FROM group_members WHERE user_id=$1', [me.id])).rows.map(x => x.slug));
+  res.json({ you: me, groups: r.rows.map(g => ({ ...g, youJoined: joined.has(g.slug) })) });
+});
+app.get('/api/groups/:slug', async (req, res) => {
+  if (!pool) return res.status(404).json({ error: 'not found' });
+  const r = await q('SELECT * FROM groups WHERE slug=$1', [req.params.slug]);
+  if (!r.rows.length) return res.status(404).json({ error: 'not found' });
+  res.json({ group: r.rows[0] });
+});
+app.post('/api/groups/:slug/join', async (req, res) => {
+  if (!pool) return need(res, 503, 'db required');
+  const me = await currentUser(req); if (!me) return need(res, 401, 'sign in first');
+  const ex = await q('SELECT 1 FROM group_members WHERE slug=$1 AND user_id=$2', [req.params.slug, me.id]);
+  let joined; if (ex.rows.length) { await q('DELETE FROM group_members WHERE slug=$1 AND user_id=$2', [req.params.slug, me.id]); await q('UPDATE groups SET members=greatest(members-1,0) WHERE slug=$1', [req.params.slug]); joined = false; }
+  else { await q('INSERT INTO group_members(slug,user_id) VALUES($1,$2)', [req.params.slug, me.id]); await q('UPDATE groups SET members=members+1 WHERE slug=$1', [req.params.slug]); joined = true; }
+  const r = await q('SELECT members FROM groups WHERE slug=$1', [req.params.slug]);
+  res.json({ ok: true, joined, members: r.rows[0].members });
+});
+
+// ===================== HOME HISTORY (wholivedthere; module optional) =====================
+app.get('/api/home-history', async (req, res) => {
+  const address = (req.query.address || '').trim(); if (!address) return need(res, 400, 'address required');
+  if (homeHistory && homeHistory.getHomeHistory) {
+    try { return res.json(await homeHistory.getHomeHistory(address)); } catch (e) { console.error('home-history fail:', e.message); }
+  }
+  res.json({ address, source: 'unavailable', summary: 'Home history coming soon.', link: `https://wholivedthere.com/?address=${encodeURIComponent(address)}` });
+});
+
+// ===================== page routes =====================
 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')));
+for (const r of ['events', 'groups', 'bookmarks', 'search', 'inbox', 'notifications']) {
+  app.get('/' + r, (_req, res) => res.sendFile(path.join(__dirname, 'public', r + '.html')));
+}
+for (const r of ['about', 'guidelines', 'privacy', 'help']) {
+  app.get('/' + r, (_req, res) => res.sendFile(path.join(__dirname, 'public', 'content.html')));
+}
 
 app.listen(PORT, () => console.log(`BubbesBlock on http://localhost:${PORT} (db=${!!pool})`));

← 698091f Add home-history module with LA County parcel + LADBS permit  ·  back to Bubbesblock  ·  Security hardening (code-reviewer pass): server-side XSS esc 977e8e6 →