← back to Ventura Corridor
public/marketplace.html
718 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<script>(function(){try{var t=localStorage.getItem('vc-theme');document.documentElement.dataset.theme=(t==='light'||t==='dark')?t:'dark';}catch(e){document.documentElement.dataset.theme='dark';}})();</script>
<link rel="stylesheet" href="/theme.css">
<script src="/theme.js" defer></script>
<title>Marketplace · Ventura Boulevard</title>
<style>
:root {
--noir: #0a0a0c; --noir-rise: #131316; --rule: #2a2724;
--ink: #f4f1ea; --ink-soft: #d8d2c5; --ink-mute: #8b857a;
--metal: #b89968; --metal-glow: #d4b683; --metal-deep: #8a7044;
--serif: "Cormorant Garamond", Georgia, serif;
--sans: -apple-system, "SF Pro Text", "Inter", system-ui, sans-serif;
--mono: 'JetBrains Mono', 'SFMono-Regular', Menlo, Consolas, monospace;
--cols: 4;
}
*, *::before, *::after { box-sizing: border-box; }
html, body { margin: 0; background: var(--noir); color: var(--ink); font-family: var(--sans); font-weight: 300; min-height: 100vh; }
header {
position: sticky; top: 0; z-index: 1000;
padding: 18px 24px;
background: linear-gradient(180deg, rgba(10,10,12,.96), rgba(10,10,12,.86));
backdrop-filter: blur(10px);
border-bottom: 1px solid var(--rule);
display: flex; align-items: center; justify-content: space-between; gap: 24px;
}
.logo {
font-family: var(--serif); font-weight: 300; font-size: 22px; letter-spacing: -.005em;
color: var(--ink); text-decoration: none; line-height: 1;
}
.logo em { font-style: italic; color: var(--metal); }
.logo .pill {
display: inline-block; margin-left: 10px; padding: 3px 10px;
font-family: var(--sans); font-size: 9px; letter-spacing: .26em; text-transform: uppercase;
color: var(--metal); border: 1px solid var(--metal); vertical-align: middle;
}
.hamburger {
display: inline-flex; align-items: center; gap: 8px;
background: transparent; border: 1px solid var(--rule);
padding: 8px 14px; color: var(--ink-mute); cursor: pointer;
font-size: 10px; letter-spacing: .26em; text-transform: uppercase;
transition: color 160ms ease, border-color 160ms ease;
}
.hamburger:hover { color: var(--metal); border-color: var(--metal); }
.hamburger .bars { display: inline-flex; flex-direction: column; gap: 3px; }
.hamburger .bars i { display: block; width: 14px; height: 1px; background: currentColor; }
/* nav drawer (right-side) */
#drawer { position: fixed; top: 0; right: 0; bottom: 0; width: 320px; max-width: 88vw;
background: var(--noir-rise); border-left: 1px solid var(--rule); z-index: 1500;
transform: translateX(100%); transition: transform 320ms cubic-bezier(.16,.84,.3,1);
overflow-y: auto; padding: 22px 22px 30px;
}
#drawer.show { transform: none; }
#drawer .dx { background: transparent; border: 1px solid var(--rule); color: var(--ink-mute);
padding: 6px 10px; font-size: 10px; letter-spacing: .18em; text-transform: uppercase; cursor: pointer;
margin-bottom: 18px;
}
#drawer a { display: block; padding: 10px 0; border-bottom: 1px solid var(--rule);
color: var(--ink-soft); font-size: 12px; letter-spacing: .04em; text-decoration: none;
font-family: var(--serif); font-style: italic;
}
#drawer a:hover { color: var(--metal); }
#drawer-mask { position: fixed; inset: 0; background: rgba(0,0,0,.5); z-index: 1400;
opacity: 0; pointer-events: none; transition: opacity 200ms ease;
}
#drawer-mask.show { opacity: 1; pointer-events: auto; }
.lede {
max-width: 1380px; margin: 36px auto 8px; padding: 0 24px;
}
.kicker {
font-size: 10px; letter-spacing: .3em; text-transform: uppercase;
color: var(--metal); font-weight: 500; margin-bottom: 12px;
}
h1 {
font-family: var(--serif); font-weight: 300; font-style: italic;
font-size: clamp(40px, 5.4vw, 72px); line-height: 1.04; margin: 0 0 12px;
letter-spacing: -.012em;
}
h1 em { font-style: normal; color: var(--metal); }
.deck {
font-family: var(--serif); font-style: italic; font-size: 18px; line-height: 1.5;
color: var(--ink-soft); max-width: 720px;
}
.deck a { color: var(--metal-glow); border-bottom: 1px dotted var(--metal-glow); text-decoration: none; }
.deck a:hover { color: var(--ink); border-bottom-color: var(--ink); }
/* Controls bar */
.controls {
max-width: 1380px; margin: 30px auto 0; padding: 0 24px;
display: grid; grid-template-columns: 1fr auto auto auto; gap: 12px; align-items: end;
}
@media (max-width: 880px) {
.controls { grid-template-columns: 1fr; }
}
.ctl-label {
display: block; font-size: 9px; letter-spacing: .26em; text-transform: uppercase;
color: var(--ink-mute); margin: 0 0 6px;
}
.ctl-input, .ctl-select {
width: 100%; padding: 12px 16px; background: var(--noir-rise);
border: 1px solid var(--rule); color: var(--ink); font-family: var(--sans);
font-size: 13px; font-weight: 300; outline: none; transition: border-color 200ms ease;
}
.ctl-input::placeholder { color: var(--ink-mute); font-style: italic; font-family: var(--serif); }
.ctl-input:focus, .ctl-select:focus { border-color: var(--metal); box-shadow: 0 0 0 3px rgba(184,153,104,0.12); }
.ctl-density { display: flex; align-items: center; gap: 10px; padding: 12px 14px; background: var(--noir-rise); border: 1px solid var(--rule); }
.ctl-density input[type=range] { width: 110px; accent-color: var(--metal); }
.ctl-density .v { font-family: var(--mono); font-size: 10px; color: var(--metal); letter-spacing: .12em; min-width: 26px; text-align: right; }
.cat-bar {
max-width: 1380px; margin: 18px auto 0; padding: 0 24px;
display: flex; gap: 8px; flex-wrap: wrap;
}
.cat-chip {
border: 1px solid var(--rule); background: transparent;
color: var(--ink-soft); padding: 8px 14px; font-size: 10px; letter-spacing: .2em;
text-transform: uppercase; cursor: pointer; font-family: var(--sans);
transition: color 160ms ease, border-color 160ms ease, background 160ms ease;
}
.cat-chip:hover { color: var(--metal); border-color: var(--metal); }
.cat-chip.active { color: var(--noir); background: var(--metal); border-color: var(--metal); }
.cat-chip .n { font-family: var(--serif); font-style: italic; opacity: .8; margin-left: 6px; font-size: 11px; }
/* Result count + post-listing CTA */
.meta-row {
max-width: 1380px; margin: 20px auto 0; padding: 0 24px;
display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 12px;
}
.count { font-size: 10px; letter-spacing: .3em; text-transform: uppercase; color: var(--ink-mute); }
.count b { color: var(--metal); font-family: var(--serif); font-style: italic; font-weight: 400; font-size: 15px; letter-spacing: 0; margin-right: 4px; font-variant-numeric: tabular-nums; }
.post-cta {
background: transparent; border: 1px solid var(--metal); color: var(--metal-glow);
padding: 10px 18px; font-size: 10px; letter-spacing: .26em; text-transform: uppercase;
cursor: pointer; font-family: var(--sans); transition: all 200ms ease;
}
.post-cta:hover { background: var(--metal); color: var(--noir); }
/* Grid */
.grid {
max-width: 1380px; margin: 18px auto 60px; padding: 0 24px;
display: grid; gap: 18px;
grid-template-columns: repeat(var(--cols), 1fr);
}
@media (max-width: 1100px) { :root { --cols: 3; } }
@media (max-width: 760px) { :root { --cols: 2; } }
@media (max-width: 460px) { :root { --cols: 1; } }
.card {
background: var(--noir-rise); border: 1px solid var(--rule);
display: flex; flex-direction: column; cursor: pointer;
transition: border-color 200ms ease, transform 200ms ease;
text-decoration: none; color: inherit;
}
.card:hover { border-color: var(--metal); transform: translateY(-2px); }
.card .img {
aspect-ratio: 1 / 1; background: linear-gradient(135deg, #1a1815 0%, #2a2520 100%);
position: relative; overflow: hidden;
}
.card .img img { width: 100%; height: 100%; object-fit: cover; display: block; }
.card .img .placeholder {
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
color: var(--metal-deep); font-family: var(--serif); font-style: italic; font-size: 15px;
padding: 18px; text-align: center; letter-spacing: .02em;
}
.card .img .cat-tag {
position: absolute; top: 10px; left: 10px;
font-size: 8px; letter-spacing: .26em; text-transform: uppercase;
color: var(--ink); background: rgba(10,10,12,.78); padding: 4px 8px;
border: 1px solid var(--metal); backdrop-filter: blur(4px);
}
.card .img .source {
position: absolute; top: 10px; right: 10px;
font-size: 8px; letter-spacing: .2em; text-transform: uppercase;
color: var(--metal-glow); background: rgba(10,10,12,.78); padding: 4px 8px;
border: 1px solid var(--rule); backdrop-filter: blur(4px);
}
.card .body { padding: 14px 16px 16px; display: flex; flex-direction: column; gap: 6px; flex: 1; }
.card .title {
font-family: var(--serif); font-weight: 400; font-size: 16px; line-height: 1.22;
color: var(--ink); letter-spacing: -.003em;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.card .price {
font-family: var(--serif); font-style: italic; font-size: 18px;
color: var(--metal-glow); font-variant-numeric: tabular-nums; margin-top: 2px;
}
.card .price.free { color: var(--metal); }
.card .loc {
font-size: 10px; letter-spacing: .12em; color: var(--ink-mute);
text-transform: uppercase; margin-top: auto; padding-top: 6px;
display: flex; justify-content: space-between; align-items: baseline;
}
.card .loc .pin { color: var(--metal); font-family: var(--serif); font-style: italic; text-transform: none; font-size: 12px; letter-spacing: 0; }
.card .loc .date { font-family: var(--mono); font-size: 9px; }
.empty {
max-width: 1380px; margin: 80px auto; padding: 0 24px; text-align: center;
font-family: var(--serif); font-style: italic; font-size: 22px; color: var(--ink-mute);
}
/* Modal — detail panel */
#modal-mask {
position: fixed; inset: 0; background: rgba(0,0,0,.72); z-index: 2000;
display: none; align-items: center; justify-content: center; padding: 24px;
backdrop-filter: blur(6px);
}
#modal-mask.show { display: flex; }
#modal {
background: var(--noir-rise); border: 1px solid var(--rule);
max-width: 640px; width: 100%; max-height: 88vh; overflow-y: auto;
position: relative;
}
#modal .mx {
position: absolute; top: 14px; right: 14px;
background: transparent; border: 1px solid var(--rule); color: var(--ink-mute);
padding: 6px 12px; font-size: 10px; letter-spacing: .18em; text-transform: uppercase; cursor: pointer;
}
#modal .mx:hover { color: var(--metal); border-color: var(--metal); }
#modal .img { aspect-ratio: 16 / 10; background: #1a1815; border-bottom: 1px solid var(--rule); position: relative; overflow: hidden; }
#modal .img img { width: 100%; height: 100%; object-fit: cover; }
#modal .body { padding: 26px 28px 30px; }
#modal .mk { font-size: 9px; letter-spacing: .28em; text-transform: uppercase; color: var(--metal); margin: 0 0 10px; font-weight: 500; }
#modal h2 { font-family: var(--serif); font-weight: 400; font-size: 28px; line-height: 1.18; color: var(--ink); margin: 0 0 8px; letter-spacing: -.005em; }
#modal .mp { font-family: var(--serif); font-style: italic; font-size: 28px; color: var(--metal-glow); margin: 0 0 18px; }
#modal .md { font-size: 14px; line-height: 1.6; color: var(--ink-soft); margin: 0 0 18px; white-space: pre-wrap; }
#modal .mrow { display: flex; justify-content: space-between; padding: 10px 0; border-top: 1px solid var(--rule); font-size: 12px; }
#modal .mrow .k { color: var(--ink-mute); letter-spacing: .04em; }
#modal .mrow .v { color: var(--ink); font-family: var(--serif); font-style: italic; font-size: 13px; }
#modal .cta {
display: block; margin-top: 22px; padding: 14px 18px;
border: 1px solid var(--metal); color: var(--metal-glow);
font-size: 11px; letter-spacing: .22em; text-transform: uppercase; font-weight: 500;
text-align: center; text-decoration: none;
transition: background 200ms ease, color 200ms ease;
}
#modal .cta:hover { background: var(--metal); color: var(--noir); }
/* Post-listing form modal */
#post-mask {
position: fixed; inset: 0; background: rgba(0,0,0,.78); z-index: 2100;
display: none; align-items: center; justify-content: center; padding: 24px;
backdrop-filter: blur(6px);
}
#post-mask.show { display: flex; }
#post {
background: var(--noir-rise); border: 1px solid var(--rule);
max-width: 540px; width: 100%; max-height: 88vh; overflow-y: auto; padding: 28px;
position: relative;
}
#post h2 { font-family: var(--serif); font-style: italic; font-size: 28px; margin: 0 0 6px; color: var(--ink); font-weight: 400; }
#post .sub { font-family: var(--serif); font-style: italic; color: var(--ink-mute); font-size: 13px; margin: 0 0 22px; }
#post .fld { margin-bottom: 14px; }
#post .fld label { display: block; font-size: 9px; letter-spacing: .26em; text-transform: uppercase; color: var(--ink-mute); margin-bottom: 6px; }
#post .fld input, #post .fld select, #post .fld textarea {
width: 100%; padding: 10px 14px; background: var(--noir); border: 1px solid var(--rule);
color: var(--ink); font-family: var(--sans); font-size: 13px; outline: none;
}
#post .fld input:focus, #post .fld select:focus, #post .fld textarea:focus { border-color: var(--metal); }
#post .fld textarea { resize: vertical; min-height: 90px; }
#post .row2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
#post .submit {
width: 100%; padding: 14px; background: var(--metal); color: var(--noir);
border: none; font-size: 11px; letter-spacing: .26em; text-transform: uppercase;
font-weight: 500; cursor: pointer; margin-top: 10px;
}
#post .submit:hover { background: var(--metal-glow); }
#post .submit:disabled { opacity: .5; cursor: not-allowed; }
#post .pclose {
position: absolute; top: 14px; right: 14px;
background: transparent; border: 1px solid var(--rule); color: var(--ink-mute);
padding: 6px 12px; font-size: 10px; letter-spacing: .18em; text-transform: uppercase; cursor: pointer;
}
#post .msg { padding: 12px; font-size: 12px; margin-top: 12px; }
#post .msg.ok { background: rgba(184,153,104,0.12); border: 1px solid var(--metal); color: var(--metal-glow); }
#post .msg.err { background: rgba(196,123,123,0.12); border: 1px solid #c47b7b; color: #c47b7b; }
/* FB Marketplace deeplink footer */
.fb-deeplink {
max-width: 1380px; margin: 40px auto; padding: 28px 24px;
border-top: 1px solid var(--rule); border-bottom: 1px solid var(--rule);
text-align: center;
}
.fb-deeplink .fk { font-size: 9px; letter-spacing: .3em; text-transform: uppercase; color: var(--metal); margin-bottom: 14px; font-weight: 500; }
.fb-deeplink .fp { font-family: var(--serif); font-style: italic; font-size: 17px; color: var(--ink-soft); margin: 0 0 18px; line-height: 1.5; }
.fb-deeplink a {
display: inline-block; margin: 4px; padding: 10px 18px; border: 1px solid var(--rule);
color: var(--metal); font-size: 10px; letter-spacing: .22em; text-transform: uppercase;
text-decoration: none; font-family: var(--sans); transition: all 200ms ease;
}
.fb-deeplink a:hover { background: var(--metal); color: var(--noir); border-color: var(--metal); }
</style>
</head>
<body>
<header>
<a class="logo" href="/">Ventura <em>Boulevard</em><span class="pill">Marketplace</span></a>
<button class="hamburger" aria-label="Menu" onclick="document.getElementById('drawer').classList.add('show');document.getElementById('drawer-mask').classList.add('show')">
<span class="bars"><i></i><i></i><i></i></span>
<span>Menu</span>
</button>
</header>
<div id="drawer-mask" onclick="document.getElementById('drawer').classList.remove('show');this.classList.remove('show')"></div>
<aside id="drawer">
<button class="dx" onclick="document.getElementById('drawer').classList.remove('show');document.getElementById('drawer-mask').classList.remove('show')">close ×</button>
<a href="/">Corridor map</a>
<a href="/marketplace.html" style="color:var(--metal)">Marketplace</a>
<a href="/today.html">Today</a>
<a href="/issue">The issue</a>
<a href="/search.html">Search</a>
<a href="/news.html">News</a>
<a href="/atlas.html">Atlas</a>
<a href="/scoreboard.html">Scoreboard</a>
<a href="/3d.html">3D corridor</a>
<a href="/wall.html">The wall</a>
<a href="/timeline.html">Timeline</a>
<a href="/pantone.html">Pantone</a>
<a href="/feedback.html">Feedback</a>
<a href="/about.html">About</a>
</aside>
<section class="lede">
<p class="kicker">Local · neighborhood-to-neighborhood</p>
<h1>The corridor's <em>marketplace</em>.</h1>
<p class="deck">Furniture, electronics, vehicles, rentals, services, jobs — listed by neighbors along Ventura Boulevard, from Sherman Oaks to Woodland Hills. A local board for the corridor. Looking for a wider net? <a href="https://www.facebook.com/marketplace/?ref=app_tab" target="_blank" rel="noopener noreferrer">Facebook Marketplace ↗</a> covers all of LA.</p>
</section>
<div class="controls">
<div>
<label class="ctl-label" for="q">Search</label>
<input class="ctl-input" id="q" type="search" placeholder="Couch, Tesla, wallpaper installer…" autocomplete="off">
</div>
<div>
<label class="ctl-label" for="sort">Sort</label>
<select class="ctl-select" id="sort">
<option value="newest">Newest</option>
<option value="price_asc">Price ↑</option>
<option value="price_desc">Price ↓</option>
<option value="title">Title A→Z</option>
</select>
</div>
<div>
<label class="ctl-label" for="maxp">Max price</label>
<select class="ctl-select" id="maxp">
<option value="0">Any</option>
<option value="10000">Under $100</option>
<option value="50000">Under $500</option>
<option value="100000">Under $1,000</option>
<option value="500000">Under $5,000</option>
<option value="2500000">Under $25,000</option>
</select>
</div>
<div>
<label class="ctl-label" for="density">Density</label>
<div class="ctl-density">
<input id="density" type="range" min="2" max="6" step="1" value="4">
<span class="v" id="density-v">4</span>
</div>
</div>
</div>
<div class="cat-bar" id="cat-bar"></div>
<div class="meta-row">
<div class="count"><b id="count">—</b> listings on the corridor</div>
<button class="post-cta" onclick="openPost()">+ Post a listing</button>
</div>
<div class="grid" id="grid"></div>
<div class="empty" id="empty" style="display:none">Nothing matches that filter yet.</div>
<section class="fb-deeplink">
<div class="fk">Wider net · external links</div>
<p class="fp">The corridor board above is local-only. For all of LA, Facebook Marketplace deeplinks below — opens on facebook.com.</p>
<a href="https://www.facebook.com/marketplace/?ref=app_tab" target="_blank" rel="noopener noreferrer">All categories</a>
<a href="https://www.facebook.com/marketplace/category/furniture" target="_blank" rel="noopener noreferrer">Furniture</a>
<a href="https://www.facebook.com/marketplace/category/electronics" target="_blank" rel="noopener noreferrer">Electronics</a>
<a href="https://www.facebook.com/marketplace/category/vehicles" target="_blank" rel="noopener noreferrer">Vehicles</a>
<a href="https://www.facebook.com/marketplace/category/propertyrentals" target="_blank" rel="noopener noreferrer">Rentals</a>
<a href="https://www.facebook.com/marketplace/category/apparel" target="_blank" rel="noopener noreferrer">Clothing</a>
<a href="https://www.facebook.com/marketplace/category/free" target="_blank" rel="noopener noreferrer">Free</a>
<a href="https://www.facebook.com/marketplace/category/jobs" target="_blank" rel="noopener noreferrer">Jobs</a>
</section>
<!-- Detail modal -->
<div id="modal-mask" onclick="if(event.target===this)closeModal()">
<div id="modal">
<button class="mx" onclick="closeModal()">close ×</button>
<div id="modal-body"></div>
</div>
</div>
<!-- Post-listing modal -->
<div id="post-mask" onclick="if(event.target===this)closePost()">
<div id="post">
<button class="pclose" onclick="closePost()">close ×</button>
<h2>Post a listing</h2>
<p class="sub">Visible to corridor neighbors. No fees, no algorithm.</p>
<form id="post-form" onsubmit="return submitPost(event)">
<div class="fld">
<label for="p-cat">Category *</label>
<select id="p-cat" required>
<option value="">— pick one —</option>
<option>Furniture</option>
<option>Electronics</option>
<option>Vehicles</option>
<option>Home Goods</option>
<option>Real Estate</option>
<option>Services</option>
<option>Jobs</option>
<option>Clothing</option>
<option>Free</option>
<option>Misc</option>
</select>
</div>
<div class="fld">
<label for="p-title">Title *</label>
<input id="p-title" type="text" maxlength="200" required placeholder="Mid-century walnut credenza">
</div>
<div class="row2">
<div class="fld">
<label for="p-price">Price (USD) — blank if free</label>
<input id="p-price" type="number" min="0" step="1" placeholder="450">
</div>
<div class="fld">
<label for="p-condition">Condition</label>
<select id="p-condition">
<option value="">—</option>
<option value="new">New</option>
<option value="like-new">Like new</option>
<option value="good">Good</option>
<option value="fair">Fair</option>
<option value="parts">For parts</option>
</select>
</div>
</div>
<div class="fld">
<label for="p-desc">Description</label>
<textarea id="p-desc" maxlength="4000" placeholder="Condition, age, dimensions, why you're selling…"></textarea>
</div>
<div class="row2">
<div class="fld">
<label for="p-loc">Neighborhood</label>
<select id="p-loc">
<option value="">—</option>
<option>Sherman Oaks</option>
<option>Studio City</option>
<option>Tarzana</option>
<option>Encino</option>
<option>Woodland Hills</option>
</select>
</div>
<div class="fld">
<label for="p-name">Your name</label>
<input id="p-name" type="text" maxlength="80" placeholder="Alex K.">
</div>
</div>
<div class="fld">
<label for="p-contact">Contact (email or phone)</label>
<input id="p-contact" type="text" maxlength="120" placeholder="alex@example.com">
</div>
<button class="submit" id="p-submit" type="submit">Post listing</button>
<div id="p-msg" style="display:none"></div>
</form>
</div>
</div>
<script>
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
// ─── State ─────────────────────────────────────────────────────────────
let CURRENT_CAT = 'all';
let LISTINGS = [];
let CATEGORIES = [];
// localStorage-persisted UI prefs
const PREF_KEY = 'vc-mp-prefs';
function loadPrefs() {
try { return JSON.parse(localStorage.getItem(PREF_KEY) || '{}'); } catch { return {}; }
}
function savePrefs(p) {
try { localStorage.setItem(PREF_KEY, JSON.stringify(p)); } catch {}
}
const prefs = loadPrefs();
if (prefs.sort) $('sort').value = prefs.sort;
if (prefs.maxp) $('maxp').value = prefs.maxp;
if (prefs.density) { $('density').value = prefs.density; document.documentElement.style.setProperty('--cols', prefs.density); $('density-v').textContent = prefs.density; }
if (prefs.cat) CURRENT_CAT = prefs.cat;
function persist() {
savePrefs({
sort: $('sort').value, maxp: $('maxp').value,
density: $('density').value, cat: CURRENT_CAT,
});
}
// ─── Currency formatting ───────────────────────────────────────────────
function fmtPrice(cents) {
if (cents == null) return { text: 'Free', cls: 'free' };
const dollars = cents / 100;
if (dollars >= 1000) return { text: '$' + Math.round(dollars).toLocaleString(), cls: '' };
return { text: '$' + dollars.toFixed(dollars % 1 ? 2 : 0), cls: '' };
}
function fmtDate(iso) {
try {
const d = new Date(iso);
const days = (Date.now() - d.getTime()) / 86400000;
if (days < 1) return 'today';
if (days < 2) return 'yesterday';
if (days < 30) return Math.floor(days) + 'd ago';
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
} catch { return ''; }
}
// ─── Data fetching ─────────────────────────────────────────────────────
async function loadCategories() {
try {
const r = await fetch('/api/marketplace/categories').then(r => r.json());
CATEGORIES = r.rows || [];
renderCategories(r.total || 0);
} catch (e) { console.error(e); }
}
function renderCategories(total) {
const bar = $('cat-bar');
const chips = [
`<button class="cat-chip ${CURRENT_CAT === 'all' ? 'active' : ''}" data-cat="all">All<span class="n">${total}</span></button>`,
...CATEGORIES.map(c => `<button class="cat-chip ${CURRENT_CAT === c.category ? 'active' : ''}" data-cat="${esc(c.category)}">${esc(c.category)}<span class="n">${c.n}</span></button>`),
];
bar.innerHTML = chips.join('');
bar.querySelectorAll('.cat-chip').forEach(el => {
el.addEventListener('click', () => {
CURRENT_CAT = el.dataset.cat;
persist();
bar.querySelectorAll('.cat-chip').forEach(x => x.classList.toggle('active', x.dataset.cat === CURRENT_CAT));
load();
});
});
}
async function load() {
const params = new URLSearchParams();
if (CURRENT_CAT && CURRENT_CAT !== 'all') params.set('category', CURRENT_CAT);
if ($('q').value.trim()) params.set('q', $('q').value.trim());
if ($('sort').value) params.set('sort', $('sort').value);
if ($('maxp').value && $('maxp').value !== '0') params.set('max_price', $('maxp').value);
try {
const r = await fetch('/api/marketplace?' + params.toString()).then(r => r.json());
LISTINGS = r.rows || [];
render();
} catch (e) { console.error(e); }
}
function render() {
$('count').textContent = LISTINGS.length.toLocaleString();
const grid = $('grid');
const empty = $('empty');
if (!LISTINGS.length) {
grid.innerHTML = '';
empty.style.display = '';
return;
}
empty.style.display = 'none';
grid.innerHTML = LISTINGS.map(l => {
const price = fmtPrice(l.price_cents);
const img = l.image_url
? `<img src="${esc(l.image_url)}" alt="${esc(l.title)}" loading="lazy" onerror="this.style.display='none';this.nextElementSibling.style.display='flex'"><div class="placeholder" style="display:none">${esc(l.title)}</div>`
: `<div class="placeholder">${esc(l.title)}</div>`;
const src = l.source_platform && l.source_platform !== 'local' ? `<span class="source">${esc(l.source_platform)}</span>` : '';
return `
<a class="card" data-id="${l.id}" href="#">
<div class="img">
${img}
<span class="cat-tag">${esc(l.category)}</span>
${src}
</div>
<div class="body">
<div class="title">${esc(l.title)}</div>
<div class="price ${price.cls}">${price.text}</div>
<div class="loc">
<span class="pin">${esc(l.location_label || '—')}</span>
<span class="date">${fmtDate(l.posted_at)}</span>
</div>
</div>
</a>`;
}).join('');
grid.querySelectorAll('.card').forEach(el => {
el.addEventListener('click', (e) => {
e.preventDefault();
openModal(parseInt(el.dataset.id, 10));
});
});
}
// ─── Detail modal ──────────────────────────────────────────────────────
function openModal(id) {
const l = LISTINGS.find(x => x.id === id);
if (!l) return;
const price = fmtPrice(l.price_cents);
const cond = l.condition ? l.condition.replace(/-/g, ' ') : null;
const imgHtml = l.image_url
? `<div class="img"><img src="${esc(l.image_url)}" alt="${esc(l.title)}"></div>`
: `<div class="img" style="display:flex;align-items:center;justify-content:center;color:var(--metal-deep);font-family:var(--serif);font-style:italic;font-size:22px;padding:24px;text-align:center">${esc(l.title)}</div>`;
const ctaHref = l.source_url || '#';
const ctaText = l.source_platform === 'facebook' ? 'Open on Facebook Marketplace ↗'
: l.source_platform === 'craigslist' ? 'Open on Craigslist ↗'
: l.source_platform === 'offerup' ? 'Open on OfferUp ↗'
: l.source_platform === 'nextdoor' ? 'Open on Nextdoor ↗'
: l.seller_name ? `Contact ${esc(l.seller_name)} ↗`
: 'View listing ↗';
$('modal-body').innerHTML = `
${imgHtml}
<div class="body">
<p class="mk">${esc(l.category)} · ${esc(l.location_label || 'Corridor')}</p>
<h2>${esc(l.title)}</h2>
<div class="mp ${price.cls}">${price.text}</div>
${l.description ? `<div class="md">${esc(l.description)}</div>` : ''}
${cond ? `<div class="mrow"><span class="k">Condition</span><span class="v">${esc(cond)}</span></div>` : ''}
${l.seller_name ? `<div class="mrow"><span class="k">Seller</span><span class="v">${esc(l.seller_name)}</span></div>` : ''}
<div class="mrow"><span class="k">Posted</span><span class="v">${fmtDate(l.posted_at)}</span></div>
<div class="mrow"><span class="k">Source</span><span class="v">${esc(l.source_platform)}</span></div>
${l.source_url ? `<a class="cta" href="${esc(l.source_url)}" target="_blank" rel="noopener noreferrer">${ctaText}</a>` : ''}
</div>`;
$('modal-mask').classList.add('show');
document.body.style.overflow = 'hidden';
}
function closeModal() {
$('modal-mask').classList.remove('show');
document.body.style.overflow = '';
}
// ─── Post-listing modal ────────────────────────────────────────────────
function openPost() {
$('post-mask').classList.add('show');
document.body.style.overflow = 'hidden';
}
function closePost() {
$('post-mask').classList.remove('show');
document.body.style.overflow = '';
$('p-msg').style.display = 'none';
}
async function submitPost(e) {
e.preventDefault();
const btn = $('p-submit');
const msg = $('p-msg');
btn.disabled = true;
btn.textContent = 'Posting…';
msg.style.display = 'none';
const priceVal = $('p-price').value.trim();
const body = {
category: $('p-cat').value,
title: $('p-title').value.trim(),
description: $('p-desc').value.trim(),
price_cents: priceVal ? Math.round(parseFloat(priceVal) * 100) : null,
condition: $('p-condition').value || null,
location_label: $('p-loc').value || null,
seller_name: $('p-name').value.trim() || null,
seller_contact: $('p-contact').value.trim() || null,
};
try {
const r = await fetch('/api/marketplace/post', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const j = await r.json();
if (!r.ok || j.error) throw new Error(j.error || 'Failed');
msg.className = 'msg ok';
msg.textContent = `Listing #${j.id} posted. Refreshing the grid…`;
msg.style.display = '';
setTimeout(() => {
closePost();
$('post-form').reset();
loadCategories();
load();
}, 1200);
} catch (err) {
msg.className = 'msg err';
msg.textContent = err.message || 'Failed to post. Try again.';
msg.style.display = '';
} finally {
btn.disabled = false;
btn.textContent = 'Post listing';
}
return false;
}
// ─── Wire up controls ──────────────────────────────────────────────────
let qDeb;
$('q').addEventListener('input', () => { clearTimeout(qDeb); qDeb = setTimeout(load, 180); });
$('sort').addEventListener('change', () => { persist(); load(); });
$('maxp').addEventListener('change', () => { persist(); load(); });
$('density').addEventListener('input', e => {
const v = e.target.value;
document.documentElement.style.setProperty('--cols', v);
$('density-v').textContent = v;
persist();
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape') { closeModal(); closePost(); }
});
loadCategories().then(load);
</script>
</body>
</html>