← back to Permit Radar
permit-radar: full permit directory + lead funnel + admin — working prototype
de185e87da5fc00ecec2c1e7db05f1ca74263d46 · 2026-07-15 17:02:40 -0700 · Steve
Files touched
A data/lead_requests.jsonA data/permits.jsonD data/products.jsonA data/subscribers.jsonA public/admin.htmlM public/index.htmlA public/permit.htmlA scripts/ingest-stub.jsM server.js
Diff
commit de185e87da5fc00ecec2c1e7db05f1ca74263d46
Author: Steve <steve@designerwallcoverings.com>
Date: Wed Jul 15 17:02:40 2026 -0700
permit-radar: full permit directory + lead funnel + admin — working prototype
---
data/lead_requests.json | 1 +
data/permits.json | 952 ++++++++++++++++++++++++++++++++++++++++++++++++
data/products.json | 6 -
data/subscribers.json | 1 +
public/admin.html | 241 ++++++++++++
public/index.html | 605 +++++++++++++++++++++++++++---
public/permit.html | 225 ++++++++++++
scripts/ingest-stub.js | 150 ++++++++
server.js | 292 +++++++++++++--
9 files changed, 2385 insertions(+), 88 deletions(-)
diff --git a/data/lead_requests.json b/data/lead_requests.json
new file mode 100644
index 0000000..fe51488
--- /dev/null
+++ b/data/lead_requests.json
@@ -0,0 +1 @@
+[]
diff --git a/data/permits.json b/data/permits.json
new file mode 100644
index 0000000..79d2ff2
--- /dev/null
+++ b/data/permits.json
@@ -0,0 +1,952 @@
+[
+ {
+ "id": "PR-2024-001",
+ "address": "1425 Oak St",
+ "city": "Los Angeles",
+ "jurisdiction": "Los Angeles County",
+ "state": "CA",
+ "zip": "90041",
+ "date_filed": "2024-11-15",
+ "permit_type": "kitchen_remodel",
+ "permit_type_label": "Kitchen Remodel",
+ "valuation": 62000,
+ "contractor": "Pacific Coast Construction",
+ "contractor_license": "CA-LIC-882341",
+ "status": "approved",
+ "description": "Complete kitchen remodel including new cabinets, countertops, appliances, plumbing and electrical updates. Demo existing kitchen, install new layout.",
+ "bedrooms": 3,
+ "sqft_affected": 280,
+ "created_at": "2024-11-15T08:23:11Z"
+ },
+ {
+ "id": "PR-2024-002",
+ "address": "890 Willow Ave",
+ "city": "Pasadena",
+ "jurisdiction": "Los Angeles County",
+ "state": "CA",
+ "zip": "91104",
+ "date_filed": "2024-11-20",
+ "permit_type": "addition",
+ "permit_type_label": "Room Addition",
+ "valuation": 185000,
+ "contractor": "Apex Home Builders",
+ "contractor_license": "CA-LIC-774821",
+ "status": "pending",
+ "description": "400 sq ft room addition on rear of property. New family room and full bathroom. Foundation, framing, roofing, electrical, HVAC, plumbing.",
+ "bedrooms": 3,
+ "sqft_affected": 400,
+ "created_at": "2024-11-20T10:45:00Z"
+ },
+ {
+ "id": "PR-2024-003",
+ "address": "312 Magnolia Blvd",
+ "city": "Burbank",
+ "jurisdiction": "Los Angeles County",
+ "state": "CA",
+ "zip": "91502",
+ "date_filed": "2024-11-22",
+ "permit_type": "bath_remodel",
+ "permit_type_label": "Bathroom Remodel",
+ "valuation": 28500,
+ "contractor": "",
+ "contractor_license": "",
+ "status": "approved",
+ "description": "Owner-builder bathroom remodel. New tile, vanity, fixtures, updated plumbing supply lines and drain.",
+ "bedrooms": 2,
+ "sqft_affected": 95,
+ "created_at": "2024-11-22T14:10:00Z"
+ },
+ {
+ "id": "PR-2024-004",
+ "address": "5501 Venice Blvd",
+ "city": "Culver City",
+ "jurisdiction": "Los Angeles County",
+ "state": "CA",
+ "zip": "90232",
+ "date_filed": "2024-11-28",
+ "permit_type": "reroof",
+ "permit_type_label": "Reroof",
+ "valuation": 18000,
+ "contractor": "SoCal Roofing Solutions",
+ "contractor_license": "CA-LIC-665123",
+ "status": "approved",
+ "description": "Complete tear-off and reroof. Install Class A composition shingles over new 30-lb felt. 2,400 sq ft.",
+ "bedrooms": 4,
+ "sqft_affected": 2400,
+ "created_at": "2024-11-28T09:00:00Z"
+ },
+ {
+ "id": "PR-2024-005",
+ "address": "7720 Sunset Dr",
+ "city": "West Hollywood",
+ "jurisdiction": "Los Angeles County",
+ "state": "CA",
+ "zip": "90046",
+ "date_filed": "2024-12-03",
+ "permit_type": "full_remodel",
+ "permit_type_label": "Full Interior Remodel",
+ "valuation": 410000,
+ "contractor": "Urban Renovations Group",
+ "contractor_license": "CA-LIC-991234",
+ "status": "pending",
+ "description": "Whole-house gut remodel. Open floor plan conversion, new kitchen, 2 bath remodels, flooring throughout, electrical panel upgrade, new HVAC.",
+ "bedrooms": 3,
+ "sqft_affected": 1850,
+ "created_at": "2024-12-03T11:22:00Z"
+ },
+ {
+ "id": "PR-2024-006",
+ "address": "44 Hillcrest Rd",
+ "city": "San Marino",
+ "jurisdiction": "Los Angeles County",
+ "state": "CA",
+ "zip": "91108",
+ "date_filed": "2024-12-05",
+ "permit_type": "adu",
+ "permit_type_label": "ADU / Accessory Dwelling Unit",
+ "valuation": 225000,
+ "contractor": "SoCal ADU Specialists",
+ "contractor_license": "CA-LIC-554892",
+ "status": "approved",
+ "description": "Detached ADU construction, 640 sq ft. Full kitchen, bath, bedroom. Solar-ready. Separate utility meters.",
+ "bedrooms": 1,
+ "sqft_affected": 640,
+ "created_at": "2024-12-05T15:30:00Z"
+ },
+ {
+ "id": "PR-2024-007",
+ "address": "2201 Palm Ln",
+ "city": "Santa Monica",
+ "jurisdiction": "Los Angeles County",
+ "state": "CA",
+ "zip": "90404",
+ "date_filed": "2024-12-08",
+ "permit_type": "kitchen_remodel",
+ "permit_type_label": "Kitchen Remodel",
+ "valuation": 95000,
+ "contractor": "Shoreline Design Build",
+ "contractor_license": "CA-LIC-773401",
+ "status": "approved",
+ "description": "High-end kitchen remodel. Custom cabinetry, quartzite counters, Wolf appliances, new island, updated electrical and plumbing.",
+ "bedrooms": 4,
+ "sqft_affected": 360,
+ "created_at": "2024-12-08T09:55:00Z"
+ },
+ {
+ "id": "PR-2024-008",
+ "address": "1803 Elm St",
+ "city": "Glendale",
+ "jurisdiction": "Los Angeles County",
+ "state": "CA",
+ "zip": "91205",
+ "date_filed": "2024-12-10",
+ "permit_type": "addition",
+ "permit_type_label": "Room Addition",
+ "valuation": 145000,
+ "contractor": "Ararat Construction",
+ "contractor_license": "CA-LIC-884561",
+ "status": "pending",
+ "description": "350 sq ft master suite addition. Bedroom, walk-in closet, full ensuite bath. Ties into existing foundation, matching exterior.",
+ "bedrooms": 3,
+ "sqft_affected": 350,
+ "created_at": "2024-12-10T13:15:00Z"
+ },
+ {
+ "id": "PR-2024-009",
+ "address": "6642 Harbor View Ct",
+ "city": "Long Beach",
+ "jurisdiction": "Los Angeles County",
+ "state": "CA",
+ "zip": "90803",
+ "date_filed": "2024-12-12",
+ "permit_type": "bath_remodel",
+ "permit_type_label": "Bathroom Remodel",
+ "valuation": 42000,
+ "contractor": "Harbor Home Pros",
+ "contractor_license": "CA-LIC-662019",
+ "status": "approved",
+ "description": "Primary bath luxury remodel. Freestanding tub, heated tile floors, frameless shower enclosure, dual vanity, smart mirrors.",
+ "bedrooms": 4,
+ "sqft_affected": 140,
+ "created_at": "2024-12-12T08:45:00Z"
+ },
+ {
+ "id": "PR-2024-010",
+ "address": "903 El Camino Real",
+ "city": "Palo Alto",
+ "jurisdiction": "Santa Clara County",
+ "state": "CA",
+ "zip": "94301",
+ "date_filed": "2024-11-14",
+ "permit_type": "full_remodel",
+ "permit_type_label": "Full Interior Remodel",
+ "valuation": 680000,
+ "contractor": "Bay Area Design Build",
+ "contractor_license": "CA-LIC-443210",
+ "status": "approved",
+ "description": "Complete gut remodel of 2,400 sq ft home. Open kitchen/living, 3 bath remodels, smart home system, radiant heating, solar + battery.",
+ "bedrooms": 4,
+ "sqft_affected": 2400,
+ "created_at": "2024-11-14T10:00:00Z"
+ },
+ {
+ "id": "PR-2024-011",
+ "address": "2120 Forest Ave",
+ "city": "San Jose",
+ "jurisdiction": "Santa Clara County",
+ "state": "CA",
+ "zip": "95128",
+ "date_filed": "2024-11-18",
+ "permit_type": "kitchen_remodel",
+ "permit_type_label": "Kitchen Remodel",
+ "valuation": 78000,
+ "contractor": "Silicon Valley Renovations",
+ "contractor_license": "CA-LIC-557234",
+ "status": "approved",
+ "description": "Kitchen expansion and remodel. Removed wall to dining room, new island, quartz counters, custom cabinets, Bosch appliance package.",
+ "bedrooms": 3,
+ "sqft_affected": 310,
+ "created_at": "2024-11-18T11:30:00Z"
+ },
+ {
+ "id": "PR-2024-012",
+ "address": "540 Creekside Dr",
+ "city": "Sunnyvale",
+ "jurisdiction": "Santa Clara County",
+ "state": "CA",
+ "zip": "94087",
+ "date_filed": "2024-11-25",
+ "permit_type": "adu",
+ "permit_type_label": "ADU / Accessory Dwelling Unit",
+ "valuation": 195000,
+ "contractor": "NextHome Build",
+ "contractor_license": "CA-LIC-664578",
+ "status": "pending",
+ "description": "Garage conversion ADU, 480 sq ft. Full bath, kitchen, studio layout. Insulation, electrical upgrade, mini-split HVAC.",
+ "bedrooms": 1,
+ "sqft_affected": 480,
+ "created_at": "2024-11-25T14:00:00Z"
+ },
+ {
+ "id": "PR-2024-013",
+ "address": "7801 Mission Hills Dr",
+ "city": "San Diego",
+ "jurisdiction": "San Diego County",
+ "state": "CA",
+ "zip": "92120",
+ "date_filed": "2024-12-01",
+ "permit_type": "full_remodel",
+ "permit_type_label": "Full Interior Remodel",
+ "valuation": 320000,
+ "contractor": "SoCal Premier Builders",
+ "contractor_license": "CA-LIC-778820",
+ "status": "approved",
+ "description": "1,800 sq ft complete remodel. Kitchen, 2 baths, all new flooring, windows, doors, interior painting, electrical updates.",
+ "bedrooms": 3,
+ "sqft_affected": 1800,
+ "created_at": "2024-12-01T09:00:00Z"
+ },
+ {
+ "id": "PR-2024-014",
+ "address": "441 Coronado Ave",
+ "city": "Coronado",
+ "jurisdiction": "San Diego County",
+ "state": "CA",
+ "zip": "92118",
+ "date_filed": "2024-12-06",
+ "permit_type": "addition",
+ "permit_type_label": "Room Addition",
+ "valuation": 520000,
+ "contractor": "Coronado Custom Homes",
+ "contractor_license": "CA-LIC-889933",
+ "status": "pending",
+ "description": "Two-story addition. Ground floor: expanded kitchen and living area. Second floor: new primary suite with ocean views and spa bath.",
+ "bedrooms": 4,
+ "sqft_affected": 900,
+ "created_at": "2024-12-06T12:00:00Z"
+ },
+ {
+ "id": "PR-2024-015",
+ "address": "1102 Lemon Ave",
+ "city": "Escondido",
+ "jurisdiction": "San Diego County",
+ "state": "CA",
+ "zip": "92025",
+ "date_filed": "2024-12-09",
+ "permit_type": "reroof",
+ "permit_type_label": "Reroof",
+ "valuation": 14500,
+ "contractor": "Sun Country Roofing",
+ "contractor_license": "CA-LIC-331028",
+ "status": "approved",
+ "description": "Full reroof, 1,900 sq ft. Dimensional shingles, new underlayment, replace valley flashing.",
+ "bedrooms": 3,
+ "sqft_affected": 1900,
+ "created_at": "2024-12-09T07:30:00Z"
+ },
+ {
+ "id": "PR-2024-016",
+ "address": "309 Redwood Ct",
+ "city": "Berkeley",
+ "jurisdiction": "Alameda County",
+ "state": "CA",
+ "zip": "94705",
+ "date_filed": "2024-11-19",
+ "permit_type": "kitchen_remodel",
+ "permit_type_label": "Kitchen Remodel",
+ "valuation": 88000,
+ "contractor": "East Bay Remodel Co",
+ "contractor_license": "CA-LIC-445621",
+ "status": "approved",
+ "description": "Craftsman kitchen remodel to match historic character. Custom painted shaker cabinets, butcher block island, farmhouse sink, vintage-style hardware.",
+ "bedrooms": 3,
+ "sqft_affected": 290,
+ "created_at": "2024-11-19T09:20:00Z"
+ },
+ {
+ "id": "PR-2024-017",
+ "address": "1500 Telegraph Ave",
+ "city": "Oakland",
+ "jurisdiction": "Alameda County",
+ "state": "CA",
+ "zip": "94612",
+ "date_filed": "2024-11-29",
+ "permit_type": "bath_remodel",
+ "permit_type_label": "Bathroom Remodel",
+ "valuation": 33000,
+ "contractor": "Bay Tile & Bath",
+ "contractor_license": "CA-LIC-556709",
+ "status": "approved",
+ "description": "Master bath remodel. Heated tile floors, walk-in shower with rainhead, soaking tub, floating vanity.",
+ "bedrooms": 3,
+ "sqft_affected": 110,
+ "created_at": "2024-11-29T10:00:00Z"
+ },
+ {
+ "id": "PR-2024-018",
+ "address": "8803 Fremont Blvd",
+ "city": "Fremont",
+ "jurisdiction": "Alameda County",
+ "state": "CA",
+ "zip": "94538",
+ "date_filed": "2024-12-04",
+ "permit_type": "adu",
+ "permit_type_label": "ADU / Accessory Dwelling Unit",
+ "valuation": 165000,
+ "contractor": "Bay ADU Builders",
+ "contractor_license": "CA-LIC-664412",
+ "status": "pending",
+ "description": "New detached ADU, 500 sq ft. One bedroom, full bath, kitchenette. Separate entrance and utility meter.",
+ "bedrooms": 1,
+ "sqft_affected": 500,
+ "created_at": "2024-12-04T13:40:00Z"
+ },
+ {
+ "id": "PR-2024-019",
+ "address": "221 Maple Way",
+ "city": "Scottsdale",
+ "jurisdiction": "Maricopa County",
+ "state": "AZ",
+ "zip": "85251",
+ "date_filed": "2024-11-21",
+ "permit_type": "full_remodel",
+ "permit_type_label": "Full Interior Remodel",
+ "valuation": 275000,
+ "contractor": "Desert Modern Construction",
+ "contractor_license": "AZ-LIC-220183",
+ "status": "approved",
+ "description": "Modern desert remodel. Open plan kitchen-living, 2 bath remodels, new HVAC, pool deck expansion, outdoor kitchen rough-in.",
+ "bedrooms": 4,
+ "sqft_affected": 2100,
+ "created_at": "2024-11-21T11:00:00Z"
+ },
+ {
+ "id": "PR-2024-020",
+ "address": "5500 Saguaro Ln",
+ "city": "Tempe",
+ "jurisdiction": "Maricopa County",
+ "state": "AZ",
+ "zip": "85282",
+ "date_filed": "2024-11-26",
+ "permit_type": "kitchen_remodel",
+ "permit_type_label": "Kitchen Remodel",
+ "valuation": 55000,
+ "contractor": "Sun State Remodelers",
+ "contractor_license": "AZ-LIC-331840",
+ "status": "approved",
+ "description": "Kitchen remodel and layout change. Open to living area, quartz counters, soft-close cabinets, under-cabinet lighting, gas line for range.",
+ "bedrooms": 3,
+ "sqft_affected": 240,
+ "created_at": "2024-11-26T08:15:00Z"
+ },
+ {
+ "id": "PR-2024-021",
+ "address": "1234 Cactus Rd",
+ "city": "Mesa",
+ "jurisdiction": "Maricopa County",
+ "state": "AZ",
+ "zip": "85205",
+ "date_filed": "2024-12-02",
+ "permit_type": "addition",
+ "permit_type_label": "Room Addition",
+ "valuation": 105000,
+ "contractor": "Mesa Home Builders",
+ "contractor_license": "AZ-LIC-447291",
+ "status": "pending",
+ "description": "250 sq ft sunroom addition on south side. Insulated slab, vinyl windows, mini-split, connects to main living area.",
+ "bedrooms": 3,
+ "sqft_affected": 250,
+ "created_at": "2024-12-02T14:20:00Z"
+ },
+ {
+ "id": "PR-2024-022",
+ "address": "892 Pinecrest Ave",
+ "city": "Flagstaff",
+ "jurisdiction": "Coconino County",
+ "state": "AZ",
+ "zip": "86001",
+ "date_filed": "2024-12-07",
+ "permit_type": "reroof",
+ "permit_type_label": "Reroof",
+ "valuation": 22000,
+ "contractor": "Mountain Roofing Pros",
+ "contractor_license": "AZ-LIC-559018",
+ "status": "approved",
+ "description": "Full tear-off reroof. Snow-rated metal standing seam panels, ice-and-water shield, 2,800 sq ft mountain property.",
+ "bedrooms": 4,
+ "sqft_affected": 2800,
+ "created_at": "2024-12-07T09:10:00Z"
+ },
+ {
+ "id": "PR-2024-023",
+ "address": "10 Lakeview Terr",
+ "city": "Chicago",
+ "jurisdiction": "Cook County",
+ "state": "IL",
+ "zip": "60657",
+ "date_filed": "2024-11-13",
+ "permit_type": "kitchen_remodel",
+ "permit_type_label": "Kitchen Remodel",
+ "valuation": 71000,
+ "contractor": "Windy City Renovations",
+ "contractor_license": "IL-LIC-334120",
+ "status": "approved",
+ "description": "Gut kitchen remodel in vintage greystome condo. Custom millwork, Thermador appliances, marble herringbone backsplash, concealed range hood.",
+ "bedrooms": 2,
+ "sqft_affected": 250,
+ "created_at": "2024-11-13T10:30:00Z"
+ },
+ {
+ "id": "PR-2024-024",
+ "address": "4411 N Clark St",
+ "city": "Chicago",
+ "jurisdiction": "Cook County",
+ "state": "IL",
+ "zip": "60640",
+ "date_filed": "2024-11-30",
+ "permit_type": "addition",
+ "permit_type_label": "Room Addition",
+ "valuation": 315000,
+ "contractor": "Lakeside Build Group",
+ "contractor_license": "IL-LIC-556034",
+ "status": "pending",
+ "description": "Rear 2-story addition. New family room + mudroom at grade, primary suite above. Tuckpointing to match existing brick.",
+ "bedrooms": 3,
+ "sqft_affected": 600,
+ "created_at": "2024-11-30T12:45:00Z"
+ },
+ {
+ "id": "PR-2024-025",
+ "address": "820 Sherman Ave",
+ "city": "Evanston",
+ "jurisdiction": "Cook County",
+ "state": "IL",
+ "zip": "60202",
+ "date_filed": "2024-12-11",
+ "permit_type": "bath_remodel",
+ "permit_type_label": "Bathroom Remodel",
+ "valuation": 38500,
+ "contractor": "North Shore Baths",
+ "contractor_license": "IL-LIC-778823",
+ "status": "approved",
+ "description": "Complete master bath remodel. Radiant floor heat, custom tile shower, soaking tub, dual marble vanity.",
+ "bedrooms": 4,
+ "sqft_affected": 130,
+ "created_at": "2024-12-11T11:00:00Z"
+ },
+ {
+ "id": "PR-2024-026",
+ "address": "3302 W 5th Ave",
+ "city": "Austin",
+ "jurisdiction": "Travis County",
+ "state": "TX",
+ "zip": "78703",
+ "date_filed": "2024-11-16",
+ "permit_type": "full_remodel",
+ "permit_type_label": "Full Interior Remodel",
+ "valuation": 295000,
+ "contractor": "ATX Renovation Co",
+ "contractor_license": "TX-LIC-334871",
+ "status": "approved",
+ "description": "Full interior remodel, mid-century modern home. Open floor plan, custom kitchen, 2 new baths, polished concrete floors, clerestory windows.",
+ "bedrooms": 3,
+ "sqft_affected": 1750,
+ "created_at": "2024-11-16T14:00:00Z"
+ },
+ {
+ "id": "PR-2024-027",
+ "address": "9910 Balcones Dr",
+ "city": "Austin",
+ "jurisdiction": "Travis County",
+ "state": "TX",
+ "zip": "78759",
+ "date_filed": "2024-11-27",
+ "permit_type": "kitchen_remodel",
+ "permit_type_label": "Kitchen Remodel",
+ "valuation": 68000,
+ "contractor": "Hill Country Builders",
+ "contractor_license": "TX-LIC-558720",
+ "status": "pending",
+ "description": "Kitchen remodel. Waterfall island, leathered granite, commercial range, pot-filler, wine fridge, new recessed lighting plan.",
+ "bedrooms": 4,
+ "sqft_affected": 320,
+ "created_at": "2024-11-27T09:30:00Z"
+ },
+ {
+ "id": "PR-2024-028",
+ "address": "401 Lamar Blvd",
+ "city": "Round Rock",
+ "jurisdiction": "Williamson County",
+ "state": "TX",
+ "zip": "78664",
+ "date_filed": "2024-12-13",
+ "permit_type": "adu",
+ "permit_type_label": "ADU / Accessory Dwelling Unit",
+ "valuation": 155000,
+ "contractor": "Central TX ADU Co",
+ "contractor_license": "TX-LIC-667041",
+ "status": "approved",
+ "description": "Detached garage conversion to ADU, 520 sq ft. Studio plan, full bath, kitchenette. New electrical service, spray foam insulation, mini-split.",
+ "bedrooms": 1,
+ "sqft_affected": 520,
+ "created_at": "2024-12-13T10:10:00Z"
+ },
+ {
+ "id": "PR-2024-029",
+ "address": "1889 Royal Ln",
+ "city": "Dallas",
+ "jurisdiction": "Dallas County",
+ "state": "TX",
+ "zip": "75229",
+ "date_filed": "2024-11-23",
+ "permit_type": "full_remodel",
+ "permit_type_label": "Full Interior Remodel",
+ "valuation": 440000,
+ "contractor": "Magnolia Design Build",
+ "contractor_license": "TX-LIC-882019",
+ "status": "approved",
+ "description": "Luxury remodel, 2,600 sq ft. Designer kitchen, 3 bath remodels, primary suite expansion, new hardwoods, smart home integration.",
+ "bedrooms": 4,
+ "sqft_affected": 2600,
+ "created_at": "2024-11-23T11:45:00Z"
+ },
+ {
+ "id": "PR-2024-030",
+ "address": "5550 Lemmon Ave",
+ "city": "Dallas",
+ "jurisdiction": "Dallas County",
+ "state": "TX",
+ "zip": "75209",
+ "date_filed": "2024-12-14",
+ "permit_type": "bath_remodel",
+ "permit_type_label": "Bathroom Remodel",
+ "valuation": 52000,
+ "contractor": "Dallas Bath Studio",
+ "contractor_license": "TX-LIC-773920",
+ "status": "approved",
+ "description": "Primary bath remodel. Walk-in steam shower, sculptural soaking tub, book-matched marble walls, heated floors, lighted niche shelving.",
+ "bedrooms": 4,
+ "sqft_affected": 175,
+ "created_at": "2024-12-14T08:00:00Z"
+ },
+ {
+ "id": "PR-2024-031",
+ "address": "724 NW 28th Ave",
+ "city": "Portland",
+ "jurisdiction": "Multnomah County",
+ "state": "OR",
+ "zip": "97210",
+ "date_filed": "2024-11-17",
+ "permit_type": "kitchen_remodel",
+ "permit_type_label": "Kitchen Remodel",
+ "valuation": 82000,
+ "contractor": "PDX Design Build",
+ "contractor_license": "OR-LIC-445231",
+ "status": "approved",
+ "description": "Craftsman kitchen remodel. Painted cabinets, soapstone counters, La Cornue range, butlers pantry addition, natural wood accents.",
+ "bedrooms": 3,
+ "sqft_affected": 275,
+ "created_at": "2024-11-17T10:00:00Z"
+ },
+ {
+ "id": "PR-2024-032",
+ "address": "2110 Hawthorne Blvd",
+ "city": "Portland",
+ "jurisdiction": "Multnomah County",
+ "state": "OR",
+ "zip": "97214",
+ "date_filed": "2024-11-24",
+ "permit_type": "addition",
+ "permit_type_label": "Room Addition",
+ "valuation": 210000,
+ "contractor": "Rose City Builders",
+ "contractor_license": "OR-LIC-556840",
+ "status": "pending",
+ "description": "Rear addition, 500 sq ft. Ground floor: expanded dining and mudroom. Second floor: two new bedrooms with shared bath.",
+ "bedrooms": 2,
+ "sqft_affected": 500,
+ "created_at": "2024-11-24T13:00:00Z"
+ },
+ {
+ "id": "PR-2024-033",
+ "address": "8030 SE Division St",
+ "city": "Portland",
+ "jurisdiction": "Multnomah County",
+ "state": "OR",
+ "zip": "97206",
+ "date_filed": "2024-12-16",
+ "permit_type": "reroof",
+ "permit_type_label": "Reroof",
+ "valuation": 16500,
+ "contractor": "Pacific Northwest Roofing",
+ "contractor_license": "OR-LIC-663012",
+ "status": "approved",
+ "description": "Complete reroof with Owens Corning Duration shingles. Ice dam protection, new ridge cap, replace all pipe flashings.",
+ "bedrooms": 3,
+ "sqft_affected": 2100,
+ "created_at": "2024-12-16T08:30:00Z"
+ },
+ {
+ "id": "PR-2024-034",
+ "address": "4450 Meridian Ave N",
+ "city": "Seattle",
+ "jurisdiction": "King County",
+ "state": "WA",
+ "zip": "98103",
+ "date_filed": "2024-11-12",
+ "permit_type": "full_remodel",
+ "permit_type_label": "Full Interior Remodel",
+ "valuation": 580000,
+ "contractor": "Emerald City Renovations",
+ "contractor_license": "WA-LIC-334870",
+ "status": "approved",
+ "description": "Complete 2,300 sq ft remodel, 1940s craftsman. Structural updates, new kitchen, 2 bath remodels, primary suite, smart home, new windows.",
+ "bedrooms": 4,
+ "sqft_affected": 2300,
+ "created_at": "2024-11-12T09:00:00Z"
+ },
+ {
+ "id": "PR-2024-035",
+ "address": "1610 Queen Anne Ave N",
+ "city": "Seattle",
+ "jurisdiction": "King County",
+ "state": "WA",
+ "zip": "98109",
+ "date_filed": "2024-11-22",
+ "permit_type": "adu",
+ "permit_type_label": "ADU / Accessory Dwelling Unit",
+ "valuation": 210000,
+ "contractor": "Seattle ADU Pros",
+ "contractor_license": "WA-LIC-556203",
+ "status": "approved",
+ "description": "DADU (detached ADU) 600 sq ft. Full kitchen, bath, living, loft sleeping. Fiber cement siding to match main house. EV charger.",
+ "bedrooms": 1,
+ "sqft_affected": 600,
+ "created_at": "2024-11-22T11:30:00Z"
+ },
+ {
+ "id": "PR-2024-036",
+ "address": "310 Bellevue Way NE",
+ "city": "Bellevue",
+ "jurisdiction": "King County",
+ "state": "WA",
+ "zip": "98004",
+ "date_filed": "2024-12-15",
+ "permit_type": "kitchen_remodel",
+ "permit_type_label": "Kitchen Remodel",
+ "valuation": 115000,
+ "contractor": "Eastside Luxury Remodel",
+ "contractor_license": "WA-LIC-770234",
+ "status": "approved",
+ "description": "Ultra-luxury kitchen remodel. Full overlay custom cabinetry, Calacatta marble, Sub-Zero/Wolf appliances, butler's pantry, wine refrigeration.",
+ "bedrooms": 5,
+ "sqft_affected": 420,
+ "created_at": "2024-12-15T10:45:00Z"
+ },
+ {
+ "id": "PR-2024-037",
+ "address": "612 Piedmont Ave NE",
+ "city": "Atlanta",
+ "jurisdiction": "Fulton County",
+ "state": "GA",
+ "zip": "30308",
+ "date_filed": "2024-11-15",
+ "permit_type": "bath_remodel",
+ "permit_type_label": "Bathroom Remodel",
+ "valuation": 45000,
+ "contractor": "Peachtree Bath Studio",
+ "contractor_license": "GA-LIC-222901",
+ "status": "approved",
+ "description": "Master bath remodel. Marble shower, claw-foot tub, shiplap accent wall, dual vanity with vessel sinks.",
+ "bedrooms": 3,
+ "sqft_affected": 160,
+ "created_at": "2024-11-15T13:00:00Z"
+ },
+ {
+ "id": "PR-2024-038",
+ "address": "2234 Peachtree Rd NW",
+ "city": "Atlanta",
+ "jurisdiction": "Fulton County",
+ "state": "GA",
+ "zip": "30309",
+ "date_filed": "2024-12-03",
+ "permit_type": "addition",
+ "permit_type_label": "Room Addition",
+ "valuation": 390000,
+ "contractor": "Southern Heritage Builders",
+ "contractor_license": "GA-LIC-559120",
+ "status": "pending",
+ "description": "Three-story rear addition with elevator. Ground: expanded kitchen, pantry, mudroom. Second: primary suite and office. Third: bonus room.",
+ "bedrooms": 4,
+ "sqft_affected": 1200,
+ "created_at": "2024-12-03T10:20:00Z"
+ },
+ {
+ "id": "PR-2024-039",
+ "address": "500 Massachusetts Ave",
+ "city": "Boston",
+ "jurisdiction": "Suffolk County",
+ "state": "MA",
+ "zip": "02118",
+ "date_filed": "2024-11-20",
+ "permit_type": "full_remodel",
+ "permit_type_label": "Full Interior Remodel",
+ "valuation": 750000,
+ "contractor": "Back Bay Renovations",
+ "contractor_license": "MA-LIC-334092",
+ "status": "approved",
+ "description": "Historic Brownstone renovation, 2,800 sq ft. LPC-approved period kitchen, 3 bath gut remodels, new MEP throughout, parlor restoration.",
+ "bedrooms": 4,
+ "sqft_affected": 2800,
+ "created_at": "2024-11-20T09:15:00Z"
+ },
+ {
+ "id": "PR-2024-040",
+ "address": "78 Chestnut Hill Ave",
+ "city": "Newton",
+ "jurisdiction": "Middlesex County",
+ "state": "MA",
+ "zip": "02467",
+ "date_filed": "2024-12-10",
+ "permit_type": "kitchen_remodel",
+ "permit_type_label": "Kitchen Remodel",
+ "valuation": 125000,
+ "contractor": "Greater Boston Remodelers",
+ "contractor_license": "MA-LIC-556710",
+ "status": "pending",
+ "description": "Open-concept kitchen remodel with formal dining integration. Transitional style, quartz and walnut surfaces, Miele appliances, wine cellar.",
+ "bedrooms": 5,
+ "sqft_affected": 480,
+ "created_at": "2024-12-10T14:30:00Z"
+ },
+ {
+ "id": "PR-2024-041",
+ "address": "3300 N Ocean Blvd",
+ "city": "Fort Lauderdale",
+ "jurisdiction": "Broward County",
+ "state": "FL",
+ "zip": "33308",
+ "date_filed": "2024-11-28",
+ "permit_type": "full_remodel",
+ "permit_type_label": "Full Interior Remodel",
+ "valuation": 850000,
+ "contractor": "Coastal Luxury Builders",
+ "contractor_license": "FL-LIC-778234",
+ "status": "approved",
+ "description": "Ultra-luxury oceanfront condo remodel, 3,200 sq ft. Full gut, custom kitchen, 3 designer baths, smart home, motorized blinds, new mechanical.",
+ "bedrooms": 4,
+ "sqft_affected": 3200,
+ "created_at": "2024-11-28T10:00:00Z"
+ },
+ {
+ "id": "PR-2024-042",
+ "address": "1825 Brickell Ave",
+ "city": "Miami",
+ "jurisdiction": "Miami-Dade County",
+ "state": "FL",
+ "zip": "33129",
+ "date_filed": "2024-12-08",
+ "permit_type": "bath_remodel",
+ "permit_type_label": "Bathroom Remodel",
+ "valuation": 68000,
+ "contractor": "SoBe Design Remodel",
+ "contractor_license": "FL-LIC-442891",
+ "status": "approved",
+ "description": "Luxury spa bath. Italian porcelain, steam shower with body jets, Japanese soaking tub, waterfall faucet, LED chromotherapy.",
+ "bedrooms": 3,
+ "sqft_affected": 200,
+ "created_at": "2024-12-08T11:15:00Z"
+ },
+ {
+ "id": "PR-2024-043",
+ "address": "7721 Biscayne Blvd",
+ "city": "Miami",
+ "jurisdiction": "Miami-Dade County",
+ "state": "FL",
+ "zip": "33138",
+ "date_filed": "2024-12-17",
+ "permit_type": "addition",
+ "permit_type_label": "Room Addition",
+ "valuation": 290000,
+ "contractor": "Miami Modern Builders",
+ "contractor_license": "FL-LIC-667234",
+ "status": "pending",
+ "description": "Two-car garage addition plus bonus room above, 750 sq ft total. Hurricane-rated construction, impact windows, attic HVAC.",
+ "bedrooms": 4,
+ "sqft_affected": 750,
+ "created_at": "2024-12-17T09:00:00Z"
+ },
+ {
+ "id": "PR-2024-044",
+ "address": "410 Lakeview Dr",
+ "city": "Nashville",
+ "jurisdiction": "Davidson County",
+ "state": "TN",
+ "zip": "37205",
+ "date_filed": "2024-12-05",
+ "permit_type": "kitchen_remodel",
+ "permit_type_label": "Kitchen Remodel",
+ "valuation": 90000,
+ "contractor": "Music City Renovations",
+ "contractor_license": "TN-LIC-445891",
+ "status": "approved",
+ "description": "Modern farmhouse kitchen. Shaker cabinets with brass hardware, Carrara marble, 48-inch range, pot rack, breakfast nook addition.",
+ "bedrooms": 4,
+ "sqft_affected": 360,
+ "created_at": "2024-12-05T10:30:00Z"
+ },
+ {
+ "id": "PR-2024-045",
+ "address": "6200 Hillsboro Pike",
+ "city": "Nashville",
+ "jurisdiction": "Davidson County",
+ "state": "TN",
+ "zip": "37215",
+ "date_filed": "2024-12-18",
+ "permit_type": "adu",
+ "permit_type_label": "ADU / Accessory Dwelling Unit",
+ "valuation": 175000,
+ "contractor": "Nashville ADU Build",
+ "contractor_license": "TN-LIC-556120",
+ "status": "approved",
+ "description": "Above-garage ADU, 620 sq ft. New staircase, open plan living/kitchen, full bath, private entrance, mini-split HVAC.",
+ "bedrooms": 1,
+ "sqft_affected": 620,
+ "created_at": "2024-12-18T08:45:00Z"
+ },
+ {
+ "id": "PR-2024-046",
+ "address": "900 Sheridan Rd",
+ "city": "Wilmette",
+ "jurisdiction": "Cook County",
+ "state": "IL",
+ "zip": "60091",
+ "date_filed": "2024-12-19",
+ "permit_type": "full_remodel",
+ "permit_type_label": "Full Interior Remodel",
+ "valuation": 620000,
+ "contractor": "North Shore Estate Remodelers",
+ "contractor_license": "IL-LIC-889231",
+ "status": "pending",
+ "description": "Comprehensive remodel of 1920s Tudor. Kitchen, 3 baths, primary suite expansion, new mudroom, windows, restored millwork, new mechanical.",
+ "bedrooms": 5,
+ "sqft_affected": 3100,
+ "created_at": "2024-12-19T11:00:00Z"
+ },
+ {
+ "id": "PR-2024-047",
+ "address": "1415 Pacific Coast Hwy",
+ "city": "Malibu",
+ "jurisdiction": "Los Angeles County",
+ "state": "CA",
+ "zip": "90265",
+ "date_filed": "2024-12-20",
+ "permit_type": "full_remodel",
+ "permit_type_label": "Full Interior Remodel",
+ "valuation": 1250000,
+ "contractor": "Malibu Dream Homes",
+ "contractor_license": "CA-LIC-992001",
+ "status": "pending",
+ "description": "Complete remodel of oceanfront home, 4,200 sq ft. Designer kitchen, 4 baths, home theater, wine cellar, primary suite with ocean-view terrace.",
+ "bedrooms": 5,
+ "sqft_affected": 4200,
+ "created_at": "2024-12-20T09:00:00Z"
+ },
+ {
+ "id": "PR-2024-048",
+ "address": "805 N Michigan Ave",
+ "city": "Chicago",
+ "jurisdiction": "Cook County",
+ "state": "IL",
+ "zip": "60611",
+ "date_filed": "2024-12-21",
+ "permit_type": "bath_remodel",
+ "permit_type_label": "Bathroom Remodel",
+ "valuation": 58000,
+ "contractor": "Gold Coast Baths",
+ "contractor_license": "IL-LIC-778910",
+ "status": "approved",
+ "description": "High-rise condo master bath remodel. Dolomite marble, freestanding tub, walk-in steam shower, heated floors, radiant towel bar.",
+ "bedrooms": 3,
+ "sqft_affected": 185,
+ "created_at": "2024-12-21T10:00:00Z"
+ },
+ {
+ "id": "PR-2024-049",
+ "address": "3300 Bee Cave Rd",
+ "city": "Austin",
+ "jurisdiction": "Travis County",
+ "state": "TX",
+ "zip": "78746",
+ "date_filed": "2024-12-22",
+ "permit_type": "kitchen_remodel",
+ "permit_type_label": "Kitchen Remodel",
+ "valuation": 140000,
+ "contractor": "Westlake Design Build",
+ "contractor_license": "TX-LIC-998210",
+ "status": "pending",
+ "description": "Trophy kitchen remodel. Custom cabinetry to ceiling, 10-ft island, Quartzite waterfall, La Cornue 60-inch range, integrated Miele appliances.",
+ "bedrooms": 5,
+ "sqft_affected": 500,
+ "created_at": "2024-12-22T09:30:00Z"
+ },
+ {
+ "id": "PR-2024-050",
+ "address": "2201 Collins Ave",
+ "city": "Miami Beach",
+ "jurisdiction": "Miami-Dade County",
+ "state": "FL",
+ "zip": "33139",
+ "date_filed": "2024-12-23",
+ "permit_type": "full_remodel",
+ "permit_type_label": "Full Interior Remodel",
+ "valuation": 1100000,
+ "contractor": "South Beach Luxury Build",
+ "contractor_license": "FL-LIC-887120",
+ "status": "approved",
+ "description": "Art Deco penthouse remodel, 3,800 sq ft. Full gut, bespoke kitchen, 3 designer baths, home automation, custom millwork, terrace expansion.",
+ "bedrooms": 4,
+ "sqft_affected": 3800,
+ "created_at": "2024-12-23T11:00:00Z"
+ }
+]
diff --git a/data/products.json b/data/products.json
deleted file mode 100644
index 8eee5f2..0000000
--- a/data/products.json
+++ /dev/null
@@ -1,6 +0,0 @@
-[
- { "title": "Sample One", "sku": "SMP-001", "price": 129.00, "hex": "#2b3a55", "image": "" },
- { "title": "Sample Two", "sku": "SMP-002", "price": 89.00, "hex": "#d8c3a5", "image": "" },
- { "title": "Sample Three", "sku": "SMP-003", "price": 210.00, "hex": "#8e8d8a", "image": "" },
- { "title": "Sample Four", "sku": "SMP-004", "price": 54.00, "hex": "#e98074", "image": "" }
-]
diff --git a/data/subscribers.json b/data/subscribers.json
new file mode 100644
index 0000000..fe51488
--- /dev/null
+++ b/data/subscribers.json
@@ -0,0 +1 @@
+[]
diff --git a/public/admin.html b/public/admin.html
new file mode 100644
index 0000000..0d58167
--- /dev/null
+++ b/public/admin.html
@@ -0,0 +1,241 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Admin — Permit Radar</title>
+<style>
+:root { --cols: 3; --accent: #1d4ed8; }
+*, *::before, *::after { box-sizing: border-box; }
+body { margin: 0; font: 14px/1.5 -apple-system, Segoe UI, Roboto, sans-serif; color: #1a1a1a; background: #f1f5f9; }
+
+nav { background: #0f172a; color: #fff; padding: 12px 20px; display: flex; align-items: center; gap: 16px; }
+.logo { font-size: 17px; font-weight: 800; color: #fff; text-decoration: none; }
+.logo span { color: #60a5fa; }
+.admin-badge { background: #ef4444; color: #fff; font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: 4px; }
+.nav-links { margin-left: auto; display: flex; gap: 16px; }
+.nav-links a { color: #94a3b8; font-size: 13px; text-decoration: none; }
+.nav-links a:hover { color: #fff; }
+
+/* ── TABS ── */
+.tabs { display: flex; gap: 0; border-bottom: 2px solid #e2e8f0; background: #fff; padding: 0 20px; }
+.tab {
+ padding: 12px 20px; font-size: 14px; font-weight: 600; color: #64748b;
+ cursor: pointer; border-bottom: 3px solid transparent; margin-bottom: -2px;
+}
+.tab.active { color: var(--accent); border-color: var(--accent); }
+.tab-badge { background: #e2e8f0; color: #475569; font-size: 11px; font-weight: 700; padding: 1px 7px; border-radius: 99px; margin-left: 6px; }
+.tab-badge.new { background: #dbeafe; color: var(--accent); }
+
+/* ── CONTROLS ── */
+.controls {
+ display: flex; gap: 12px; align-items: center; flex-wrap: wrap;
+ padding: 12px 20px; background: #fff; border-bottom: 1px solid #e2e8f0;
+ position: sticky; top: 0; z-index: 5;
+}
+.controls select { padding: 7px 10px; border: 1px solid #d1d5db; border-radius: 6px; font: inherit; font-size: 13px; }
+.ctrl-label { font-size: 12px; color: #64748b; display: flex; gap: 5px; align-items: center; }
+.count-badge { margin-left: auto; background: #f1f5f9; color: #475569; font-size: 12px; font-weight: 600; padding: 4px 10px; border-radius: 99px; }
+
+/* ── GRID ── */
+.grid-wrap { padding: 20px; }
+.grid { display: grid; grid-template-columns: repeat(var(--cols), minmax(0, 1fr)); gap: 14px; }
+.empty { padding: 60px; text-align: center; color: #94a3b8; }
+
+/* ── ADMIN CARD ── */
+/* House rule: every admin card MUST show created date + time */
+.card {
+ background: #fff; border: 1px solid #e2e8f0; border-radius: 10px;
+ overflow: hidden; display: flex; flex-direction: column;
+}
+.card-header { padding: 14px 14px 10px; border-bottom: 1px solid #f1f5f9; }
+.card-id { font-size: 11px; color: #94a3b8; font-weight: 600; letter-spacing: .4px; margin-bottom: 4px; }
+.card-title { font-size: 15px; font-weight: 700; color: #1e293b; margin: 0 0 2px; }
+.card-subtitle { font-size: 12px; color: #64748b; }
+.card-body { padding: 12px 14px; flex: 1; }
+.field { margin-bottom: 6px; font-size: 13px; color: #374151; }
+.field .lbl { font-size: 11px; color: #94a3b8; font-weight: 600; text-transform: uppercase; letter-spacing: .4px; display: block; margin-bottom: 1px; }
+/* HARD RULE: created date+time chip — visible on every admin card */
+.when-chip {
+ display: inline-flex; align-items: center; gap: 4px;
+ font-size: 11px; color: #64748b; background: #f8fafc;
+ border: 1px solid #e2e8f0; border-radius: 6px; padding: 3px 8px;
+ margin-top: 10px; font-weight: 500;
+}
+.when-chip .icon { color: #94a3b8; }
+.type-chip {
+ display: inline-block; font-size: 11px; font-weight: 600; padding: 2px 8px;
+ border-radius: 4px; background: #f1f5f9; color: #475569; margin-right: 4px;
+}
+.plan-chip { font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: 4px; }
+.plan-basic { background: #f1f5f9; color: #475569; }
+.plan-pro { background: #dbeafe; color: #1d4ed8; }
+.plan-team { background: #d1fae5; color: #065f46; }
+.val-chip { font-size: 13px; font-weight: 700; color: #15803d; }
+
+footer { background: #0f172a; color: #475569; padding: 20px; text-align: center; font-size: 13px; }
+footer a { color: #64748b; text-decoration: none; }
+</style>
+</head>
+<body>
+
+<nav>
+ <a class="logo" href="/"><span>📡</span> Permit Radar</a>
+ <span class="admin-badge">ADMIN</span>
+ <div class="nav-links">
+ <a href="/">Directory</a>
+ <a href="/#get-alerts">Get Alerts</a>
+ </div>
+</nav>
+
+<div class="tabs">
+ <div class="tab active" id="tab-subs" onclick="showTab('subs')">
+ Subscribers <span class="tab-badge new" id="badge-subs">…</span>
+ </div>
+ <div class="tab" id="tab-leads" onclick="showTab('leads')">
+ Lead Requests <span class="tab-badge" id="badge-leads">…</span>
+ </div>
+</div>
+
+<!-- CONTROLS — sort + density (house rule: admin grid also gets both) -->
+<div class="controls">
+ <label>
+ <select id="sort">
+ <option value="newest">Sort: Newest First</option>
+ <option value="oldest">Oldest First</option>
+ </select>
+ </label>
+ <span class="ctrl-label">
+ Density <input id="density" type="range" min="1" max="6" value="3" style="width:70px">
+ </span>
+ <span class="count-badge" id="count">…</span>
+</div>
+
+<!-- PANELS -->
+<div class="grid-wrap" id="panel-subs">
+ <div class="grid" id="grid-subs"><div class="empty">Loading subscribers…</div></div>
+</div>
+<div class="grid-wrap" id="panel-leads" style="display:none">
+ <div class="grid" id="grid-leads"><div class="empty">Loading lead requests…</div></div>
+</div>
+
+<footer>
+ <a href="/">← Back to Directory</a> · Permit Radar Admin
+</footer>
+
+<script>
+'use strict';
+const esc = s => (s||'').replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c]));
+
+// House rule: created date+time on every admin card
+// Must show BOTH date and time via toLocaleString; full ISO in title= attribute
+function fmtDateTime(iso) {
+ if (!iso) return '—';
+ const d = new Date(iso);
+ return d.toLocaleString(undefined, { year:'numeric', month:'short', day:'numeric', hour:'numeric', minute:'2-digit' });
+}
+
+// ── State ─────────────────────────────────────────────────────────────────────
+let activeTab = 'subs';
+const sortSel = document.getElementById('sort');
+const dens = document.getElementById('density');
+
+sortSel.value = localStorage.getItem('adm_sort') || 'newest';
+dens.value = localStorage.getItem('adm_cols') || '3';
+function applyCols() {
+ document.documentElement.style.setProperty('--cols', dens.value);
+ localStorage.setItem('adm_cols', dens.value);
+}
+applyCols();
+
+sortSel.onchange = () => { localStorage.setItem('adm_sort', sortSel.value); loadBoth(); };
+dens.oninput = applyCols;
+
+// ── Tab switching ─────────────────────────────────────────────────────────────
+function showTab(tab) {
+ activeTab = tab;
+ document.getElementById('tab-subs').classList.toggle('active', tab === 'subs');
+ document.getElementById('tab-leads').classList.toggle('active', tab === 'leads');
+ document.getElementById('panel-subs').style.display = tab === 'subs' ? '' : 'none';
+ document.getElementById('panel-leads').style.display = tab === 'leads' ? '' : 'none';
+ updateCount();
+}
+
+let _subCount = 0, _leadCount = 0;
+function updateCount() {
+ document.getElementById('count').textContent =
+ (activeTab === 'subs' ? _subCount : _leadCount) + ' records';
+}
+
+// ── Subscribers ───────────────────────────────────────────────────────────────
+async function loadSubs() {
+ const r = await fetch('/api/admin/subscribers?sort=' + sortSel.value);
+ if (r.status === 401) { document.getElementById('grid-subs').innerHTML = '<div class="empty">Auth required — reload with credentials.</div>'; return; }
+ const d = await r.json();
+ _subCount = d.count;
+ document.getElementById('badge-subs').textContent = d.count;
+ updateCount();
+ const grid = document.getElementById('grid-subs');
+ if (!d.subscribers.length) { grid.innerHTML = '<div class="empty">No subscribers yet.</div>'; return; }
+ grid.innerHTML = d.subscribers.map(s => {
+ const planClass = 'plan-' + (s.plan || 'basic');
+ const isoCreated = s.created_at || '';
+ return `
+<div class="card">
+ <div class="card-header">
+ <div class="card-id">${esc(s.id)}</div>
+ <div class="card-title">${esc(s.email)}</div>
+ <div class="card-subtitle"><span class="type-chip">${esc(s.trade_type || '—')}</span><span class="plan-chip ${planClass}">${esc(s.plan || 'basic')}</span></div>
+ </div>
+ <div class="card-body">
+ <div class="field"><span class="lbl">Service Area</span>${esc(s.service_area || '—')}</div>
+ ${s.jurisdictions && s.jurisdictions.length ? `<div class="field"><span class="lbl">Jurisdictions</span>${esc(s.jurisdictions.join(', '))}</div>` : ''}
+ ${s.permit_types && s.permit_types.length ? `<div class="field"><span class="lbl">Permit Types</span>${esc(s.permit_types.join(', '))}</div>` : ''}
+ <!-- House rule: created date + time chip — BOTH date and time, always visible -->
+ <div class="when-chip" title="${esc(isoCreated)}">
+ <span class="icon">🕓</span> ${esc(fmtDateTime(isoCreated))}
+ </div>
+ </div>
+</div>`;
+ }).join('');
+}
+
+// ── Lead Requests ─────────────────────────────────────────────────────────────
+async function loadLeads() {
+ const r = await fetch('/api/admin/lead-requests?sort=' + sortSel.value);
+ if (r.status === 401) { document.getElementById('grid-leads').innerHTML = '<div class="empty">Auth required.</div>'; return; }
+ const d = await r.json();
+ _leadCount = d.count;
+ document.getElementById('badge-leads').textContent = d.count;
+ updateCount();
+ const grid = document.getElementById('grid-leads');
+ if (!d.lead_requests.length) { grid.innerHTML = '<div class="empty">No lead requests yet.</div>'; return; }
+ grid.innerHTML = d.lead_requests.map(lr => {
+ const isoCreated = lr.created_at || '';
+ return `
+<div class="card">
+ <div class="card-header">
+ <div class="card-id">${esc(lr.id)}</div>
+ <div class="card-title">${esc(lr.name || lr.email)}</div>
+ <div class="card-subtitle">${esc(lr.email)}</div>
+ </div>
+ <div class="card-body">
+ <div class="field"><span class="lbl">Permit</span>${esc(lr.permit_id)} — ${esc(lr.permit_address || '')}</div>
+ <div class="field"><span class="lbl">Valuation</span><span class="val-chip">${lr.permit_valuation ? '$' + Number(lr.permit_valuation).toLocaleString() : '—'}</span></div>
+ <div class="field"><span class="lbl">Trade Type</span><span class="type-chip">${esc(lr.trade_type || '—')}</span></div>
+ ${lr.phone ? `<div class="field"><span class="lbl">Phone</span>${esc(lr.phone)}</div>` : ''}
+ ${lr.message ? `<div class="field"><span class="lbl">Message</span>${esc(lr.message)}</div>` : ''}
+ <!-- House rule: created date + time chip — BOTH date and time, always visible -->
+ <div class="when-chip" title="${esc(isoCreated)}">
+ <span class="icon">🕓</span> ${esc(fmtDateTime(isoCreated))}
+ </div>
+ </div>
+</div>`;
+ }).join('');
+}
+
+async function loadBoth() { await Promise.all([loadSubs(), loadLeads()]); }
+loadBoth();
+</script>
+</body>
+</html>
diff --git a/public/index.html b/public/index.html
index ceb7ab1..8679fa4 100644
--- a/public/index.html
+++ b/public/index.html
@@ -1,55 +1,564 @@
-<!doctype html><html lang="en"><head><meta charset="utf-8">
-<meta name="viewport" content="width=device-width,initial-scale=1"><title>Storefront starter</title>
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Permit Radar — Residential Remodel Permit Directory</title>
<style>
- :root{--cols:4}
- *{box-sizing:border-box} body{margin:0;font:15px/1.4 -apple-system,Segoe UI,Roboto,sans-serif;color:#1a1a1a;background:#fafafa}
- header{display:flex;gap:14px;align-items:center;flex-wrap:wrap;padding:14px 20px;border-bottom:1px solid #e6e6e6;position:sticky;top:0;background:#fff;z-index:5}
- h1{font-size:17px;margin:0;font-weight:700} .spacer{flex:1}
- label{font-size:12px;color:#666;display:flex;gap:6px;align-items:center}
- select,input[type=range]{font:inherit} .count{font-size:12px;color:#999}
- .grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:14px;padding:20px}
- .card{background:#fff;border:1px solid #e6e6e6;border-radius:8px;overflow:hidden}
- .card img{width:100%;aspect-ratio:1/1;object-fit:cover;display:block;background:#f0f0f0}
- .meta{padding:8px 10px} .t{font-size:13px;font-weight:600;margin:0 0 2px} .s{font-size:11px;color:#888}
- .p{font-size:13px;margin-top:4px} .empty{padding:40px;color:#999;text-align:center}
-</style></head><body>
-<header>
- <h1>Storefront starter</h1>
- <label>Sort
+:root { --cols: 3; --accent: #1d4ed8; --accent-light: #eff6ff; }
+*, *::before, *::after { box-sizing: border-box; }
+body { margin: 0; font: 15px/1.5 -apple-system, Segoe UI, Roboto, sans-serif; color: #1a1a1a; background: #f3f4f6; }
+
+/* ── SAMPLE BANNER ── */
+.sample-banner {
+ background: #fef3c7; border-bottom: 2px solid #f59e0b;
+ padding: 8px 20px; font-size: 13px; text-align: center; color: #78350f;
+}
+.sample-banner strong { color: #92400e; }
+
+/* ── TOP NAV ── */
+nav {
+ background: #1e293b; color: #fff; padding: 12px 20px;
+ display: flex; align-items: center; gap: 16px;
+}
+.logo { font-size: 18px; font-weight: 800; letter-spacing: -0.5px; color: #fff; text-decoration: none; }
+.logo span { color: #60a5fa; }
+.nav-links { margin-left: auto; display: flex; gap: 16px; }
+.nav-links a { color: #cbd5e1; font-size: 13px; text-decoration: none; }
+.nav-links a:hover { color: #fff; }
+
+/* ── HERO ── */
+.hero {
+ background: linear-gradient(135deg, #1e293b 0%, #1d4ed8 100%);
+ color: #fff; padding: 48px 20px 40px; text-align: center;
+}
+.hero h1 { font-size: 32px; font-weight: 800; margin: 0 0 10px; }
+.hero p { font-size: 16px; color: #bfdbfe; margin: 0 0 24px; }
+.hero-cta {
+ display: inline-block; background: #f59e0b; color: #1a1a1a;
+ font-weight: 700; padding: 12px 28px; border-radius: 8px;
+ text-decoration: none; font-size: 15px; transition: background .15s;
+}
+.hero-cta:hover { background: #fbbf24; }
+
+/* ── CONTROLS ── */
+.controls {
+ display: flex; gap: 12px; align-items: center; flex-wrap: wrap;
+ padding: 14px 20px; background: #fff; border-bottom: 1px solid #e2e8f0;
+ position: sticky; top: 0; z-index: 10;
+}
+.search-box {
+ flex: 1; min-width: 200px; position: relative;
+}
+.search-box input {
+ width: 100%; padding: 8px 12px 8px 36px; border: 1px solid #d1d5db;
+ border-radius: 6px; font: inherit; font-size: 14px; outline: none;
+}
+.search-box input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(29,78,216,.12); }
+.search-box::before { content: "🔍"; position: absolute; left: 10px; top: 50%; transform: translateY(-50%); font-size: 14px; }
+.filter-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
+.filter-row select, .filter-row input[type=text] {
+ padding: 7px 10px; border: 1px solid #d1d5db; border-radius: 6px;
+ font: inherit; font-size: 13px; background: #fff; cursor: pointer;
+}
+.filter-row select:focus { outline: none; border-color: var(--accent); }
+.ctrl-label { font-size: 12px; color: #64748b; display: flex; gap: 5px; align-items: center; }
+.count-badge {
+ margin-left: auto; background: var(--accent-light); color: var(--accent);
+ font-size: 12px; font-weight: 600; padding: 4px 10px; border-radius: 99px;
+}
+
+/* ── GRID ── */
+.grid-wrap { padding: 20px; }
+.grid {
+ display: grid;
+ grid-template-columns: repeat(var(--cols), minmax(0, 1fr));
+ gap: 14px;
+}
+.empty { padding: 60px; text-align: center; color: #94a3b8; font-size: 15px; }
+
+/* ── PERMIT CARD ── */
+.card {
+ background: #fff; border: 1px solid #e2e8f0; border-radius: 10px;
+ overflow: hidden; display: flex; flex-direction: column;
+ transition: box-shadow .15s, transform .15s;
+}
+.card:hover { box-shadow: 0 4px 24px rgba(0,0,0,.10); transform: translateY(-1px); }
+.card-header {
+ padding: 14px 14px 10px; border-bottom: 1px solid #f1f5f9;
+ display: flex; justify-content: space-between; align-items: flex-start; gap: 8px;
+}
+.permit-id { font-size: 11px; color: #94a3b8; font-weight: 600; letter-spacing: .5px; }
+.status-chip {
+ font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: 99px; white-space: nowrap;
+}
+.status-approved { background: #dcfce7; color: #15803d; }
+.status-pending { background: #fef9c3; color: #854d0e; }
+.status-issued { background: #dbeafe; color: #1d4ed8; }
+.type-chip {
+ font-size: 11px; font-weight: 600; padding: 3px 9px; border-radius: 4px;
+ background: #f1f5f9; color: #475569;
+}
+.card-body { padding: 12px 14px; flex: 1; }
+.card-address { font-size: 14px; font-weight: 700; margin: 0 0 2px; line-height: 1.3; }
+.card-city { font-size: 12px; color: #64748b; margin: 0 0 8px; }
+.card-meta { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 8px; }
+.meta-item { font-size: 12px; color: #475569; }
+.meta-item strong { color: #1e293b; }
+.card-desc { font-size: 12px; color: #64748b; line-height: 1.4;
+ display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
+.card-footer { padding: 10px 14px; border-top: 1px solid #f1f5f9; display: flex; gap: 8px; align-items: center; }
+.btn-detail {
+ flex: 1; text-align: center; padding: 7px; background: var(--accent-light);
+ color: var(--accent); border: 1px solid #bfdbfe; border-radius: 6px;
+ font-size: 12px; font-weight: 600; text-decoration: none; cursor: pointer;
+}
+.btn-detail:hover { background: #dbeafe; }
+.btn-lead {
+ flex: 1; text-align: center; padding: 7px; background: #f59e0b;
+ color: #1a1a1a; border: none; border-radius: 6px;
+ font-size: 12px; font-weight: 700; cursor: pointer; transition: background .15s;
+}
+.btn-lead:hover { background: #fbbf24; }
+.valuation { font-size: 14px; font-weight: 700; color: #15803d; }
+
+/* ── LEAD FUNNEL SECTION ── */
+.funnel-section {
+ background: linear-gradient(135deg, #1e293b 0%, #0f172a 100%);
+ color: #fff; padding: 56px 20px; margin-top: 32px; text-align: center;
+}
+.funnel-section h2 { font-size: 28px; margin: 0 0 10px; }
+.funnel-section p { color: #94a3b8; margin: 0 0 32px; font-size: 15px; }
+.plan-cards { display: flex; gap: 20px; justify-content: center; flex-wrap: wrap; margin-bottom: 36px; }
+.plan-card {
+ background: #1e293b; border: 1px solid #334155; border-radius: 12px;
+ padding: 24px; width: 220px; text-align: left; position: relative;
+}
+.plan-card.featured { border-color: #f59e0b; }
+.plan-badge {
+ position: absolute; top: -10px; left: 50%; transform: translateX(-50%);
+ background: #f59e0b; color: #1a1a1a; font-size: 11px; font-weight: 700;
+ padding: 2px 12px; border-radius: 99px;
+}
+.plan-name { font-size: 16px; font-weight: 700; margin: 0 0 4px; }
+.plan-price { font-size: 28px; font-weight: 800; color: #60a5fa; margin: 0 0 12px; }
+.plan-price span { font-size: 14px; color: #94a3b8; font-weight: 400; }
+.plan-features { list-style: none; margin: 0; padding: 0; font-size: 13px; color: #cbd5e1; }
+.plan-features li { padding: 3px 0; }
+.plan-features li::before { content: "✓ "; color: #60a5fa; font-weight: 700; }
+.funnel-form {
+ background: #fff; color: #1a1a1a; border-radius: 12px; padding: 28px;
+ max-width: 500px; margin: 0 auto; text-align: left;
+}
+.funnel-form h3 { font-size: 18px; margin: 0 0 16px; }
+.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 12px; }
+.form-row.single { grid-template-columns: 1fr; }
+.funnel-form label { display: block; font-size: 12px; font-weight: 600; color: #475569; margin-bottom: 4px; }
+.funnel-form select, .funnel-form input {
+ width: 100%; padding: 9px 12px; border: 1px solid #d1d5db; border-radius: 6px;
+ font: inherit; font-size: 14px; outline: none;
+}
+.funnel-form select:focus, .funnel-form input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(29,78,216,.1); }
+.btn-subscribe {
+ width: 100%; padding: 12px; background: var(--accent); color: #fff;
+ border: none; border-radius: 8px; font: 700 15px inherit; cursor: pointer; margin-top: 8px;
+}
+.btn-subscribe:hover { background: #1e40af; }
+.form-note { font-size: 11px; color: #94a3b8; text-align: center; margin-top: 8px; }
+.success-msg { background: #dcfce7; color: #15803d; border: 1px solid #bbf7d0; border-radius: 6px; padding: 12px; margin-top: 12px; font-size: 13px; text-align: center; }
+.error-msg { background: #fee2e2; color: #991b1b; border: 1px solid #fca5a5; border-radius: 6px; padding: 12px; margin-top: 12px; font-size: 13px; text-align: center; }
+
+/* ── LEAD MODAL ── */
+.modal-bg {
+ display: none; position: fixed; inset: 0; background: rgba(0,0,0,.5);
+ z-index: 100; align-items: center; justify-content: center;
+}
+.modal-bg.open { display: flex; }
+.modal {
+ background: #fff; border-radius: 12px; padding: 28px; width: 100%; max-width: 440px;
+ margin: 20px; position: relative;
+}
+.modal h3 { margin: 0 0 6px; font-size: 18px; }
+.modal p { color: #64748b; font-size: 13px; margin: 0 0 18px; }
+.modal-close {
+ position: absolute; top: 14px; right: 16px; font-size: 20px; line-height: 1;
+ cursor: pointer; color: #94a3b8; background: none; border: none;
+}
+.modal-close:hover { color: #1a1a1a; }
+
+/* ── FOOTER ── */
+footer { background: #0f172a; color: #64748b; padding: 24px 20px; text-align: center; font-size: 13px; margin-top: 32px; }
+footer a { color: #94a3b8; text-decoration: none; }
+footer a:hover { color: #fff; }
+</style>
+</head>
+<body>
+
+<!-- SAMPLE DATA BANNER -->
+<div class="sample-banner">
+ ⚠️ <strong>SAMPLE / REPRESENTATIVE DATA ONLY</strong> — These permits are illustrative, not sourced from any county portal.
+ Real-feed seam: <code>scripts/ingest-stub.js</code>
+</div>
+
+<!-- NAV -->
+<nav>
+ <a class="logo" href="/"><span>📡</span> Permit Radar</a>
+ <div class="nav-links">
+ <a href="/">Directory</a>
+ <a href="#get-alerts">Get Alerts</a>
+ <a href="/admin">Admin</a>
+ </div>
+</nav>
+
+<!-- HERO -->
+<div class="hero">
+ <h1>Residential Remodel Permits — Live</h1>
+ <p>High-intent homeowner leads at the moment they start their renovation. Reach them first.</p>
+ <a href="#get-alerts" class="hero-cta">Get Permit Alerts in Your Area →</a>
+</div>
+
+<!-- CONTROLS (sort + density + filters) -->
+<div class="controls">
+ <div class="search-box">
+ <input id="search" type="text" placeholder="Search address, city, contractor, type…">
+ </div>
+ <div class="filter-row">
<select id="sort">
- <option value="newest">Newest</option>
- <option value="title">Title A→Z</option>
- <option value="sku">SKU A→Z</option>
- <option value="price-asc">Price ↑</option>
- <option value="price-desc">Price ↓</option>
- <option value="light">Light → Dark</option>
- <option value="dark">Dark → Light</option>
+ <option value="date_desc">Sort: Newest Filed</option>
+ <option value="date_asc">Oldest Filed</option>
+ <option value="valuation_desc">Valuation High→Low</option>
+ <option value="valuation_asc">Valuation Low→High</option>
+ <option value="city">City A→Z</option>
+ <option value="type">Permit Type</option>
+ <option value="address">Address A→Z</option>
</select>
- </label>
- <label>Density <input id="density" type="range" min="2" max="8" value="4"></label>
- <span class="spacer"></span><span class="count" id="count"></span>
-</header>
-<div class="grid" id="grid"><div class="empty">Loading…</div></div>
+ <select id="filt-jurisdiction">
+ <option value="">All Jurisdictions</option>
+ <option value="los angeles county">Los Angeles County</option>
+ <option value="santa clara county">Santa Clara County</option>
+ <option value="san diego county">San Diego County</option>
+ <option value="alameda county">Alameda County</option>
+ <option value="maricopa county">Maricopa County</option>
+ <option value="coconino county">Coconino County</option>
+ <option value="cook county">Cook County</option>
+ <option value="travis county">Travis County</option>
+ <option value="williamson county">Williamson County</option>
+ <option value="dallas county">Dallas County</option>
+ <option value="multnomah county">Multnomah County</option>
+ <option value="king county">King County</option>
+ <option value="fulton county">Fulton County</option>
+ <option value="suffolk county">Suffolk County</option>
+ <option value="middlesex county">Middlesex County</option>
+ <option value="broward county">Broward County</option>
+ <option value="miami-dade county">Miami-Dade County</option>
+ <option value="davidson county">Davidson County</option>
+ </select>
+ <select id="filt-type">
+ <option value="">All Types</option>
+ <option value="kitchen_remodel">Kitchen Remodel</option>
+ <option value="bath_remodel">Bathroom Remodel</option>
+ <option value="addition">Room Addition</option>
+ <option value="full_remodel">Full Remodel</option>
+ <option value="adu">ADU</option>
+ <option value="reroof">Reroof</option>
+ </select>
+ <select id="filt-status">
+ <option value="">All Statuses</option>
+ <option value="approved">Approved</option>
+ <option value="pending">Pending</option>
+ </select>
+ <span class="ctrl-label">Density <input id="density" type="range" min="1" max="6" value="3" style="width:70px"></span>
+ </div>
+ <span class="count-badge" id="count">… permits</span>
+</div>
+
+<!-- PERMIT GRID -->
+<div class="grid-wrap">
+ <div class="grid" id="grid"><div class="empty">Loading permits…</div></div>
+</div>
+
+<!-- LEAD FUNNEL SECTION -->
+<div class="funnel-section" id="get-alerts">
+ <h2>Turn Permits Into Revenue</h2>
+ <p>Trades, designers, and home-goods vendors — get alerts the moment a high-value permit is filed in your area.</p>
+
+ <div class="plan-cards">
+ <div class="plan-card">
+ <div class="plan-name">Basic</div>
+ <div class="plan-price">$79<span>/mo</span></div>
+ <ul class="plan-features">
+ <li>Up to 3 jurisdictions</li>
+ <li>Weekly digest emails</li>
+ <li>1 permit type</li>
+ <li>Permits >$25K valuation</li>
+ </ul>
+ </div>
+ <div class="plan-card featured">
+ <div class="plan-badge">MOST POPULAR</div>
+ <div class="plan-name">Pro</div>
+ <div class="plan-price">$199<span>/mo</span></div>
+ <ul class="plan-features">
+ <li>Unlimited jurisdictions</li>
+ <li>Real-time email alerts</li>
+ <li>All permit types</li>
+ <li>All valuations</li>
+ <li>Contractor contacts</li>
+ </ul>
+ </div>
+ <div class="plan-card">
+ <div class="plan-name">Team</div>
+ <div class="plan-price">$449<span>/mo</span></div>
+ <ul class="plan-features">
+ <li>Everything in Pro</li>
+ <li>5 team seats</li>
+ <li>CRM/CSV export</li>
+ <li>DW Trade referrals</li>
+ <li>Priority support</li>
+ </ul>
+ </div>
+ </div>
+
+ <div class="funnel-form">
+ <h3>Get Permit Alerts</h3>
+ <form id="subscribe-form" onsubmit="submitSubscribe(event)">
+ <div class="form-row">
+ <div>
+ <label>Trade Type *</label>
+ <select name="trade_type" required>
+ <option value="">Select…</option>
+ <option value="general_contractor">General Contractor</option>
+ <option value="kitchen_bath">Kitchen & Bath Designer</option>
+ <option value="interior_designer">Interior Designer</option>
+ <option value="roofing">Roofing Contractor</option>
+ <option value="plumber">Plumber</option>
+ <option value="electrician">Electrician</option>
+ <option value="flooring">Flooring Contractor</option>
+ <option value="hvac">HVAC</option>
+ <option value="home_goods_vendor">Home Goods Vendor</option>
+ <option value="wallcovering_vendor">Wallcovering Vendor</option>
+ <option value="real_estate">Real Estate Agent</option>
+ <option value="other">Other</option>
+ </select>
+ </div>
+ <div>
+ <label>Plan</label>
+ <select name="plan">
+ <option value="basic">Basic — $79/mo</option>
+ <option value="pro" selected>Pro — $199/mo</option>
+ <option value="team">Team — $449/mo</option>
+ </select>
+ </div>
+ </div>
+ <div class="form-row">
+ <div>
+ <label>Email *</label>
+ <input type="email" name="email" placeholder="you@company.com" required>
+ </div>
+ <div>
+ <label>Service Area / City</label>
+ <input type="text" name="service_area" placeholder="e.g. Los Angeles, CA">
+ </div>
+ </div>
+ <button type="submit" class="btn-subscribe">Start Getting Leads →</button>
+ <div class="form-note">No charge yet — this prototype doesn't process payment. We'll contact you to onboard.</div>
+ <div id="sub-msg"></div>
+ </form>
+ </div>
+</div>
+
+<!-- LEAD REQUEST MODAL -->
+<div class="modal-bg" id="lead-modal" onclick="if(event.target===this)closeLead()">
+ <div class="modal">
+ <button class="modal-close" onclick="closeLead()">×</button>
+ <h3>Request This Lead</h3>
+ <p id="modal-permit-label">Permit details…</p>
+ <form id="lead-form" onsubmit="submitLead(event)">
+ <input type="hidden" id="modal-permit-id" name="permit_id">
+ <div class="form-row">
+ <div>
+ <label>Your Name</label>
+ <input type="text" name="name" placeholder="Jane Smith">
+ </div>
+ <div>
+ <label>Trade Type *</label>
+ <select name="trade_type" required>
+ <option value="">Select…</option>
+ <option value="general_contractor">General Contractor</option>
+ <option value="kitchen_bath">Kitchen & Bath</option>
+ <option value="interior_designer">Interior Designer</option>
+ <option value="roofing">Roofer</option>
+ <option value="flooring">Flooring</option>
+ <option value="hvac">HVAC</option>
+ <option value="home_goods_vendor">Home Goods Vendor</option>
+ <option value="wallcovering_vendor">Wallcovering Vendor</option>
+ <option value="other">Other</option>
+ </select>
+ </div>
+ </div>
+ <div class="form-row">
+ <div>
+ <label>Email *</label>
+ <input type="email" name="email" required placeholder="you@company.com">
+ </div>
+ <div>
+ <label>Phone</label>
+ <input type="tel" name="phone" placeholder="555-555-5555">
+ </div>
+ </div>
+ <div class="form-row single">
+ <div>
+ <label>Message</label>
+ <input type="text" name="message" placeholder="What services are you offering?">
+ </div>
+ </div>
+ <button type="submit" class="btn-subscribe">Request This Lead →</button>
+ <div id="lead-msg"></div>
+ </form>
+ </div>
+</div>
+
+<footer>
+ <p>© 2024 Permit Radar — <a href="#get-alerts">Get Alerts</a> · <a href="/admin">Admin</a></p>
+ <p style="margin-top:6px;font-size:11px">⚠️ Sample data only — not sourced from any county permit portal. Real-feed seam: <code>scripts/ingest-stub.js</code></p>
+</footer>
+
<script>
-// House rule: sort + density both persist in localStorage so the choice survives reloads.
-const $=s=>document.querySelector(s), esc=s=>(s||'').replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
-const sortSel=$('#sort'), dens=$('#density');
-sortSel.value=localStorage.getItem('sort')||'newest'; dens.value=localStorage.getItem('cols')||'4';
-function applyCols(){document.documentElement.style.setProperty('--cols',dens.value);localStorage.setItem('cols',dens.value);}
+'use strict';
+const esc = s => (s||'').replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c]));
+const fmtVal = v => v ? '$' + Number(v).toLocaleString() : '—';
+const fmtDate = d => d ? new Date(d + 'T12:00:00').toLocaleDateString('en-US', { month:'short', day:'numeric', year:'numeric' }) : '—';
+
+// ── Persist controls ──────────────────────────────────────────────────────────
+const sortSel = document.getElementById('sort');
+const dens = document.getElementById('density');
+const search = document.getElementById('search');
+const filtJ = document.getElementById('filt-jurisdiction');
+const filtT = document.getElementById('filt-type');
+const filtS = document.getElementById('filt-status');
+
+sortSel.value = localStorage.getItem('pr_sort') || 'date_desc';
+dens.value = localStorage.getItem('pr_cols') || '3';
+
+function applyCols() {
+ document.documentElement.style.setProperty('--cols', dens.value);
+ localStorage.setItem('pr_cols', dens.value);
+}
applyCols();
-async function load(){
- const r=await fetch('/api/products?sort='+encodeURIComponent(sortSel.value)); const d=await r.json();
- $('#count').textContent=d.count+' items';
- $('#grid').innerHTML = d.products.length ? d.products.map(p=>`
- <div class="card">
- <img src="${esc(p.image||'')}" alt="${esc(p.title||'')}" loading="lazy">
- <div class="meta"><p class="t">${esc(p.title||'Untitled')}</p>
- <div class="s">${esc(p.sku||p.handle||'')}</div>
- ${p.price?`<div class="p">$${Number(p.price).toFixed(2)}</div>`:''}
- </div>
- </div>`).join('') : '<div class="empty">No products yet — fill data/products.json.</div>';
+
+// ── Fetch + render ────────────────────────────────────────────────────────────
+let debounce;
+async function load() {
+ const params = new URLSearchParams({
+ sort: sortSel.value,
+ q: search.value,
+ jurisdiction: filtJ.value,
+ permit_type: filtT.value,
+ status: filtS.value,
+ });
+ const r = await fetch('/api/permits?' + params);
+ const d = await r.json();
+ document.getElementById('count').textContent = d.count + ' permits';
+ const grid = document.getElementById('grid');
+ if (!d.permits.length) {
+ grid.innerHTML = '<div class="empty">No permits match your filters.</div>';
+ return;
+ }
+ grid.innerHTML = d.permits.map(p => {
+ const statusClass = 'status-' + (p.status || 'pending');
+ const contractor = p.contractor ? `<span class="meta-item"><strong>Contractor:</strong> ${esc(p.contractor)}</span>` : '';
+ return `
+<div class="card">
+ <div class="card-header">
+ <div>
+ <div class="permit-id">${esc(p.id)}</div>
+ <span class="type-chip">${esc(p.permit_type_label || p.permit_type)}</span>
+ </div>
+ <span class="status-chip ${statusClass}">${esc(p.status)}</span>
+ </div>
+ <div class="card-body">
+ <p class="card-address">${esc(p.address)}</p>
+ <p class="card-city">${esc(p.city)}, ${esc(p.state)} ${esc(p.zip)} — ${esc(p.jurisdiction)}</p>
+ <div class="card-meta">
+ <span class="meta-item"><strong>Filed:</strong> ${fmtDate(p.date_filed)}</span>
+ <span class="valuation">${fmtVal(p.valuation)}</span>
+ ${contractor}
+ </div>
+ <div class="card-desc">${esc(p.description)}</div>
+ </div>
+ <div class="card-footer">
+ <a class="btn-detail" href="/permit/${esc(p.id)}">View Details</a>
+ <button class="btn-lead" onclick='openLead(${JSON.stringify(p.id)}, ${JSON.stringify(p.address + ", " + p.city)})'>Request Lead</button>
+ </div>
+</div>`;
+ }).join('');
}
-sortSel.onchange=()=>{localStorage.setItem('sort',sortSel.value);load();};
-dens.oninput=applyCols;
+
+sortSel.onchange = () => { localStorage.setItem('pr_sort', sortSel.value); load(); };
+dens.oninput = applyCols;
+search.oninput = () => { clearTimeout(debounce); debounce = setTimeout(load, 300); };
+filtJ.onchange = filtT.onchange = filtS.onchange = load;
+
load();
-</script></body></html>
+
+// ── Subscribe form ────────────────────────────────────────────────────────────
+async function submitSubscribe(e) {
+ e.preventDefault();
+ const form = e.target;
+ const body = Object.fromEntries(new FormData(form));
+ const msg = document.getElementById('sub-msg');
+ msg.innerHTML = '';
+ try {
+ const r = await fetch('/api/subscribe', {
+ method: 'POST', headers: {'Content-Type':'application/json'},
+ body: JSON.stringify(body)
+ });
+ const d = await r.json();
+ if (d.ok) {
+ msg.innerHTML = '<div class="success-msg">✓ Subscribed! ID: ' + esc(d.id) + '<br>We\'ll be in touch to complete your onboarding.</div>';
+ form.reset();
+ } else {
+ msg.innerHTML = '<div class="error-msg">Error: ' + esc(d.error || 'Unknown error') + '</div>';
+ }
+ } catch (err) {
+ msg.innerHTML = '<div class="error-msg">Network error — please try again.</div>';
+ }
+}
+
+// ── Lead modal ────────────────────────────────────────────────────────────────
+function openLead(id, label) {
+ document.getElementById('modal-permit-id').value = id;
+ document.getElementById('modal-permit-label').textContent = label;
+ document.getElementById('lead-msg').innerHTML = '';
+ document.getElementById('lead-form').reset();
+ document.getElementById('modal-permit-id').value = id; // re-set after reset
+ document.getElementById('lead-modal').classList.add('open');
+}
+function closeLead() {
+ document.getElementById('lead-modal').classList.remove('open');
+}
+
+async function submitLead(e) {
+ e.preventDefault();
+ const form = e.target;
+ const body = Object.fromEntries(new FormData(form));
+ const msg = document.getElementById('lead-msg');
+ msg.innerHTML = '';
+ try {
+ const r = await fetch('/api/lead-request', {
+ method: 'POST', headers: {'Content-Type':'application/json'},
+ body: JSON.stringify(body)
+ });
+ const d = await r.json();
+ if (d.ok) {
+ msg.innerHTML = '<div class="success-msg">✓ Lead request submitted! ID: ' + esc(d.id) + '</div>';
+ setTimeout(closeLead, 2500);
+ } else {
+ msg.innerHTML = '<div class="error-msg">Error: ' + esc(d.error) + '</div>';
+ }
+ } catch (err) {
+ msg.innerHTML = '<div class="error-msg">Network error.</div>';
+ }
+}
+</script>
+</body>
+</html>
diff --git a/public/permit.html b/public/permit.html
new file mode 100644
index 0000000..3b3a924
--- /dev/null
+++ b/public/permit.html
@@ -0,0 +1,225 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Permit Detail — Permit Radar</title>
+<style>
+:root { --accent: #1d4ed8; --accent-light: #eff6ff; }
+*, *::before, *::after { box-sizing: border-box; }
+body { margin: 0; font: 15px/1.5 -apple-system, Segoe UI, Roboto, sans-serif; color: #1a1a1a; background: #f3f4f6; }
+
+.sample-banner { background: #fef3c7; border-bottom: 2px solid #f59e0b; padding: 8px 20px; font-size: 13px; text-align: center; color: #78350f; }
+nav { background: #1e293b; color: #fff; padding: 12px 20px; display: flex; align-items: center; gap: 16px; }
+.logo { font-size: 18px; font-weight: 800; color: #fff; text-decoration: none; }
+.logo span { color: #60a5fa; }
+.nav-links { margin-left: auto; display: flex; gap: 16px; }
+.nav-links a { color: #cbd5e1; font-size: 13px; text-decoration: none; }
+.nav-links a:hover { color: #fff; }
+
+.container { max-width: 800px; margin: 32px auto; padding: 0 20px 40px; }
+.back-link { font-size: 13px; color: var(--accent); text-decoration: none; margin-bottom: 16px; display: inline-block; }
+.back-link:hover { text-decoration: underline; }
+
+.permit-card { background: #fff; border: 1px solid #e2e8f0; border-radius: 12px; overflow: hidden; }
+.permit-header { background: linear-gradient(135deg, #1e293b 0%, #1d4ed8 100%); color: #fff; padding: 28px; }
+.permit-header-top { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; flex-wrap: wrap; }
+.permit-id-label { font-size: 12px; color: #93c5fd; font-weight: 600; letter-spacing: .5px; margin-bottom: 4px; }
+.permit-address-h { font-size: 22px; font-weight: 800; margin: 0 0 2px; }
+.permit-location { font-size: 14px; color: #bfdbfe; }
+.status-chip { font-size: 12px; font-weight: 700; padding: 4px 12px; border-radius: 99px; white-space: nowrap; margin-top: 4px; }
+.status-approved { background: #dcfce7; color: #15803d; }
+.status-pending { background: #fef9c3; color: #854d0e; }
+
+.permit-body { padding: 28px; }
+.section-title { font-size: 11px; font-weight: 700; color: #94a3b8; letter-spacing: 1px; text-transform: uppercase; margin: 0 0 12px; }
+.fields-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 16px; margin-bottom: 24px; }
+.field { }
+.field-label { font-size: 11px; color: #94a3b8; font-weight: 600; margin-bottom: 2px; }
+.field-value { font-size: 15px; font-weight: 600; color: #1e293b; }
+.field-value.big { font-size: 20px; color: #15803d; }
+.permit-desc { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 16px; font-size: 14px; color: #374151; line-height: 1.6; margin-bottom: 24px; }
+
+.cta-box { background: var(--accent-light); border: 1px solid #bfdbfe; border-radius: 10px; padding: 20px; }
+.cta-box h3 { font-size: 16px; margin: 0 0 6px; color: #1e40af; }
+.cta-box p { font-size: 13px; color: #3b82f6; margin: 0 0 14px; }
+.cta-row { display: flex; gap: 10px; flex-wrap: wrap; }
+.btn-lead {
+ padding: 10px 20px; background: #f59e0b; color: #1a1a1a; border: none;
+ border-radius: 8px; font: 700 14px inherit; cursor: pointer;
+}
+.btn-lead:hover { background: #fbbf24; }
+.btn-subscribe {
+ padding: 10px 20px; background: var(--accent); color: #fff; border: none;
+ border-radius: 8px; font: 700 14px inherit; cursor: pointer; text-decoration: none; display: inline-block;
+}
+.btn-subscribe:hover { background: #1e40af; }
+
+/* Lead modal */
+.modal-bg { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.5); z-index: 100; align-items: center; justify-content: center; }
+.modal-bg.open { display: flex; }
+.modal { background: #fff; border-radius: 12px; padding: 28px; width: 100%; max-width: 420px; margin: 20px; position: relative; }
+.modal h3 { margin: 0 0 6px; }
+.modal p { color: #64748b; font-size: 13px; margin: 0 0 16px; }
+.modal-close { position: absolute; top: 14px; right: 16px; font-size: 20px; cursor: pointer; color: #94a3b8; background: none; border: none; }
+.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 10px; }
+.form-row.single { grid-template-columns: 1fr; }
+label { display: block; font-size: 12px; font-weight: 600; color: #475569; margin-bottom: 4px; }
+select, input { width: 100%; padding: 9px 12px; border: 1px solid #d1d5db; border-radius: 6px; font: inherit; font-size: 14px; }
+.btn-submit { width: 100%; padding: 12px; background: var(--accent); color: #fff; border: none; border-radius: 8px; font: 700 15px inherit; cursor: pointer; margin-top: 8px; }
+.success-msg { background: #dcfce7; color: #15803d; border-radius: 6px; padding: 10px; margin-top: 10px; font-size: 13px; text-align: center; }
+.error-msg { background: #fee2e2; color: #991b1b; border-radius: 6px; padding: 10px; margin-top: 10px; font-size: 13px; text-align: center; }
+.loading { color: #94a3b8; padding: 60px; text-align: center; }
+</style>
+</head>
+<body>
+
+<div class="sample-banner">⚠️ <strong>SAMPLE / REPRESENTATIVE DATA</strong> — not from any county portal.</div>
+
+<nav>
+ <a class="logo" href="/"><span>📡</span> Permit Radar</a>
+ <div class="nav-links">
+ <a href="/">Directory</a>
+ <a href="/#get-alerts">Get Alerts</a>
+ <a href="/admin">Admin</a>
+ </div>
+</nav>
+
+<div class="container">
+ <a class="back-link" href="/">← Back to Directory</a>
+ <div id="content" class="loading">Loading permit…</div>
+</div>
+
+<!-- Lead modal -->
+<div class="modal-bg" id="lead-modal" onclick="if(event.target===this)closeLead()">
+ <div class="modal">
+ <button class="modal-close" onclick="closeLead()">×</button>
+ <h3>Request This Lead</h3>
+ <p id="modal-permit-label">—</p>
+ <form id="lead-form" onsubmit="submitLead(event)">
+ <input type="hidden" id="modal-permit-id" name="permit_id">
+ <div class="form-row">
+ <div>
+ <label>Your Name</label>
+ <input type="text" name="name" placeholder="Jane Smith">
+ </div>
+ <div>
+ <label>Trade Type *</label>
+ <select name="trade_type" required>
+ <option value="">Select…</option>
+ <option value="general_contractor">General Contractor</option>
+ <option value="kitchen_bath">Kitchen & Bath</option>
+ <option value="interior_designer">Interior Designer</option>
+ <option value="roofing">Roofer</option>
+ <option value="flooring">Flooring</option>
+ <option value="hvac">HVAC</option>
+ <option value="home_goods_vendor">Home Goods Vendor</option>
+ <option value="wallcovering_vendor">Wallcovering Vendor</option>
+ <option value="other">Other</option>
+ </select>
+ </div>
+ </div>
+ <div class="form-row">
+ <div>
+ <label>Email *</label>
+ <input type="email" name="email" required placeholder="you@company.com">
+ </div>
+ <div>
+ <label>Phone</label>
+ <input type="tel" name="phone" placeholder="555-555-5555">
+ </div>
+ </div>
+ <div class="form-row single">
+ <div>
+ <label>Message</label>
+ <input type="text" name="message" placeholder="Services you offer">
+ </div>
+ </div>
+ <button type="submit" class="btn-submit">Request Lead →</button>
+ <div id="lead-msg"></div>
+ </form>
+ </div>
+</div>
+
+<script>
+'use strict';
+const esc = s => (s||'').replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c]));
+const fmtVal = v => v ? '$' + Number(v).toLocaleString() : '—';
+const fmtDate = d => d ? new Date(d + 'T12:00:00').toLocaleDateString('en-US', { month:'long', day:'numeric', year:'numeric' }) : '—';
+
+async function load() {
+ const id = decodeURIComponent(location.pathname.replace('/permit/', '').replace(/\/$/, ''));
+ const r = await fetch('/api/permits/' + encodeURIComponent(id));
+ const content = document.getElementById('content');
+ if (!r.ok) { content.innerHTML = '<p style="color:#ef4444">Permit not found.</p>'; return; }
+ const { permit: p } = await r.json();
+ document.title = p.address + ' — Permit Radar';
+ const statusClass = 'status-' + (p.status || 'pending');
+ content.innerHTML = `
+<div class="permit-card">
+ <div class="permit-header">
+ <div class="permit-header-top">
+ <div>
+ <div class="permit-id-label">Permit ${esc(p.id)}</div>
+ <div class="permit-address-h">${esc(p.address)}</div>
+ <div class="permit-location">${esc(p.city)}, ${esc(p.state)} ${esc(p.zip)} — ${esc(p.jurisdiction)}</div>
+ </div>
+ <span class="status-chip ${statusClass}">${esc(p.status)}</span>
+ </div>
+ </div>
+ <div class="permit-body">
+ <div class="section-title">Permit Details</div>
+ <div class="fields-grid">
+ <div class="field"><div class="field-label">Date Filed</div><div class="field-value">${fmtDate(p.date_filed)}</div></div>
+ <div class="field"><div class="field-label">Permit Type</div><div class="field-value">${esc(p.permit_type_label || p.permit_type)}</div></div>
+ <div class="field"><div class="field-label">Valuation</div><div class="field-value big">${fmtVal(p.valuation)}</div></div>
+ <div class="field"><div class="field-label">Sq Ft Affected</div><div class="field-value">${p.sqft_affected ? p.sqft_affected.toLocaleString() + ' sq ft' : '—'}</div></div>
+ <div class="field"><div class="field-label">Status</div><div class="field-value">${esc(p.status)}</div></div>
+ <div class="field"><div class="field-label">Bedrooms (home)</div><div class="field-value">${p.bedrooms || '—'}</div></div>
+ </div>
+ ${p.contractor ? `
+ <div class="section-title">Contractor on Record</div>
+ <div class="fields-grid">
+ <div class="field"><div class="field-label">Contractor</div><div class="field-value">${esc(p.contractor)}</div></div>
+ <div class="field"><div class="field-label">License</div><div class="field-value">${esc(p.contractor_license || '—')}</div></div>
+ </div>` : ''}
+ <div class="section-title">Description</div>
+ <div class="permit-desc">${esc(p.description)}</div>
+ <div class="cta-box">
+ <h3>Reach this homeowner at the renovation moment</h3>
+ <p>Request this lead directly, or subscribe for alerts on every permit like it in your area.</p>
+ <div class="cta-row">
+ <button class="btn-lead" onclick="openLead('${esc(p.id)}', '${esc(p.address + ', ' + p.city)}')">Request This Lead</button>
+ <a class="btn-subscribe" href="/#get-alerts">Get Area Alerts →</a>
+ </div>
+ </div>
+ </div>
+</div>`;
+}
+load();
+
+function openLead(id, label) {
+ document.getElementById('modal-permit-id').value = id;
+ document.getElementById('modal-permit-label').textContent = label;
+ document.getElementById('lead-msg').innerHTML = '';
+ document.getElementById('lead-form').reset();
+ document.getElementById('modal-permit-id').value = id;
+ document.getElementById('lead-modal').classList.add('open');
+}
+function closeLead() { document.getElementById('lead-modal').classList.remove('open'); }
+
+async function submitLead(e) {
+ e.preventDefault();
+ const body = Object.fromEntries(new FormData(e.target));
+ const msg = document.getElementById('lead-msg');
+ msg.innerHTML = '';
+ const r = await fetch('/api/lead-request', {
+ method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body)
+ });
+ const d = await r.json();
+ if (d.ok) { msg.innerHTML = '<div class="success-msg">✓ Lead request submitted! ID: ' + esc(d.id) + '</div>'; setTimeout(closeLead, 2500); }
+ else { msg.innerHTML = '<div class="error-msg">Error: ' + esc(d.error) + '</div>'; }
+}
+</script>
+</body>
+</html>
diff --git a/scripts/ingest-stub.js b/scripts/ingest-stub.js
new file mode 100644
index 0000000..47e98b8
--- /dev/null
+++ b/scripts/ingest-stub.js
@@ -0,0 +1,150 @@
+// permit-radar / scripts/ingest-stub.js
+//
+// REAL PERMIT FEED SEAM
+// ─────────────────────────────────────────────────────────────────────────────
+// Replace data/permits.json sample data with real county permit records here.
+//
+// HOW A REAL FEED WORKS (per-county options):
+//
+// 1. MUNICIPAL / COUNTY OPEN DATA PORTALS (free, Socrata/ArcGIS APIs)
+// Many counties publish permit data via Socrata: e.g.
+// - Los Angeles County: data.lacounty.gov (search "building permits")
+// - San Francisco: data.sfgov.org /resource/i98e-djp9.json
+// - Chicago: data.cityofchicago.org /resource/ydr8-5enu.json
+// Use the Socrata SODA API — free, JSON, filterable by date & type.
+// Example:
+// const { SodaClient } = require('soda-js');
+// const client = new SodaClient('data.lacounty.gov');
+// client.query().withDataset('permit_dataset_id').where('permit_type_desc', 'REMODEL')
+// .getRows().on('row', row => permits.push(normalizeRow(row)));
+//
+// 2. BUILDFAX / PERMITZIP / SALESDRIVE (paid commercial feeds)
+// These aggregate nationwide permit data behind a subscription API.
+// - BuildFax: https://buildfax.com/api (REST, JSON, $$ per county)
+// - PermitZip: bulk CSV download or webhook per jurisdiction
+// Wire credentials via env: BUILDFAX_API_KEY=... or PERMITZIP_API_KEY=...
+//
+// 3. DIRECT COUNTY PORTAL SCRAPING (inconsistent, often paywalled)
+// Some counties use iParc / EnerGov / Accela portals. Most require auth
+// or session tokens. NOT recommended for production without legal review.
+//
+// NORMALIZATION CONTRACT
+// ─────────────────────────────────────────────────────────────────────────────
+// Every upstream record must be normalized to this schema before writing to
+// data/permits.json so the server + frontend work without changes:
+//
+// {
+// id: string, // unique e.g. "LA-2024-100001"
+// address: string, // street address
+// city: string,
+// jurisdiction: string, // "Los Angeles County"
+// state: string, // "CA"
+// zip: string,
+// date_filed: string, // "YYYY-MM-DD"
+// permit_type: string, // machine key: kitchen_remodel | bath_remodel | addition | full_remodel | adu | reroof
+// permit_type_label: string, // human label
+// valuation: number, // dollar amount
+// contractor: string, // may be ""
+// contractor_license: string,
+// status: string, // "approved" | "pending" | "issued"
+// description: string,
+// bedrooms: number,
+// sqft_affected: number,
+// created_at: string, // ISO timestamp of when WE ingested it
+// }
+//
+// USAGE (to be run as a cron job or manual refresh):
+// node scripts/ingest-stub.js --county "los-angeles" --days 7
+// node scripts/ingest-stub.js --source buildfax --zips 90210,90401
+//
+// The stub below shows the structure; replace with real API calls.
+// ─────────────────────────────────────────────────────────────────────────────
+
+'use strict';
+const fs = require('fs');
+const path = require('path');
+
+const DATA_FILE = path.join(__dirname, '..', 'data', 'permits.json');
+
+// ── Normalization helper ──────────────────────────────────────────────────────
+function normalizeRow(raw, jurisdiction, source) {
+ // Adapt field names from whichever source is feeding this.
+ // This is a skeleton — fill in per-source mappings.
+ return {
+ id: source + '-' + (raw.permit_number || raw.id || Date.now()),
+ address: raw.address || raw.street_address || '',
+ city: raw.city || raw.city_name || '',
+ jurisdiction: jurisdiction,
+ state: raw.state || '',
+ zip: raw.zip_code || raw.zip || '',
+ date_filed: (raw.issue_date || raw.filed_date || raw.date_filed || '').slice(0, 10),
+ permit_type: mapPermitType(raw.permit_type || raw.permit_type_desc || ''),
+ permit_type_label: raw.permit_type_desc || raw.permit_type || '',
+ valuation: parseFloat(raw.valuation || raw.estimated_cost || 0) || 0,
+ contractor: raw.contractor_name || raw.contractor || '',
+ contractor_license: raw.contractor_license || '',
+ status: (raw.status || 'pending').toLowerCase(),
+ description: raw.description || raw.work_description || '',
+ bedrooms: parseInt(raw.bedrooms || 0, 10) || 0,
+ sqft_affected: parseInt(raw.sqft || raw.sq_ft_affected || 0, 10) || 0,
+ created_at: new Date().toISOString(),
+ };
+}
+
+function mapPermitType(raw) {
+ const r = (raw || '').toLowerCase();
+ if (r.includes('kitchen')) return 'kitchen_remodel';
+ if (r.includes('bath')) return 'bath_remodel';
+ if (r.includes('addition') || r.includes('adu') || r.includes('accessory')) return r.includes('adu') || r.includes('accessory') ? 'adu' : 'addition';
+ if (r.includes('reroof') || r.includes('roof')) return 'reroof';
+ if (r.includes('remodel') || r.includes('renovation') || r.includes('interior')) return 'full_remodel';
+ return 'full_remodel';
+}
+
+// ── Stub: fake fetch from a Socrata endpoint ──────────────────────────────────
+async function fetchFromSocrata(domain, dataset, jurisdiction) {
+ // Real impl: replace URL below with actual county Socrata dataset ID.
+ // const url = `https://${domain}/resource/${dataset}.json?$where=date_filed>='${since}'&$limit=1000`;
+ // const rows = await fetch(url).then(r => r.json());
+ // return rows.map(r => normalizeRow(r, jurisdiction, domain));
+ console.log(`[ingest-stub] STUB: would fetch from https://${domain}/resource/${dataset}.json`);
+ return [];
+}
+
+// ── Main ──────────────────────────────────────────────────────────────────────
+async function main() {
+ const args = process.argv.slice(2);
+ const dryRun = args.includes('--dry-run');
+ console.log('[ingest-stub] Real-feed seam — replace stubs with live API calls.');
+ console.log('[ingest-stub] Source options: Socrata open data, BuildFax, PermitZip — see comments above.');
+ if (dryRun) { console.log('[ingest-stub] --dry-run: no writes.'); return; }
+
+ // Example county feed registrations:
+ const sources = [
+ { domain: 'data.lacounty.gov', dataset: 'PLACEHOLDER_DATASET_ID', jurisdiction: 'Los Angeles County' },
+ { domain: 'data.sfgov.org', dataset: 'i98e-djp9', jurisdiction: 'San Francisco County' },
+ { domain: 'data.cityofchicago.org', dataset: 'ydr8-5enu', jurisdiction: 'Cook County' },
+ ];
+
+ let all = [];
+ for (const s of sources) {
+ const rows = await fetchFromSocrata(s.domain, s.dataset, s.jurisdiction);
+ all = all.concat(rows);
+ }
+
+ if (!all.length) {
+ console.log('[ingest-stub] No records fetched (stubs return empty). Wire real APIs to populate.');
+ return;
+ }
+
+ // Merge with existing — deduplicate by id
+ let existing = [];
+ try { existing = JSON.parse(fs.readFileSync(DATA_FILE, 'utf8')); } catch {}
+ const existingIds = new Set(existing.map(p => p.id));
+ const newRecords = all.filter(p => !existingIds.has(p.id));
+ const merged = [...existing, ...newRecords].sort((a, b) => b.date_filed.localeCompare(a.date_filed));
+ fs.writeFileSync(DATA_FILE, JSON.stringify(merged, null, 2), 'utf8');
+ console.log(`[ingest-stub] Wrote ${merged.length} total permits (${newRecords.length} new) to data/permits.json`);
+}
+
+main().catch(err => { console.error('[ingest-stub] Error:', err.message); process.exit(1); });
diff --git a/server.js b/server.js
index 57f5df5..0d18448 100644
--- a/server.js
+++ b/server.js
@@ -1,41 +1,265 @@
-// Storefront starter — zero-dep Node http. Serves data/products.json with SERVER-SIDE sort
-// (house rule: every product grid gets sort + a density slider). Optional basic auth via BASIC_AUTH.
-const http = require('http'), fs = require('fs'), path = require('path');
-const PORT = parseInt(process.env.PORT || '3900', 10);
-const BASIC_AUTH = process.env.BASIC_AUTH || ''; // "user:pass" to gate; empty = open
+// permit-radar — zero-dep Node http server
+// Serves permit directory with sort/filter, lead funnel, and admin dashboard.
+// PORT from env (default 3905). BASIC_AUTH=user:pass gates /admin.
+//
+// REAL FEED SEAM: see scripts/ingest-stub.js for how a real county permit
+// feed would populate data/permits.json. The sample data is representative only.
+'use strict';
+const http = require('http');
+const fs = require('fs');
+const path = require('path');
+
+const PORT = parseInt(process.env.PORT || '3905', 10);
+const BASIC_AUTH = process.env.BASIC_AUTH || ''; // "user:pass" to gate /admin
const DIR = __dirname;
-function load() { try { return JSON.parse(fs.readFileSync(path.join(DIR, 'data', 'products.json'), 'utf8')); } catch { return []; } }
-function authed(req) {
- if (!BASIC_AUTH) return true;
- const m = (req.headers.authorization || '').match(/^Basic\s+(.+)$/i);
- if (!m) return false; try { return Buffer.from(m[1], 'base64').toString() === BASIC_AUTH; } catch { return false; }
+// ── Data helpers ─────────────────────────────────────────────────────────────
+
+function loadJSON(file) {
+ try { return JSON.parse(fs.readFileSync(path.join(DIR, 'data', file), 'utf8')); } catch { return []; }
+}
+function saveJSON(file, data) {
+ fs.writeFileSync(path.join(DIR, 'data', file), JSON.stringify(data, null, 2), 'utf8');
}
-// Sort modes mirror the DW sort-skill: newest, title/sku A→Z, price ↑↓, light→dark (by dominant hex).
-function luminance(hex) { if (!/^#?[0-9a-f]{6}$/i.test(hex || '')) return 999; const h = hex.replace('#', '');
- const r = parseInt(h.slice(0, 2), 16), g = parseInt(h.slice(2, 4), 16), b = parseInt(h.slice(4, 6), 16);
- return 0.2126 * r + 0.7152 * g + 0.0722 * b; }
-function sortProducts(items, mode) {
+function loadPermits() { return loadJSON('permits.json'); }
+function loadSubscribers() { return loadJSON('subscribers.json'); }
+function loadLeadRequests() { return loadJSON('lead_requests.json'); }
+
+// ── Permit sort ───────────────────────────────────────────────────────────────
+
+function sortPermits(items, mode) {
const a = items.slice();
switch (mode) {
- case 'title': return a.sort((x, y) => (x.title || '').localeCompare(y.title || ''));
- case 'sku': return a.sort((x, y) => (x.sku || x.handle || '').localeCompare(y.sku || y.handle || ''));
- case 'price-asc': return a.sort((x, y) => (x.price || 0) - (y.price || 0));
- case 'price-desc': return a.sort((x, y) => (y.price || 0) - (x.price || 0));
- case 'light': return a.sort((x, y) => luminance(y.hex) - luminance(x.hex));
- case 'dark': return a.sort((x, y) => luminance(x.hex) - luminance(y.hex));
- default: return a; // 'newest' = natural order
+ case 'date_asc': return a.sort((x, y) => x.date_filed.localeCompare(y.date_filed));
+ case 'date_desc': return a.sort((x, y) => y.date_filed.localeCompare(x.date_filed));
+ case 'valuation_asc': return a.sort((x, y) => (x.valuation || 0) - (y.valuation || 0));
+ case 'valuation_desc': return a.sort((x, y) => (y.valuation || 0) - (x.valuation || 0));
+ case 'city': return a.sort((x, y) => (x.city || '').localeCompare(y.city || ''));
+ case 'type': return a.sort((x, y) => (x.permit_type || '').localeCompare(y.permit_type || ''));
+ case 'address': return a.sort((x, y) => (x.address || '').localeCompare(y.address || ''));
+ default: return a; // 'newest' = file order (newest filed last in seed; reversed by date_desc)
+ }
+}
+
+// ── Permit filter ─────────────────────────────────────────────────────────────
+
+function filterPermits(items, params) {
+ let out = items;
+ const q = (params.get('q') || '').toLowerCase().trim();
+ const jurisdiction = (params.get('jurisdiction') || '').toLowerCase().trim();
+ const permit_type = (params.get('permit_type') || '').toLowerCase().trim();
+ const status = (params.get('status') || '').toLowerCase().trim();
+ const val_min = parseFloat(params.get('val_min') || '0') || 0;
+ const val_max = parseFloat(params.get('val_max') || '0') || 0;
+ const date_from = params.get('date_from') || '';
+ const date_to = params.get('date_to') || '';
+
+ if (q) {
+ out = out.filter(p =>
+ (p.address || '').toLowerCase().includes(q) ||
+ (p.city || '').toLowerCase().includes(q) ||
+ (p.jurisdiction || '').toLowerCase().includes(q) ||
+ (p.description || '').toLowerCase().includes(q) ||
+ (p.contractor || '').toLowerCase().includes(q) ||
+ (p.permit_type_label || '').toLowerCase().includes(q)
+ );
}
+ if (jurisdiction) out = out.filter(p => (p.jurisdiction || '').toLowerCase().includes(jurisdiction));
+ if (permit_type) out = out.filter(p => (p.permit_type || '').toLowerCase() === permit_type);
+ if (status) out = out.filter(p => (p.status || '').toLowerCase() === status);
+ if (val_min > 0) out = out.filter(p => (p.valuation || 0) >= val_min);
+ if (val_max > 0) out = out.filter(p => (p.valuation || 0) <= val_max);
+ if (date_from) out = out.filter(p => p.date_filed >= date_from);
+ if (date_to) out = out.filter(p => p.date_filed <= date_to);
+ return out;
}
-http.createServer((req, res) => {
- if (!authed(req)) { res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="preview"' }); return res.end('auth required'); }
- const u = new URL(req.url, 'http://x');
- if (u.pathname === '/api/products') {
- const out = sortProducts(load(), u.searchParams.get('sort') || 'newest');
- res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ count: out.length, products: out }));
- }
- const f = u.pathname === '/' ? 'index.html' : u.pathname.replace(/^\//, '');
- const fp = path.join(DIR, 'public', path.basename(f));
- if (fs.existsSync(fp)) { res.writeHead(200, { 'Content-Type': f.endsWith('.html') ? 'text/html' : 'text/plain' }); return res.end(fs.readFileSync(fp)); }
- res.writeHead(404); res.end('not found');
-}).listen(PORT, function () { console.log('[' + path.basename(DIR) + '] http://localhost:' + this.address().port); });
+
+// ── Auth helpers ──────────────────────────────────────────────────────────────
+
+function adminAuthed(req) {
+ if (!BASIC_AUTH) return true;
+ const m = (req.headers.authorization || '').match(/^Basic\s+(.+)$/i);
+ if (!m) return false;
+ try { return Buffer.from(m[1], 'base64').toString() === BASIC_AUTH; } catch { return false; }
+}
+
+// ── MIME ──────────────────────────────────────────────────────────────────────
+
+function mime(f) {
+ if (f.endsWith('.html')) return 'text/html; charset=utf-8';
+ if (f.endsWith('.css')) return 'text/css';
+ if (f.endsWith('.js')) return 'application/javascript';
+ if (f.endsWith('.json')) return 'application/json';
+ if (f.endsWith('.ico')) return 'image/x-icon';
+ return 'text/plain';
+}
+
+// ── Body parser ───────────────────────────────────────────────────────────────
+
+function readBody(req) {
+ return new Promise((resolve, reject) => {
+ const chunks = [];
+ req.on('data', c => chunks.push(c));
+ req.on('end', () => {
+ try { resolve(JSON.parse(Buffer.concat(chunks).toString())); }
+ catch { resolve({}); }
+ });
+ req.on('error', reject);
+ });
+}
+
+// ── Server ────────────────────────────────────────────────────────────────────
+
+const server = http.createServer(async (req, res) => {
+ const parsed = new URL(req.url, `http://localhost:${PORT}`);
+ const pathname = parsed.pathname;
+ const params = parsed.searchParams;
+ const method = req.method.toUpperCase();
+
+ // ── JSON API ────────────────────────────────────────────────────────────────
+
+ if (pathname === '/api/permits' && method === 'GET') {
+ const raw = loadPermits();
+ const filtered = filterPermits(raw, params);
+ const sorted = sortPermits(filtered, params.get('sort') || 'date_desc');
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ return res.end(JSON.stringify({ count: sorted.length, permits: sorted, sample: true }));
+ }
+
+ if (pathname.startsWith('/api/permits/') && method === 'GET') {
+ const id = decodeURIComponent(pathname.replace('/api/permits/', ''));
+ const permit = loadPermits().find(p => p.id === id);
+ if (!permit) {
+ res.writeHead(404, { 'Content-Type': 'application/json' });
+ return res.end(JSON.stringify({ error: 'not found' }));
+ }
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ return res.end(JSON.stringify({ permit, sample: true }));
+ }
+
+ if (pathname === '/api/subscribe' && method === 'POST') {
+ const body = await readBody(req);
+ if (!body.email || !body.trade_type) {
+ res.writeHead(400, { 'Content-Type': 'application/json' });
+ return res.end(JSON.stringify({ error: 'email and trade_type required' }));
+ }
+ const subs = loadSubscribers();
+ const record = {
+ id: 'SUB-' + Date.now(),
+ email: body.email,
+ trade_type: body.trade_type,
+ service_area: body.service_area || '',
+ jurisdictions: body.jurisdictions || [],
+ permit_types: body.permit_types || [],
+ plan: body.plan || 'basic',
+ created_at: new Date().toISOString()
+ };
+ subs.push(record);
+ saveJSON('subscribers.json', subs);
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ return res.end(JSON.stringify({ ok: true, id: record.id, message: 'Subscription recorded.' }));
+ }
+
+ if (pathname === '/api/lead-request' && method === 'POST') {
+ const body = await readBody(req);
+ if (!body.permit_id || !body.email) {
+ res.writeHead(400, { 'Content-Type': 'application/json' });
+ return res.end(JSON.stringify({ error: 'permit_id and email required' }));
+ }
+ const permits = loadPermits();
+ const permit = permits.find(p => p.id === body.permit_id);
+ const reqs = loadLeadRequests();
+ const record = {
+ id: 'LR-' + Date.now(),
+ permit_id: body.permit_id,
+ permit_address: permit ? (permit.address + ', ' + permit.city) : '',
+ permit_valuation: permit ? permit.valuation : 0,
+ name: body.name || '',
+ email: body.email,
+ phone: body.phone || '',
+ trade_type: body.trade_type || '',
+ message: body.message || '',
+ created_at: new Date().toISOString()
+ };
+ reqs.push(record);
+ saveJSON('lead_requests.json', reqs);
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ return res.end(JSON.stringify({ ok: true, id: record.id, message: 'Lead request received.' }));
+ }
+
+ // Admin API — gated
+
+ if (pathname === '/api/admin/subscribers' && method === 'GET') {
+ if (!adminAuthed(req)) {
+ res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="permit-radar-admin"' });
+ return res.end('auth required');
+ }
+ const subs = loadSubscribers();
+ const sort = params.get('sort') || 'newest';
+ const sorted = sort === 'oldest'
+ ? subs.slice().sort((a, b) => a.created_at.localeCompare(b.created_at))
+ : subs.slice().sort((a, b) => b.created_at.localeCompare(a.created_at));
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ return res.end(JSON.stringify({ count: sorted.length, subscribers: sorted }));
+ }
+
+ if (pathname === '/api/admin/lead-requests' && method === 'GET') {
+ if (!adminAuthed(req)) {
+ res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="permit-radar-admin"' });
+ return res.end('auth required');
+ }
+ const reqs = loadLeadRequests();
+ const sort = params.get('sort') || 'newest';
+ const sorted = sort === 'oldest'
+ ? reqs.slice().sort((a, b) => a.created_at.localeCompare(b.created_at))
+ : reqs.slice().sort((a, b) => b.created_at.localeCompare(a.created_at));
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ return res.end(JSON.stringify({ count: sorted.length, lead_requests: sorted }));
+ }
+
+ // ── Static files ────────────────────────────────────────────────────────────
+
+ // /admin → admin.html (gated)
+ if (pathname === '/admin' || pathname === '/admin/') {
+ if (!adminAuthed(req)) {
+ res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="permit-radar-admin"' });
+ return res.end('auth required');
+ }
+ const fp = path.join(DIR, 'public', 'admin.html');
+ if (fs.existsSync(fp)) {
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
+ return res.end(fs.readFileSync(fp));
+ }
+ res.writeHead(404); return res.end('admin.html not found');
+ }
+
+ // /permit/:id → permit.html (SPA-style; ID parsed on client from location)
+ if (pathname.startsWith('/permit/')) {
+ const fp = path.join(DIR, 'public', 'permit.html');
+ if (fs.existsSync(fp)) {
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
+ return res.end(fs.readFileSync(fp));
+ }
+ res.writeHead(404); return res.end('not found');
+ }
+
+ // static files in /public
+ const relPath = pathname === '/' ? 'index.html' : pathname.replace(/^\//, '');
+ const safe = path.normalize(relPath).replace(/^(\.\.(\/|\\|$))+/, '');
+ const fp = path.join(DIR, 'public', safe);
+ if (fs.existsSync(fp) && fs.statSync(fp).isFile()) {
+ res.writeHead(200, { 'Content-Type': mime(fp) });
+ return res.end(fs.readFileSync(fp));
+ }
+
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
+ res.end('not found');
+});
+
+server.listen(PORT, function () {
+ const addr = this.address();
+ console.log('[permit-radar] http://localhost:' + addr.port);
+ console.log('[permit-radar] Admin: BASIC_AUTH=admin:DW2024! PORT=3905 node server.js, then visit /admin');
+ console.log('[permit-radar] NOTE: permit data is SAMPLE/REPRESENTATIVE — not real county data.');
+ console.log('[permit-radar] Real feed seam: see scripts/ingest-stub.js');
+});
← f1fb193 initial scaffold (permit-radar) via web-dev accelerator
·
back to Permit Radar
·
feat: copy shopify-catalog.json + add /api/shopify-crosssell 591ba0d →