Files
veripath/tools/gbp-snapshot-form.html
T

1215 lines
41 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>DOP — GBP Snapshot Intake v1.4</title>
<style>
:root {
--bg: #12151b;
--surface: #1a1e26;
--surface-alt: #1e232c;
--input-bg: #111318;
--line: #2a303d;
--line-soft: #222732;
--ink: #e3dfd6;
--ink-dim: #a0a7b4;
--muted: #78809b;
--stamp: #d16a34;
--stamp-dim: #8a4e2e;
--stamp-hover: #b85a2a;
--verified: #4f9d8f;
--indicative: #c9a227;
--tag-bg: #1f1a18;
--mono: ui-monospace, "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace;
--sans: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: var(--sans);
background: var(--bg);
color: var(--ink);
line-height: 1.45;
}
.wrap {
max-width: 920px;
margin: 0 auto;
padding: 28px 16px 56px;
}
/* ── Header ── */
.header-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 8px;
}
h1 {
font-family: var(--mono);
font-size: 1rem;
letter-spacing: 0.03em;
margin: 0;
font-weight: 600;
color: var(--ink);
}
h1 + .sub {
margin-top: 8px;
}
h1 .version {
color: var(--stamp);
font-weight: 700;
}
.header-progress {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.progress-ring {
display: flex;
gap: 5px;
}
.progress-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--line);
transition: background 0.25s;
}
.progress-dot.done { background: var(--verified); }
.progress-dot.partial { background: var(--indicative); }
.progress-label {
font-family: var(--mono);
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
}
.sub {
color: var(--muted);
font-size: 0.86rem;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px dashed var(--line);
}
.sub strong { color: var(--ink); font-weight: 600; }
.vocab-inline {
display: inline-block;
margin: 0 2px;
font-family: var(--mono);
font-size: 0.75rem;
background: var(--surface-alt);
padding: 0 8px;
border-radius: 3px;
color: var(--stamp);
}
/* ── Client Presets ── */
.preset-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 16px;
padding: 12px 14px;
background: var(--surface);
border: 1px solid var(--line-soft);
border-radius: 4px;
}
.preset-bar label {
font-family: var(--mono);
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
margin-right: 2px;
}
.preset-bar select {
background: var(--input-bg);
border: 1px solid var(--line);
border-radius: 4px;
color: var(--ink);
padding: 6px 10px;
font-size: 0.85rem;
outline: none;
font-family: var(--sans);
flex: 1;
min-width: 160px;
}
.preset-bar select:focus { border-color: var(--stamp); }
.preset-btn {
background: transparent;
border: 1px solid var(--line);
border-radius: 4px;
color: var(--ink-dim);
padding: 6px 12px;
font-size: 0.78rem;
font-family: var(--mono);
cursor: pointer;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.preset-btn:hover {
color: var(--stamp);
border-color: var(--stamp);
}
/* ── Cards (collapsible sections) ── */
.card {
position: relative;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 4px;
margin-bottom: 12px;
overflow: hidden;
transition: border-color 0.2s;
}
.card.has-data { border-color: var(--line); }
.card.is-open { border-color: var(--line); }
.card-header {
display: flex;
align-items: center;
gap: 10px;
padding: 16px 18px;
cursor: pointer;
user-select: none;
-webkit-user-select: none;
transition: background 0.15s;
}
.card-header:hover { background: var(--surface-alt); }
.card-header .tag-no {
font-family: var(--mono);
font-size: 0.65rem;
font-weight: 700;
color: var(--stamp);
letter-spacing: 0.04em;
min-width: 20px;
}
.card-header .section-title {
flex: 1;
font-family: var(--mono);
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
font-weight: 500;
}
.card-header .section-status {
font-family: var(--mono);
font-size: 0.6rem;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 2px 8px;
border-radius: 10px;
background: var(--line-soft);
color: var(--ink-dim);
}
.card-header .section-status.done {
background: rgba(79,157,143,0.12);
color: var(--verified);
}
.card-header .section-arrow {
font-size: 0.75rem;
color: var(--muted);
transition: transform 0.2s;
}
.card.is-open .section-arrow {
transform: rotate(90deg);
}
.card-body {
padding: 0 18px 18px;
display: none;
}
.card.is-open .card-body {
display: block;
}
.card-body .grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px 14px;
}
@media (max-width: 640px) {
.card-body .grid { grid-template-columns: 1fr; }
}
.card-body .full { grid-column: 1 / -1; }
.card-body label {
display: block;
font-size: 0.8rem;
color: var(--muted);
margin-bottom: 4px;
}
.card-body .req {
display: inline-block;
margin-left: 6px;
padding: 1px 6px;
font-family: var(--mono);
font-size: 0.6rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--stamp);
border: 1px solid var(--stamp);
border-radius: 2px;
vertical-align: middle;
}
.card-body .req.met {
color: var(--verified);
border-color: var(--verified);
}
.card-body .vocab-hint {
font-size: 0.78rem;
color: var(--muted);
font-style: italic;
margin-bottom: 6px;
margin-left: 2px;
}
input[type="text"],
input[type="url"],
select,
textarea {
width: 100%;
background: var(--input-bg);
border: 1px solid var(--line);
border-radius: 5px;
color: var(--ink);
padding: 9px 11px;
font-size: 0.95rem;
outline: none;
font-family: var(--sans);
transition: border-color 0.15s;
}
input:focus, select:focus, textarea:focus {
border-color: var(--stamp);
}
#place_id, #computed_ids {
font-family: var(--mono);
font-size: 0.85rem;
color: var(--indicative);
}
textarea { min-height: 72px; resize: vertical; }
/* ── Actions ── */
.actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin: 20px 0 10px;
}
button {
border: none;
border-radius: 5px;
padding: 11px 18px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
}
.btn-primary {
background: var(--stamp);
color: #fff;
font-family: var(--mono);
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.82rem;
}
.btn-primary:hover { background: var(--stamp-hover); }
.btn-secondary {
background: transparent;
color: var(--ink);
border: 1px solid var(--line);
}
.btn-secondary:hover { border-color: var(--muted); }
.btn-void {
background: transparent;
color: var(--muted);
border: 1px dashed var(--line);
font-family: var(--mono);
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.btn-void:hover {
color: var(--stamp);
border-color: var(--stamp);
}
.btn-save {
background: transparent;
color: var(--verified);
border: 1px solid var(--verified);
font-family: var(--mono);
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.btn-save:hover {
background: rgba(79,157,143,0.08);
}
.status {
font-size: 0.85rem;
font-family: var(--mono);
color: var(--verified);
min-height: 1.2em;
margin-bottom: 8px;
}
label[for="output"] {
font-family: var(--mono);
text-transform: uppercase;
font-size: 0.7rem;
letter-spacing: 0.1em;
color: var(--muted);
}
#output {
width: 100%;
min-height: 360px;
font-family: var(--mono);
font-size: 0.82rem;
line-height: 1.4;
background: var(--input-bg);
border: 1px solid var(--line);
border-radius: 6px;
color: var(--ink);
padding: 12px;
white-space: pre;
margin-top: 6px;
}
.hint {
font-size: 0.8rem;
color: var(--muted);
margin-top: 10px;
}
.hint kbd {
display: inline-block;
font-family: var(--mono);
font-size: 0.72rem;
background: var(--surface-alt);
padding: 1px 6px;
border-radius: 3px;
border: 1px solid var(--line);
color: var(--ink-dim);
}
/* ── Toast ── */
#toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%) translateY(80px);
font-family: var(--mono);
font-size: 0.78rem;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 6px;
padding: 10px 20px;
color: var(--ink);
opacity: 0;
transition: opacity 0.3s, transform 0.3s;
pointer-events: none;
z-index: 100;
}
#toast.show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
#toast.ok { border-color: var(--verified); }
#toast.warn { border-color: var(--indicative); }
</style>
</head>
<body>
<div class="wrap">
<div class="header-row">
<div>
<h1>GBP Snapshot Intake <span class="version">v1.4</span></h1>
<div class="sub">
<span class="vocab-inline">NOT_PRESENT</span> = looked, confirmed absent &nbsp;·&nbsp;
<span class="vocab-inline">NOT_OBSERVED</span> = didn't look / couldn't see &nbsp;·&nbsp;
Paste to Leonard → R1R6
</div>
</div>
<div class="header-progress">
<div class="progress-label" id="progLabel">0/8</div>
<div class="progress-ring" id="progRing">
<div class="progress-dot" data-idx="0"></div>
<div class="progress-dot" data-idx="1"></div>
<div class="progress-dot" data-idx="2"></div>
<div class="progress-dot" data-idx="3"></div>
<div class="progress-dot" data-idx="4"></div>
<div class="progress-dot" data-idx="5"></div>
<div class="progress-dot" data-idx="6"></div>
<div class="progress-dot" data-idx="7"></div>
</div>
</div>
</div>
<!-- Client Presets -->
<div class="preset-bar">
<label>Client</label>
<select id="presetSelect">
<option value="">— New client —</option>
</select>
<button type="button" class="preset-btn" id="btnSavePreset">Save</button>
<button type="button" class="preset-btn" id="btnDeletePreset">Delete</button>
</div>
<form id="gbpForm" autocomplete="off">
<!-- 01: Snapshot Metadata -->
<div class="card is-open" data-section="0">
<div class="card-header">
<span class="tag-no">01</span>
<span class="section-title">Snapshot Metadata</span>
<span class="section-status" id="status0">incomplete</span>
<span class="section-arrow"></span>
</div>
<div class="card-body">
<div class="grid">
<div>
<label for="client">Client name<span class="req" id="req-client">required</span></label>
<input id="client" type="text" placeholder="Client name" />
</div>
<div>
<label for="snapshot_dt">Datetime + TZ<span class="req" id="req-snapshot_dt">required</span></label>
<input id="snapshot_dt" type="text" placeholder="2026-08-04T14:30:00-07:00" />
</div>
<div>
<label for="source">Source</label>
<select id="source">
<option value="Human eye on Maps">Human eye on Maps</option>
<option value="Places API">Places API</option>
<option value="Owner dashboard">Owner dashboard</option>
</select>
</div>
<div>
<label for="surface">Observation surface</label>
<select id="surface">
<option value="">— select —</option>
<option value="desktop_maps">desktop_maps</option>
<option value="mobile_maps">mobile_maps</option>
<option value="knowledge_panel">knowledge_panel</option>
<option value="other">other</option>
</select>
</div>
<div class="full">
<label for="maps_url">Maps URL<span class="req" id="req-maps_url">required</span></label>
<input id="maps_url" type="url" placeholder="https://www.google.com/maps/place/..." />
</div>
<div>
<label for="place_id">Place ID</label>
<input id="place_id" type="text" readonly placeholder="auto-computed from URL" />
</div>
<div>
<label for="computed_ids">feature · CID</label>
<input id="computed_ids" type="text" readonly placeholder="auto-computed" />
</div>
</div>
</div>
</div>
<!-- 02: Core Fields -->
<div class="card" data-section="1">
<div class="card-header">
<span class="tag-no">02</span>
<span class="section-title">Core Fields</span>
<span class="section-status" id="status1">incomplete</span>
<span class="section-arrow"></span>
</div>
<div class="card-body">
<div class="vocab-hint">Copy exactly as shown. NOT_OBSERVED if you didn't see it.</div>
<div class="grid">
<div>
<label for="business_name">Business name shown</label>
<input id="business_name" type="text" />
</div>
<div>
<label for="address">Address shown (exact)</label>
<input id="address" type="text" />
</div>
<div>
<label for="address_canonical">Address canonical</label>
<input id="address_canonical" type="text" placeholder="Suite not #, Drive not Dr" />
</div>
<div>
<label for="phone">Phone (full digits, no masking)</label>
<input id="phone" type="text" placeholder="+1 555-010-0100" />
</div>
<div>
<label for="website">Website shown</label>
<input id="website" type="text" />
</div>
<div>
<label for="primary_category">Primary category <strong>(ONE value)</strong></label>
<input id="primary_category" type="text" placeholder="e.g. Gym · Auto repair shop" />
</div>
<div class="full">
<label for="additional_categories">Additional categories (comma-separated)</label>
<input id="additional_categories" type="text" />
</div>
</div>
</div>
</div>
<!-- 03: Reputation -->
<div class="card" data-section="2">
<div class="card-header">
<span class="tag-no">03</span>
<span class="section-title">Reputation</span>
<span class="section-status" id="status2">incomplete</span>
<span class="section-arrow"></span>
</div>
<div class="card-body">
<div class="grid">
<div>
<label for="star_rating">Star rating</label>
<input id="star_rating" type="text" placeholder="e.g. 4.8" />
</div>
<div>
<label for="review_count">Review count</label>
<input id="review_count" type="text" placeholder="e.g. 42" />
</div>
<div>
<label for="review_responses">Review responses observed?</label>
<select id="review_responses">
<option value="NOT_OBSERVED">NOT_OBSERVED</option>
<option value="Yes">Yes</option>
<option value="No">No</option>
</select>
</div>
<div>
<label for="review_responses_detail">If Yes: N with replies + recency</label>
<input id="review_responses_detail" type="text" placeholder="e.g. 3 of 5, most recent 12d ago" />
</div>
</div>
</div>
</div>
<!-- 04: Ownership / Claim -->
<div class="card" data-section="3">
<div class="card-header">
<span class="tag-no">04</span>
<span class="section-title">Ownership / Claim Signals</span>
<span class="section-status" id="status3">incomplete</span>
<span class="section-arrow"></span>
</div>
<div class="card-body">
<div class="grid">
<div>
<label for="claim_prompt">"Own this business?" prompt visible</label>
<select id="claim_prompt">
<option value="NOT_OBSERVED">NOT_OBSERVED</option>
<option value="No">No</option>
<option value="Yes">Yes</option>
</select>
</div>
<div>
<label for="claimed_status">Claimed status</label>
<select id="claimed_status">
<option value="Unknown">Unknown</option>
<option value="Claimed">Claimed</option>
<option value="Unclaimed">Unclaimed</option>
<option value="Likely Claimed — inferred">Likely Claimed — inferred</option>
</select>
</div>
<div class="full">
<label for="claimed_signals">If inferred — signals used</label>
<input id="claimed_signals" type="text" placeholder="e.g. No claim prompt; review responses observed" />
</div>
<div>
<label for="verified_badge">Verified badge observed?</label>
<select id="verified_badge">
<option value="NOT_OBSERVED">NOT_OBSERVED</option>
<option value="Yes">Yes</option>
<option value="No">No</option>
</select>
</div>
</div>
</div>
</div>
<!-- 05: Operations -->
<div class="card" data-section="4">
<div class="card-header">
<span class="tag-no">05</span>
<span class="section-title">Operations</span>
<span class="section-status" id="status4">incomplete</span>
<span class="section-arrow"></span>
</div>
<div class="card-body">
<div class="grid">
<div>
<label for="hours_listed">Hours listed</label>
<select id="hours_listed">
<option value="NOT_OBSERVED">NOT_OBSERVED</option>
<option value="Yes">Yes</option>
<option value="No">No</option>
</select>
</div>
<div>
<label for="open_now">Open now (valid only with datetime+TZ)</label>
<select id="open_now">
<option value="NOT_OBSERVED">NOT_OBSERVED</option>
<option value="Yes">Yes</option>
<option value="No">No</option>
</select>
</div>
<div class="full">
<label for="hours_text">Hours text</label>
<input id="hours_text" type="text" placeholder="e.g. MonFri 95, Sat 102, Sun Closed" />
</div>
</div>
</div>
</div>
<!-- 06: Conversion / Action Buttons -->
<div class="card" data-section="5">
<div class="card-header">
<span class="tag-no">06</span>
<span class="section-title">Action Buttons — complete row if any button observed</span>
<span class="section-status" id="status5">incomplete</span>
<span class="section-arrow"></span>
</div>
<div class="card-body">
<div class="vocab-hint">Map the full GBP action-button row. If you see one, capture all.</div>
<div class="grid">
<div>
<label for="booking_button">Booking button <strong>on the GBP</strong></label>
<select id="booking_button">
<option value="NOT_OBSERVED">NOT_OBSERVED</option>
<option value="Yes">Yes</option>
<option value="No NOT_PRESENT">No — NOT_PRESENT</option>
</select>
</div>
<div>
<label for="booking_label">Booking label</label>
<input id="booking_label" type="text" placeholder="e.g. Book appointment" />
</div>
<div>
<label for="messaging_button">Messaging button</label>
<select id="messaging_button">
<option value="NOT_OBSERVED">NOT_OBSERVED</option>
<option value="Yes">Yes</option>
<option value="No NOT_PRESENT">No — NOT_PRESENT</option>
</select>
</div>
<div>
<label for="call_button">Call button</label>
<select id="call_button">
<option value="NOT_OBSERVED">NOT_OBSERVED</option>
<option value="Yes">Yes</option>
<option value="No NOT_PRESENT">No — NOT_PRESENT</option>
</select>
</div>
<div>
<label for="directions_button">Directions button</label>
<select id="directions_button">
<option value="NOT_OBSERVED">NOT_OBSERVED</option>
<option value="Yes">Yes</option>
<option value="No NOT_PRESENT">No — NOT_PRESENT</option>
</select>
</div>
<div class="full">
<label for="other_action_buttons">Other action buttons</label>
<input id="other_action_buttons" type="text" placeholder="e.g. Order, Sign up, Get offer" />
</div>
</div>
</div>
</div>
<!-- 07: Content Surface -->
<div class="card" data-section="6">
<div class="card-header">
<span class="tag-no">07</span>
<span class="section-title">Content Surface</span>
<span class="section-status" id="status6">incomplete</span>
<span class="section-arrow"></span>
</div>
<div class="card-body">
<div class="grid">
<div>
<label for="photos_present">Photos present?</label>
<select id="photos_present">
<option value="NOT_OBSERVED">NOT_OBSERVED</option>
<option value="Yes">Yes</option>
<option value="No">No</option>
</select>
</div>
<div>
<label for="photo_count">Approx photo count</label>
<input id="photo_count" type="text" placeholder="e.g. 6" />
</div>
<div class="full">
<label for="services_listed">Services listed (exact labels)</label>
<input id="services_listed" type="text" placeholder="e.g. as shown on profile, comma-separated" />
</div>
<div>
<label for="posts_section_present">Posts / Updates present?</label>
<select id="posts_section_present">
<option value="NOT_OBSERVED">NOT_OBSERVED</option>
<option value="Yes">Yes</option>
<option value="No NOT_PRESENT">No — NOT_PRESENT</option>
</select>
</div>
<div>
<label for="post_count">Post count (0 if empty)</label>
<input id="post_count" type="text" placeholder="0" />
</div>
<div>
<label for="most_recent_post_date">Most recent post date</label>
<input id="most_recent_post_date" type="text" />
</div>
<div>
<label for="qa_status">Q&amp;A status</label>
<select id="qa_status">
<option value="NOT_OBSERVED">NOT_OBSERVED</option>
<option value="NOT_PRESENT">NOT_PRESENT</option>
<option value="Visible but empty">Visible but empty</option>
<option value="Answers present">Answers present</option>
</select>
</div>
<div class="full">
<label for="attributes">Attributes / highlights</label>
<input id="attributes" type="text" placeholder="e.g. Wheelchair accessible, Veteran-owned" />
</div>
</div>
</div>
</div>
<!-- 08: Related Listings -->
<div class="card" data-section="7">
<div class="card-header">
<span class="tag-no">08</span>
<span class="section-title">Related Listings</span>
<span class="section-status" id="status7">incomplete</span>
<span class="section-arrow"></span>
</div>
<div class="card-body">
<div class="grid">
<div>
<label for="second_listing">Second related listing nearby?</label>
<select id="second_listing">
<option value="Unknown">Unknown</option>
<option value="Yes">Yes</option>
<option value="No">No</option>
</select>
</div>
<div>
<label for="second_listing_names">Related listing name(s)</label>
<input id="second_listing_names" type="text" />
</div>
<div class="full">
<label for="second_listing_notes">Related listing notes (factual only)</label>
<textarea id="second_listing_notes" placeholder="What relationship? Different suite? Same building?"></textarea>
</div>
<div class="full">
<label for="raw_notes">Raw notes (factual only)</label>
<textarea id="raw_notes" placeholder="Anything else worth capturing — no analysis, just observation"></textarea>
</div>
</div>
</div>
</div>
</form>
<div class="actions">
<button type="button" class="btn-primary" id="btnGenerate">Generate for Leonard</button>
<button type="button" class="btn-secondary" id="btnCopy">Copy</button>
<button type="button" class="btn-secondary" id="btnDownload">Download</button>
<button type="button" class="btn-save" id="btnSaveState">Save Draft</button>
<button type="button" class="btn-void" id="btnClear">Clear</button>
</div>
<div class="status" id="status"></div>
<label for="output">Leonard-ready output</label>
<textarea id="output" readonly placeholder="Click Generate for Leonard…"></textarea>
<p class="hint"><kbd>Ctrl+Enter</kbd> to generate · Draft auto-saved to browser · Use client presets for repeat audits</p>
</div>
<div id="toast"></div>
<script>
const $ = (id) => document.getElementById(id);
const SAVE_KEY = "gbp-intake-v1.4-draft";
const PRESETS_KEY = "gbp-intake-v1.4-presets";
const SECTIONS = 8;
/* ── localStorage helpers ── */
function saveDraft() {
const data = {};
const fields = document.querySelectorAll("#gbpForm input, #gbpForm select, #gbpForm textarea");
fields.forEach(el => { data[el.id] = el.value; });
// Save which sections are open
data["_open"] = [];
document.querySelectorAll(".card.is-open").forEach(c => {
data["_open"].push(c.dataset.section);
});
localStorage.setItem(SAVE_KEY, JSON.stringify(data));
}
function loadDraft() {
const raw = localStorage.getItem(SAVE_KEY);
if (!raw) return;
try {
const data = JSON.parse(raw);
const opens = data["_open"] || [];
Object.keys(data).forEach(key => {
if (key === "_open") return;
const el = document.getElementById(key);
if (el) el.value = data[key];
});
// Restore open sections
opens.forEach(idx => {
const card = document.querySelector(`.card[data-section="${idx}"]`);
if (card) card.classList.add("is-open");
});
// Always open section 0 if nothing else
if (opens.length === 0) {
const first = document.querySelector('.card[data-section="0"]');
if (first) first.classList.add("is-open");
}
} catch(e) {}
}
/* ── Presets ── */
function getPresets() {
try { return JSON.parse(localStorage.getItem(PRESETS_KEY)) || {}; }
catch(e) { return {}; }
}
function savePresets(presets) {
localStorage.setItem(PRESETS_KEY, JSON.stringify(presets));
}
function populatePresetSelect() {
const sel = $("presetSelect");
const presets = getPresets();
const current = sel.value;
sel.innerHTML = '<option value="">— New client —</option>';
Object.keys(presets).sort().forEach(name => {
const opt = document.createElement("option");
opt.value = name;
opt.textContent = name;
sel.appendChild(opt);
});
if (current && presets[current]) sel.value = current;
}
function loadPreset(name) {
const presets = getPresets();
const data = presets[name];
if (!data) return;
Object.keys(data).forEach(key => {
const el = document.getElementById(key);
if (el) el.value = data[key];
});
// Trigger ID computation for preset URL
computePlaceId();
updateAllStatus();
toast(`Loaded: ${name}`, "ok");
}
function savePreset(name) {
const presets = getPresets();
const data = {};
const fields = document.querySelectorAll("#gbpForm input, #gbpForm select, #gbpForm textarea");
fields.forEach(el => { data[el.id] = el.value; });
presets[name] = data;
savePresets(presets);
populatePresetSelect();
$("presetSelect").value = name;
toast(`Saved: ${name}`, "ok");
}
function deletePreset(name) {
const presets = getPresets();
delete presets[name];
savePresets(presets);
populatePresetSelect();
toast(`Deleted: ${name}`, "warn");
}
/* ── Collapsible sections ── */
document.querySelectorAll(".card-header").forEach(hdr => {
hdr.addEventListener("click", () => {
const card = hdr.closest(".card");
card.classList.toggle("is-open");
saveDraft();
});
});
/* ── Progress indicator ── */
function sectionHasData(sectionIdx) {
const card = document.querySelector(`.card[data-section="${sectionIdx}"]`);
if (!card) return false;
const inputs = card.querySelectorAll("input, select, textarea");
let filled = 0, total = 0;
inputs.forEach(el => {
total++;
const v = el.value.trim();
if (v && v !== "NOT_OBSERVED" && v !== "" && v !== "Unknown" && v !== "— select —") filled++;
});
if (total === 0) return false;
const ratio = filled / total;
if (ratio >= 0.5) return "done";
if (filled > 0) return "partial";
return false;
}
function updateAllStatus() {
let done = 0;
for (let i = 0; i < SECTIONS; i++) {
const status = sectionHasData(i);
const dot = document.querySelector(`.progress-dot[data-idx="${i}"]`);
const label = $(`status${i}`);
if (status === "done") {
done++;
if (dot) { dot.className = "progress-dot done"; }
if (label) { label.textContent = "done"; label.className = "section-status done"; }
} else if (status === "partial") {
if (dot) { dot.className = "progress-dot partial"; }
if (label) { label.textContent = "partial"; label.className = "section-status"; }
} else {
if (dot) { dot.className = "progress-dot"; }
if (label) { label.textContent = "incomplete"; label.className = "section-status"; }
}
}
$("progLabel").textContent = `${done}/${SECTIONS}`;
}
/* ── Auto-compute PLACE_ID from MAPS_URL ── */
function computePlaceId() {
const url = $("maps_url").value.trim();
const placeIdField = $("place_id");
const computedField = $("computed_ids");
if (!url) {
placeIdField.value = "";
computedField.value = "";
return;
}
const parts = [];
const placeMatch = url.match(/(?:place\/|\/)g\/[a-zA-Z0-9_\-]+/);
if (placeMatch) parts.push("PLACE_ID: " + placeMatch[0].replace(/(place\/|\/)/, ""));
const featureMatch = url.match(/0x[a-fA-F0-9]+:0x[a-fA-F0-9]+/g);
if (featureMatch) {
parts.push("feature: " + featureMatch[0]);
if (featureMatch[1]) parts.push("!5s lead: " + featureMatch[1]);
placeIdField.value = featureMatch[0];
}
const cidMatch = url.match(/[?&]cid=(\d+)/) || url.match(/0x[a-fA-F0-9]+:0x([a-fA-F0-9]+)/);
if (cidMatch && cidMatch[1]) {
const cid = cidMatch[1].startsWith("0x") ? String(parseInt(cidMatch[1], 16)) : cidMatch[1];
parts.push("CID: " + cid);
}
computedField.value = parts.join(" · ") || "";
if (!placeIdField.value && featureMatch) placeIdField.value = featureMatch[0];
}
$("maps_url").addEventListener("input", () => { computePlaceId(); saveDraft(); updateAllStatus(); });
/* ── Auto-save on any input ── */
document.querySelectorAll("#gbpForm input, #gbpForm select, #gbpForm textarea").forEach(el => {
el.addEventListener("input", () => { saveDraft(); updateAllStatus(); });
el.addEventListener("change", () => { saveDraft(); updateAllStatus(); });
});
/* ── Toast ── */
let toastTimer = null;
function toast(msg, type) {
const t = $("toast");
t.textContent = msg;
t.className = "show" + (type ? " " + type : "");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => { t.className = ""; }, 2200);
}
/* ── nowISO with local TZ ── */
function nowISO() {
const d = new Date();
const tz = -d.getTimezoneOffset();
const sign = tz >= 0 ? "+" : "-";
const pad = (n) => String(Math.abs(n)).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:00${sign}${pad(Math.floor(Math.abs(tz)/60))}:${pad(Math.abs(tz)%60)}`;
}
function val(id) {
const el = $(id);
const v = (el.value || "").trim();
if (v === "") return "NOT_OBSERVED";
if (v === "No NOT_PRESENT") return "NOT_PRESENT";
return v;
}
function buildOutput() {
computePlaceId();
const instructions = `Follow docs/agents/leonard-gbp-execution-card.md exactly (v1.2).
Map intake into client Data Inventory GBP row.
Apply only threat trigger rules R1R6.
Do not invent fields. Do not add solutions. Do not reason beyond the execution card.
Return: updated GBP section, any threat changes, Changes Made list (with object IDs), Rules Fired list (rule ID, version, inputs, values, output).`;
const sec = (id) => val(id);
const sections = [
// 01 Metadata
`CLIENT: ${sec("client")}
SNAPSHOT_DATETIME: ${sec("snapshot_dt")}
SOURCE: ${sec("source")}
OBSERVATION_SURFACE: ${sec("surface")}
MAPS_URL: ${sec("maps_url")}
PLACE_ID: ${sec("place_id")}`,
// 02 Core
`BUSINESS_NAME: ${sec("business_name")}
ADDRESS: ${sec("address")}
ADDRESS_CANONICAL: ${sec("address_canonical")}
PHONE: ${sec("phone")}
WEBSITE: ${sec("website")}
PRIMARY_CATEGORY: ${sec("primary_category")}
ADDITIONAL_CATEGORIES: ${sec("additional_categories")}`,
// 03 Reputation
`STAR_RATING: ${sec("star_rating")}
REVIEW_COUNT: ${sec("review_count")}
REVIEW_RESPONSES_OBSERVED: ${sec("review_responses")}
REVIEW_RESPONSES_DETAIL: ${sec("review_responses_detail")}`,
// 04 Claim
`CLAIM_PROMPT_VISIBLE: ${sec("claim_prompt")}
CLAIMED_STATUS: ${sec("claimed_status")}
CLAIMED_SIGNALS: ${sec("claimed_signals")}
VERIFIED_BADGE_VISIBLE: ${sec("verified_badge")}`,
// 05 Operations
`HOURS_LISTED: ${sec("hours_listed")}
HOURS_TEXT: ${sec("hours_text")}
OPEN_NOW_OBSERVED: ${sec("open_now")}`,
// 06 Conversion
`BOOKING_BUTTON_PRESENT_ON_GBP: ${sec("booking_button")}
BOOKING_BUTTON_LABEL: ${sec("booking_label")}
MESSAGING_BUTTON_PRESENT: ${sec("messaging_button")}
CALL_BUTTON_PRESENT: ${sec("call_button")}
DIRECTIONS_BUTTON_PRESENT: ${sec("directions_button")}
OTHER_ACTION_BUTTONS: ${sec("other_action_buttons")}`,
// 07 Content
`PHOTOS_PRESENT: ${sec("photos_present")}
PHOTO_COUNT_APPROX: ${sec("photo_count")}
SERVICES_LISTED: ${sec("services_listed")}
POSTS_SECTION_PRESENT: ${sec("posts_section_present")}
POST_COUNT: ${sec("post_count")}
MOST_RECENT_POST_DATE: ${sec("most_recent_post_date")}
QA_STATUS: ${sec("qa_status")}
ATTRIBUTES_HIGHLIGHTS: ${sec("attributes")}`,
// 08 Related
`SECOND_LISTING_NEARBY: ${sec("second_listing")}
SECOND_LISTING_NAMES: ${sec("second_listing_names")}
SECOND_LISTING_NOTES: ${sec("second_listing_notes")}
RAW_NOTES: ${sec("raw_notes")}`
];
return `${instructions}
--- GBP SNAPSHOT INTAKE v1.4 ---
${sections.join("\n\n")}
`;
}
/* ── Event listeners ── */
$("btnGenerate").addEventListener("click", () => {
$("output").value = buildOutput();
$("output").select();
toast("Generated. Press Ctrl+C to copy.", "ok");
});
$("btnCopy").addEventListener("click", async () => {
const text = $("output").value || buildOutput();
$("output").value = text;
try {
await navigator.clipboard.writeText(text);
toast("Copied.", "ok");
} catch (e) {
$("output").focus();
$("output").select();
toast("Select-all ready — Ctrl/Cmd+C.", "warn");
}
});
$("btnDownload").addEventListener("click", () => {
const text = $("output").value || buildOutput();
$("output").value = text;
const client = ($("client").value || "client").trim().replace(/[^\w\-]+/g, "_") || "client";
const date = ($("snapshot_dt").value || "").split("T")[0] || "nodate";
const blob = new Blob([text], { type: "text/plain;charset=utf-8" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `gbp-intake-${client}-${date}.txt`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(a.href);
toast("Downloaded.", "ok");
});
$("btnSaveState").addEventListener("click", () => {
saveDraft();
toast("Draft saved to browser.", "ok");
});
$("btnClear").addEventListener("click", () => {
if (!confirm("Clear all form data? This cannot be undone.")) return;
$("gbpForm").reset();
$("snapshot_dt").value = nowISO();
$("computed_ids").value = "";
$("place_id").value = "";
$("output").value = "";
localStorage.removeItem(SAVE_KEY);
// Open section 0, close others
document.querySelectorAll(".card").forEach(c => {
if (c.dataset.section === "0") c.classList.add("is-open");
else c.classList.remove("is-open");
});
updateAllStatus();
toast("Form cleared.", "warn");
});
// Preset buttons
$("btnSavePreset").addEventListener("click", () => {
const name = $("client").value.trim();
if (!name) { toast("Enter a client name first.", "warn"); return; }
savePreset(name);
});
$("btnDeletePreset").addEventListener("click", () => {
const name = $("presetSelect").value;
if (!name) { toast("Select a preset to delete.", "warn"); return; }
if (confirm(`Delete preset "${name}"?`)) deletePreset(name);
});
$("presetSelect").addEventListener("change", () => {
const name = $("presetSelect").value;
if (name) loadPreset(name);
});
// Keyboard: Ctrl+Enter to generate
document.addEventListener("keydown", (e) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
$("btnGenerate").click();
}
});
/* ── Init ── */
// Pre-fill datetime (only if not restored from draft)
const existing = localStorage.getItem(SAVE_KEY);
if (existing) {
loadDraft();
} else {
$("snapshot_dt").value = nowISO();
document.querySelector('.card[data-section="0"]')?.classList.add("is-open");
}
populatePresetSelect();
updateAllStatus();
// Recompute from any URL that might have been loaded
computePlaceId();
// Auto-save every 30s as backup
setInterval(saveDraft, 30000);
</script>
</body>
</html>