[object Object]

← back to Animals

yolo: Business owners can claim their listing

2fa8980c457cee7e197c1f715352856768bbc33c · 2026-05-08 17:19:54 -0700 · animal-yolo

Task: yolo.business-claim-flow
Prompt: In ~/Projects/animals: add a /clinic/:id/claim route. If logged-in user submits, it sends a verification email to the email already on file for that business. Click the link, businesses.claimed_by get

Files touched

Diff

commit 2fa8980c457cee7e197c1f715352856768bbc33c
Author: animal-yolo <steve@designerwallcoverings.com>
Date:   Fri May 8 17:19:54 2026 -0700

    yolo: Business owners can claim their listing
    
    Task: yolo.business-claim-flow
    Prompt: In ~/Projects/animals: add a /clinic/:id/claim route. If logged-in user submits, it sends a verification email to the email already on file for that business. Click the link, businesses.claimed_by get
---
 agents/animal-yolo/state.json      |   4 +-
 migrations/015_business_claims.sql |  39 ++++++
 src/lib/business_claim.js          | 112 +++++++++++++++++
 src/server/index.js                | 243 ++++++++++++++++++++++++++++++++++++-
 4 files changed, 393 insertions(+), 5 deletions(-)

diff --git a/agents/animal-yolo/state.json b/agents/animal-yolo/state.json
index cab1b8f..fb5b1f4 100644
--- a/agents/animal-yolo/state.json
+++ b/agents/animal-yolo/state.json
@@ -1,5 +1,5 @@
 {
-  "cursor": 6,
-  "runs": 6,
+  "cursor": 7,
+  "runs": 7,
   "started_at": "2026-05-08T07:01:22.386Z"
 }
\ No newline at end of file
diff --git a/migrations/015_business_claims.sql b/migrations/015_business_claims.sql
new file mode 100644
index 0000000..b3dde1a
--- /dev/null
+++ b/migrations/015_business_claims.sql
@@ -0,0 +1,39 @@
+-- Project: Animals — business-claim tokens.
+--
+-- Wires the /clinic/:id/claim flow:
+--   1. Logged-in user POSTs /clinic/:id/claim
+--   2. We generate a fresh token, persist it on businesses.claim_token,
+--      and send the click-link to businesses.email (the address already on
+--      file from the directory crawl).
+--   3. Anyone with access to that mailbox clicks the link → /clinic/:id/verify-claim?token=…
+--   4. Token is consumed (cleared) and businesses.claimed_by is set to the
+--      user_id stored alongside the token.
+--   5. From then on /clinic/:id/edit lets that user (and only that user)
+--      edit hours/website/phone.
+--
+-- claimed_by already existed (BIGINT, see 001_initial_schema.sql) — we are
+-- only adding the token-flow plumbing + claimed_at audit timestamp.
+--
+-- Single-token-at-a-time semantics: if a second user tries to claim while the
+-- first token is still live, the second token overwrites the first. The
+-- email-on-file owner is the arbiter — whoever's link they click, wins.
+
+BEGIN;
+
+ALTER TABLE businesses
+  ADD COLUMN IF NOT EXISTS claim_token         TEXT,
+  ADD COLUMN IF NOT EXISTS claim_token_user_id BIGINT,
+  ADD COLUMN IF NOT EXISTS claim_token_sent_at TIMESTAMPTZ,
+  ADD COLUMN IF NOT EXISTS claimed_at          TIMESTAMPTZ;
+
+-- Live tokens must be unique (NULL after consumption).
+CREATE UNIQUE INDEX IF NOT EXISTS idx_businesses_claim_token
+  ON businesses (claim_token)
+  WHERE claim_token IS NOT NULL;
+
+-- Helps the green-badge / "your listings" lookup.
+CREATE INDEX IF NOT EXISTS idx_biz_claimed_by
+  ON businesses (claimed_by)
+  WHERE claimed_by IS NOT NULL;
+
+COMMIT;
diff --git a/src/lib/business_claim.js b/src/lib/business_claim.js
new file mode 100644
index 0000000..154622c
--- /dev/null
+++ b/src/lib/business_claim.js
@@ -0,0 +1,112 @@
+// Business-claim flow — generate a token, persist it onto the businesses row,
+// send the click-link to the email already on file via George (localhost:9850),
+// then consume the token at /clinic/:id/verify-claim and set claimed_by.
+//
+// Mirrors the shape of email_verification.js (same George wire, same fire-and-
+// forget semantics, same esc/page helpers) so failure modes stay predictable
+// across both flows.
+
+import crypto from 'node:crypto';
+import { pool, one } from './db.js';
+import { log } from './log.js';
+
+const GEORGE_URL  = process.env.GEORGE_GMAIL_URL || 'http://localhost:9850';
+const GEORGE_AUTH = process.env.GEORGE_AUTH || 'admin:DWSecure2024!';
+const PUBLIC_BASE = process.env.PUBLIC_BASE_URL || 'https://animalsdirectory.com';
+const FROM_NAME   = process.env.GEORGE_FROM_NAME || 'AnimalsDirectory';
+
+export function generateToken() {
+  return crypto.randomBytes(32).toString('base64url');
+}
+
+// Mask an email for display: john.smith@vetowner.com → j***h@v***r.com
+// Used on /clinic/:id/claim so the user can verify they recognize the address
+// before submitting, without leaking the full address to drive-by viewers.
+export function maskEmail(email) {
+  if (!email || typeof email !== 'string') return '';
+  const at = email.indexOf('@');
+  if (at < 1) return '';
+  const local = email.slice(0, at);
+  const domain = email.slice(at + 1);
+  const maskPart = (s) => s.length <= 2 ? s[0] + '***' : s[0] + '***' + s[s.length - 1];
+  const dot = domain.lastIndexOf('.');
+  const host = dot > 0 ? domain.slice(0, dot) : domain;
+  const tld  = dot > 0 ? domain.slice(dot)     : '';
+  return `${maskPart(local)}@${maskPart(host)}${tld}`;
+}
+
+// Persist a fresh token onto the business row + send the email. Returns the
+// URL (mostly so dev/testing can grab it without round-tripping George).
+export async function issueAndSendClaimToken({ bizId, userId, biz }) {
+  if (!biz?.email) {
+    throw new Error('no email on file');
+  }
+  const token = generateToken();
+  await pool.query(
+    `UPDATE businesses
+        SET claim_token         = $1,
+            claim_token_user_id = $2,
+            claim_token_sent_at = NOW()
+      WHERE id = $3`,
+    [token, userId, bizId]
+  );
+  const url = `${PUBLIC_BASE}/clinic/${bizId}/verify-claim?token=${encodeURIComponent(token)}`;
+
+  // Fire-and-forget — don't block the request on George being healthy.
+  sendClaimEmail({ to: biz.email, biz, url }).catch(err => {
+    log.warn('claim email send failed', { bizId, userId, err: err.message });
+  });
+
+  return { token, url };
+}
+
+async function sendClaimEmail({ to, biz, url }) {
+  const subject = `Confirm ownership of ${biz.name} on AnimalsDirectory`;
+  const body = `
+<p>Hi,</p>
+<p>Someone (likely you) is asking to claim the AnimalsDirectory listing for <strong>${esc(biz.name)}</strong> in ${esc(biz.city || '')}${biz.state ? ', ' + esc(biz.state) : ''}.</p>
+<p>If that was you, click below to confirm. The person who started the claim will then be able to edit hours, website, and phone for this listing.</p>
+<p><a href="${esc(url)}" style="display:inline-block;background:#0f4d3a;color:#fff;padding:10px 18px;border-radius:6px;text-decoration:none">Confirm I own ${esc(biz.name)}</a></p>
+<p style="color:#666;font-size:.9em">Or paste this link into your browser:<br><code style="word-break:break-all">${esc(url)}</code></p>
+<p style="color:#888;font-size:.85em">If this wasn't you, ignore this email — the listing stays unclaimed unless someone with access to this mailbox clicks the link.</p>
+<p style="color:#888;font-size:.85em">— ${esc(FROM_NAME)}</p>
+`.trim();
+
+  const r = await fetch(`${GEORGE_URL}/api/send`, {
+    method: 'POST',
+    headers: {
+      'content-type': 'application/json',
+      'authorization': 'Basic ' + Buffer.from(GEORGE_AUTH).toString('base64'),
+    },
+    body: JSON.stringify({ to, subject, body }),
+    signal: AbortSignal.timeout(10000),
+  });
+  if (!r.ok) {
+    const text = await r.text().catch(() => '');
+    throw new Error(`george returned ${r.status}: ${text.slice(0, 200)}`);
+  }
+}
+
+// Consume the token and set claimed_by atomically. Returns the row on success
+// (so the caller knows which user_id won the claim and can redirect to the
+// edit page on their behalf), or null if the token is invalid / already used.
+export async function consumeClaimToken({ bizId, token }) {
+  if (!token || !bizId) return null;
+  const row = await one(
+    `UPDATE businesses
+        SET claimed_by         = claim_token_user_id,
+            claimed_at         = NOW(),
+            claim_token        = NULL,
+            claim_token_user_id = NULL
+      WHERE id = $1
+        AND claim_token = $2
+        AND claim_token_user_id IS NOT NULL
+      RETURNING id, name, claimed_by`,
+    [bizId, token]
+  );
+  return row || null;
+}
+
+function esc(s) {
+  return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
+}
diff --git a/src/server/index.js b/src/server/index.js
index d4bfc46..95a5900 100644
--- a/src/server/index.js
+++ b/src/server/index.js
@@ -33,6 +33,7 @@ import { dogFriends } from './dog_friends.js';
 import { attachDogPark } from './dogpark.js';
 import { avatarsRouter } from './avatars_router.js';
 import { attachUser, requireUser } from '../lib/auth.js';
+import { issueAndSendClaimToken, consumeClaimToken, maskEmail } from '../lib/business_claim.js';
 
 // SECURITY: middleware to gate admin routes registered directly on `app`
 // (the adminPitch router has its own internal gate, but `/admin`, `/admin/flags`,
@@ -939,8 +940,9 @@ app.get('/:cat', async (req, res, next) => {
   res.send(renderCategory({ category: req.params.cat, dbCategory: dbCat, businesses, ad }));
 });
 
-// Business detail
-app.get('/clinic/:id', async (req, res) => {
+// Business detail. attachUser so renderBusiness can swap the claim CTA for an
+// "Edit your listing" link when the viewer owns the claim.
+app.get('/clinic/:id', attachUser, async (req, res) => {
   const biz = await one('SELECT * FROM businesses WHERE id=$1 AND opt_out_flag=FALSE', [req.params.id]);
   if (!biz) return res.status(404).send(renderNotFound('Listing not found'));
   const audit = await one('SELECT * FROM site_audits WHERE business_id=$1 ORDER BY audited_at DESC LIMIT 1', [biz.id]);
@@ -952,9 +954,244 @@ app.get('/clinic/:id', async (req, res) => {
                   ORDER BY fetched_at DESC
                   LIMIT 60`, [biz.id])
     : [];
-  res.send(renderBusiness({ biz, audit, ad, animals }));
+  res.send(renderBusiness({ biz, audit, ad, animals, user: req.user }));
 });
 
+// ─── Business-claim flow ───────────────────────────────────────────────────
+//
+// /clinic/:id/claim          GET  → form (must be logged in; shows masked email)
+// /clinic/:id/claim          POST → issue token + send email to biz.email
+// /clinic/:id/verify-claim   GET  → token consumer; sets claimed_by + redirects
+// /clinic/:id/edit           GET  → owner-only edit form (hours / website / phone)
+// /clinic/:id/edit           POST → owner-only save
+//
+// Auth model: a logged-in user requests the claim, but the email-on-file is
+// the arbiter — whoever has the inbox + clicks the link wins. requireUser is
+// JSON-flavored (returns 401 JSON), so for HTML routes we redirect to /login.
+
+function requireUserHtml(req, res, next) {
+  if (!req.user) {
+    const next_ = encodeURIComponent(req.originalUrl);
+    return res.redirect(302, `/login?next=${next_}`);
+  }
+  next();
+}
+
+app.get('/clinic/:id/claim', attachUser, requireUserHtml, async (req, res) => {
+  const biz = await one('SELECT * FROM businesses WHERE id=$1 AND opt_out_flag=FALSE', [req.params.id]);
+  if (!biz) return res.status(404).send(renderNotFound('Listing not found'));
+  const masked = biz.email ? maskEmail(biz.email) : null;
+  const alreadyClaimed = !!biz.claimed_by;
+  res.send(renderClaimPage({ biz, masked, alreadyClaimed, user: req.user }));
+});
+
+app.post('/clinic/:id/claim', attachUser, requireUserHtml, async (req, res) => {
+  const biz = await one('SELECT * FROM businesses WHERE id=$1 AND opt_out_flag=FALSE', [req.params.id]);
+  if (!biz) return res.status(404).send(renderNotFound('Listing not found'));
+  if (biz.claimed_by) {
+    return res.status(409).send(renderClaimResult({
+      biz, ok: false,
+      message: 'This listing has already been claimed. If you are the rightful owner and need access, contact us.',
+    }));
+  }
+  if (!biz.email) {
+    return res.status(400).send(renderClaimResult({
+      biz, ok: false,
+      message: 'We do not have an email on file for this listing yet — claim by email is not available. Use the lead form below to contact us instead.',
+    }));
+  }
+  try {
+    await issueAndSendClaimToken({ bizId: biz.id, userId: req.user.id, biz });
+  } catch (err) {
+    log.error('claim issue failed', { bizId: biz.id, userId: req.user.id, err: err.message });
+    return res.status(500).send(renderClaimResult({
+      biz, ok: false,
+      message: 'Something went wrong sending the verification email. Try again in a minute.',
+    }));
+  }
+  res.send(renderClaimResult({
+    biz, ok: true,
+    message: `Verification email sent to ${maskEmail(biz.email)}. Click the link in that inbox to confirm — once you do, you'll be able to edit hours, website, and phone for this listing.`,
+  }));
+});
+
+app.get('/clinic/:id/verify-claim', async (req, res) => {
+  const id = parseInt(req.params.id, 10);
+  const token = String(req.query?.token || '');
+  if (!Number.isFinite(id) || !token) {
+    return res.status(400).send(renderClaimResult({
+      biz: { id, name: 'Listing' }, ok: false,
+      message: 'Missing or malformed claim link.',
+    }));
+  }
+  const row = await consumeClaimToken({ bizId: id, token });
+  if (!row) {
+    return res.status(400).send(renderClaimResult({
+      biz: { id, name: 'Listing' }, ok: false,
+      message: 'This claim link is invalid, expired, or has already been used.',
+    }));
+  }
+  log.info('business claim confirmed', { bizId: row.id, claimedBy: row.claimed_by });
+  res.send(renderClaimResult({
+    biz: row, ok: true,
+    message: `You're now the verified owner of ${row.name}. Edit hours, website, and phone any time from the listing's edit page.`,
+    editLink: `/clinic/${row.id}/edit`,
+  }));
+});
+
+app.get('/clinic/:id/edit', attachUser, requireUserHtml, async (req, res) => {
+  const biz = await one('SELECT * FROM businesses WHERE id=$1 AND opt_out_flag=FALSE', [req.params.id]);
+  if (!biz) return res.status(404).send(renderNotFound('Listing not found'));
+  if (biz.claimed_by !== req.user.id) {
+    return res.status(403).send(renderClaimResult({
+      biz, ok: false,
+      message: 'You are not the verified owner of this listing.',
+    }));
+  }
+  res.send(renderEditPage({ biz, user: req.user }));
+});
+
+app.post('/clinic/:id/edit', attachUser, requireUserHtml, async (req, res) => {
+  const biz = await one('SELECT id, claimed_by FROM businesses WHERE id=$1 AND opt_out_flag=FALSE', [req.params.id]);
+  if (!biz) return res.status(404).send(renderNotFound('Listing not found'));
+  if (biz.claimed_by !== req.user.id) return res.status(403).send('forbidden');
+
+  const { website, phone, hours } = req.body || {};
+  // Light normalization — empty strings → NULL so we don't overwrite real data
+  // with whitespace, and so the public detail page hides empty rows cleanly.
+  const cleanWebsite = (typeof website === 'string' && website.trim()) ? website.trim().slice(0, 500) : null;
+  const cleanPhone   = (typeof phone   === 'string' && phone.trim())   ? phone.trim().slice(0, 60)   : null;
+  // Hours is a freeform 7-line block (Mon: 9-5, Tue: 9-5, …). Stored as JSON
+  // alongside hours_json's existing schema-free shape — keyed by `freeform`
+  // so future structured-hours UI can coexist without a migration.
+  const cleanHours = (typeof hours === 'string' && hours.trim())
+    ? { freeform: hours.trim().slice(0, 2000) }
+    : null;
+
+  if (cleanWebsite && !/^https?:\/\//i.test(cleanWebsite)) {
+    return res.status(400).send(renderClaimResult({
+      biz: { id: biz.id, name: 'Listing' }, ok: false,
+      message: 'Website must start with http:// or https://.',
+    }));
+  }
+
+  await pool.query(
+    `UPDATE businesses
+        SET website    = $1,
+            phone      = $2,
+            hours_json = $3,
+            updated_at = NOW()
+      WHERE id = $4 AND claimed_by = $5`,
+    [cleanWebsite, cleanPhone, cleanHours, biz.id, req.user.id]
+  );
+  res.redirect(302, `/clinic/${biz.id}`);
+});
+
+function renderClaimPage({ biz, masked, alreadyClaimed, user }) {
+  const title = `Claim ${biz.name} — AnimalsDirectory`;
+  return `<!doctype html><html lang="en"><head>
+<meta charset="utf-8"><title>${esc(title)}</title>
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<style>
+  body{font-family:system-ui,sans-serif;max-width:560px;margin:48px auto;padding:0 20px;color:#111;line-height:1.55}
+  h1{font-weight:300;font-size:26px;margin:0 0 6px}
+  .muted{color:#5a6f63}
+  .card{background:#f8faf6;border:1px solid #d2dac8;border-radius:10px;padding:20px;margin:18px 0}
+  .btn{display:inline-block;background:#0f4d3a;color:#fff;padding:10px 18px;border-radius:6px;text-decoration:none;border:0;font:inherit;cursor:pointer}
+  .btn[disabled]{background:#9aa89a;cursor:not-allowed}
+  code{background:#eef2eb;padding:2px 6px;border-radius:4px;font-size:13px}
+  .back{display:inline-block;margin-top:18px;color:#0f4d3a;text-decoration:none}
+</style></head><body>
+<h1>Claim this listing</h1>
+<p class="muted"><strong>${esc(biz.name)}</strong>${biz.city ? ' · '+esc(biz.city) : ''}${biz.state ? ', '+esc(biz.state) : ''}</p>
+${alreadyClaimed ? `
+  <div class="card">
+    <p>This listing has already been claimed. If you're the rightful owner and need access, <a href="/lead?biz=${biz.id}">contact us</a>.</p>
+  </div>
+` : !biz.email ? `
+  <div class="card">
+    <p>We don't have an email on file for this listing yet, so claim-by-email isn't available.</p>
+    <p>Use the <a href="/lead?biz=${biz.id}">contact form</a> and we'll get you set up manually.</p>
+  </div>
+` : `
+  <div class="card">
+    <p>To verify you own ${esc(biz.name)}, we'll email a confirmation link to the address already on file:</p>
+    <p style="font-size:18px;margin:12px 0"><code>${esc(masked)}</code></p>
+    <p class="muted" style="font-size:13px">If that's not your inbox — for example, the listing's email is wrong — use the <a href="/lead?biz=${biz.id}">contact form</a> instead and we'll sort it out.</p>
+    <form method="POST" action="/clinic/${biz.id}/claim" style="margin-top:14px">
+      <button type="submit" class="btn">Email me the confirmation link</button>
+    </form>
+  </div>
+`}
+<a class="back" href="/clinic/${biz.id}">← Back to listing</a>
+</body></html>`;
+}
+
+function renderClaimResult({ biz, ok, message, editLink }) {
+  const title = ok ? 'Claim updated' : 'Claim problem';
+  const color = ok ? '#0f4d3a' : '#c0382b';
+  return `<!doctype html><html lang="en"><head>
+<meta charset="utf-8"><title>${esc(title)} — AnimalsDirectory</title>
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<style>
+  body{font-family:system-ui,sans-serif;max-width:560px;margin:48px auto;padding:0 20px;color:#111;line-height:1.55}
+  h1{font-weight:400;color:${color};margin:0 0 8px;font-size:24px}
+  .btn{display:inline-block;background:#0f4d3a;color:#fff;padding:10px 18px;border-radius:6px;text-decoration:none;margin-top:18px;margin-right:8px}
+  .btn-secondary{background:#fff;color:#0f4d3a;border:1px solid #0f4d3a}
+</style></head><body>
+<h1>${esc(title)}</h1>
+<p>${esc(message)}</p>
+${editLink ? `<a class="btn" href="${esc(editLink)}">Edit listing →</a>` : ''}
+<a class="btn ${editLink ? 'btn-secondary' : ''}" href="/clinic/${biz.id}">Back to ${esc(biz.name || 'listing')}</a>
+</body></html>`;
+}
+
+function renderEditPage({ biz, user }) {
+  // hours_json is freeform-shaped (see POST handler) — we stored it under .freeform.
+  // Older rows might have schema-driven hours; render whatever string we can find
+  // for round-tripping. Falls back to empty.
+  const hoursDefault = (() => {
+    const h = biz.hours_json;
+    if (!h) return '';
+    if (typeof h === 'string') return h;
+    if (typeof h.freeform === 'string') return h.freeform;
+    try { return JSON.stringify(h, null, 2); } catch { return ''; }
+  })();
+  return `<!doctype html><html lang="en"><head>
+<meta charset="utf-8"><title>Edit ${esc(biz.name)} — AnimalsDirectory</title>
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<style>
+  body{font-family:system-ui,sans-serif;max-width:640px;margin:48px auto;padding:0 20px;color:#111;line-height:1.55}
+  h1{font-weight:300;font-size:26px;margin:0 0 6px}
+  .muted{color:#5a6f63}
+  label{display:block;margin:18px 0 6px;font-weight:500;font-size:14px}
+  input[type=text],input[type=url],input[type=tel],textarea{
+    width:100%;padding:10px 12px;border:1px solid #d2dac8;border-radius:6px;font:inherit;background:#fff;box-sizing:border-box}
+  textarea{min-height:140px;font-family:ui-monospace,Menlo,monospace;font-size:13px}
+  .btn{display:inline-block;background:#0f4d3a;color:#fff;padding:10px 18px;border-radius:6px;text-decoration:none;border:0;font:inherit;cursor:pointer;margin-top:20px}
+  .btn-secondary{background:#fff;color:#0f4d3a;border:1px solid #0f4d3a;margin-left:8px}
+  .verified-badge{display:inline-block;background:#dcefe2;color:#0f4d3a;border:1px solid #7eaf91;padding:2px 10px;border-radius:999px;font-size:12px;font-weight:500;margin-left:8px}
+</style></head><body>
+<h1>Edit listing <span class="verified-badge">✓ Claimed</span></h1>
+<p class="muted">${esc(biz.name)}${biz.city ? ' · '+esc(biz.city) : ''}${biz.state ? ', '+esc(biz.state) : ''}</p>
+<p class="muted" style="font-size:13px">Editing as <strong>${esc(user.email)}</strong>. Changes go live immediately.</p>
+
+<form method="POST" action="/clinic/${biz.id}/edit">
+  <label for="website">Website</label>
+  <input id="website" name="website" type="url" placeholder="https://example.com" value="${esc(biz.website || '')}">
+
+  <label for="phone">Phone</label>
+  <input id="phone" name="phone" type="tel" placeholder="(555) 123-4567" value="${esc(biz.phone || '')}">
+
+  <label for="hours">Hours <span class="muted" style="font-weight:400">(freeform — one line per day, or however you like)</span></label>
+  <textarea id="hours" name="hours" placeholder="Mon-Fri: 8am-6pm&#10;Sat: 9am-2pm&#10;Sun: closed">${esc(hoursDefault)}</textarea>
+
+  <button type="submit" class="btn">Save changes</button>
+  <a class="btn btn-secondary" href="/clinic/${biz.id}">Cancel</a>
+</form>
+</body></html>`;
+}
+
 // Owner pets gallery — /my-pets is a PRIVATE dashboard for the logged-in user.
 // (Codex r2 CRITICAL 2026-05-01: previously this route had no auth, no
 // share_publicly filter, and listed every owner's pets+photos+bios on the

← 33ff008 yolo: Pet store detail: 'what they sell' section  ·  back to Animals  ·  yolo: Lost-pet listing → SMS alert to neighbors in ZIP f2d7975 →