1329 lines
47 KiB
HTML
1329 lines
47 KiB
HTML
<!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 ·
|
||
<span class="vocab-inline">NOT_OBSERVED</span> = didn't look / couldn't see ·
|
||
Paste to Leonard → R1–R5
|
||
</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>
|
||
|
||
<!-- Local Storage Disclosure -->
|
||
<div class="card" style="margin-bottom:16px;padding:12px 16px;border-color:var(--line-soft)">
|
||
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:8px">
|
||
<div style="font-size:0.78rem;color:var(--muted)">
|
||
<span style="font-family:var(--mono);font-size:0.65rem;text-transform:uppercase;letter-spacing:0.06em">Local data</span><br />
|
||
Draft auto-saves to this browser. Presets are identity fields only (name, address, URL, categories) — observation data is never saved in presets and must be re-filled each audit.
|
||
<span id="localDataSummary" style="display:block;margin-top:4px;font-size:0.72rem;color:var(--ink-dim)"></span>
|
||
</div>
|
||
<div style="display:flex;gap:6px">
|
||
<button type="button" class="preset-btn" id="btnExportPresets">Export</button>
|
||
<button type="button" class="preset-btn" id="btnClearLocalData">Clear local data</button>
|
||
</div>
|
||
</div>
|
||
</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. Restaurant · Auto repair shop · Physical therapist" />
|
||
</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. Mon–Fri 9–5, Sat 10–2, 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&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 · Presets store identity fields only — re-observe before use. Execution card governs R1–R5.</p>
|
||
</div>
|
||
|
||
<div id="toast"></div>
|
||
|
||
<script>
|
||
const $ = (id) => document.getElementById(id);
|
||
/* ── localStorage helpers ── */
|
||
const STATIC_FIELDS = ["client", "snapshot_dt", "source", "surface", "maps_url",
|
||
"business_name", "address", "address_canonical", "phone", "website",
|
||
"primary_category", "additional_categories"];
|
||
|
||
/** Fields that must be re-observed every audit — never saved to presets. */
|
||
const DYNAMIC_FIELDS = ["place_id", "computed_ids",
|
||
"star_rating", "review_count", "review_responses", "review_responses_detail",
|
||
"claim_prompt", "claimed_status", "claimed_signals", "verified_badge",
|
||
"hours_listed", "hours_text", "open_now",
|
||
"booking_button", "booking_label", "messaging_button", "call_button",
|
||
"directions_button", "other_action_buttons",
|
||
"photos_present", "photo_count", "services_listed",
|
||
"posts_section_present", "post_count", "most_recent_post_date",
|
||
"qa_status", "attributes",
|
||
"second_listing", "second_listing_names", "second_listing_notes", "raw_notes"];
|
||
|
||
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;
|
||
// Warn if unsaved data will be overwritten
|
||
const hasDynamicData = DYNAMIC_FIELDS.some(key => {
|
||
const el = document.getElementById(key);
|
||
return el && el.value.trim() && el.value !== "NOT_OBSERVED" && el.value !== "";
|
||
});
|
||
if (hasDynamicData && !confirm(`Load "${name}"? Any unsaved observation data will be cleared — observation fields must be re-filled from scratch.`)) {
|
||
return;
|
||
}
|
||
// Load only static identity fields
|
||
STATIC_FIELDS.forEach(key => {
|
||
const el = document.getElementById(key);
|
||
if (el && key in data) el.value = data[key] || "";
|
||
});
|
||
// Clear all dynamic observation fields so stale data can't slip in
|
||
DYNAMIC_FIELDS.forEach(key => {
|
||
const el = document.getElementById(key);
|
||
if (el) el.value = "";
|
||
});
|
||
// Trigger ID computation for preset URL
|
||
computePlaceId();
|
||
updateAllStatus();
|
||
toast(`Loaded identity fields for "${name}". Re-observe all dynamic data.`, "warn");
|
||
}
|
||
|
||
function savePreset(name) {
|
||
if (!name) { toast("Enter a client name first.", "warn"); return; }
|
||
const presets = getPresets();
|
||
const data = {};
|
||
// Save only static identity fields — NEVER observation data
|
||
STATIC_FIELDS.forEach(key => {
|
||
const el = document.getElementById(key);
|
||
if (el) data[key] = el.value;
|
||
});
|
||
presets[name] = data;
|
||
savePresets(presets);
|
||
populatePresetSelect();
|
||
$("presetSelect").value = name;
|
||
localStorageDisclosure();
|
||
toast("Preset saved (identity fields only — re-observe before use).", "ok");
|
||
}
|
||
|
||
function deletePreset(name) {
|
||
const presets = getPresets();
|
||
delete presets[name];
|
||
savePresets(presets);
|
||
populatePresetSelect();
|
||
toast(`Deleted: ${name}`, "warn");
|
||
}
|
||
|
||
/* ── localStorage disclosure ── */
|
||
function localStorageDisclosure() {
|
||
const presets = getPresets();
|
||
const pCount = Object.keys(presets).length;
|
||
const draftSize = (localStorage.getItem(SAVE_KEY) || "").length;
|
||
const $sum = $("localDataSummary");
|
||
if ($sum) {
|
||
const parts = [];
|
||
if (pCount > 0) parts.push(`${pCount} preset${pCount > 1 ? "s" : ""}`);
|
||
if (draftSize > 0) parts.push(`draft (${(draftSize / 1024).toFixed(1)} KB)`);
|
||
$sum.textContent = parts.length ? `Stored: ${parts.join(", ")}` : "Nothing stored locally";
|
||
}
|
||
}
|
||
|
||
/* ── Update .req.met badges ── */
|
||
function updateReqMet() {
|
||
["client", "snapshot_dt", "maps_url"].forEach(id => {
|
||
const el = document.getElementById(id);
|
||
const badge = document.getElementById("req-" + id);
|
||
if (el && badge) {
|
||
const hasVal = el.value.trim().length > 0;
|
||
badge.classList.toggle("met", hasVal);
|
||
}
|
||
});
|
||
}
|
||
|
||
/* ── 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;
|
||
}
|
||
// Resolve short-link URLs — expand common shorteners to their canonical form
|
||
let resolvedUrl = url;
|
||
if (/^https?:\/\/(maps\.app\.goo\.gl|g\.co\/kgs)\//.test(url)) {
|
||
resolvedUrl = url; // keep original; regex matchers below will fail but PLACE_ID won't be computed
|
||
}
|
||
const parts = [];
|
||
// Try full Maps URL format first
|
||
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]+:\s*0x[a-fA-F0-9]+/g);
|
||
if (featureMatch) {
|
||
parts.push("feature: " + featureMatch[0].replace(/\s+/g, ""));
|
||
if (featureMatch[1]) parts.push("!5s lead: " + featureMatch[1].replace(/\s+/g, ""));
|
||
placeIdField.value = featureMatch[0].replace(/\s+/g, "");
|
||
}
|
||
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);
|
||
}
|
||
// If nothing extracted, flag as short-link (not a failure — user needs to expand)
|
||
if (parts.length === 0 && url.length > 0) {
|
||
parts.push("short-link — expand in browser");
|
||
}
|
||
computedField.value = parts.join(" · ") || "";
|
||
if (!placeIdField.value && featureMatch) placeIdField.value = featureMatch[0].replace(/\s+/g, "");
|
||
}
|
||
|
||
$("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(); updateReqMet(); });
|
||
el.addEventListener("change", () => { saveDraft(); updateAllStatus(); updateReqMet(); });
|
||
});
|
||
|
||
/* ── 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.
|
||
Map intake into client Data Inventory GBP row.
|
||
Apply only threat trigger rules R1–R5.
|
||
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();
|
||
updateReqMet();
|
||
localStorageDisclosure();
|
||
toast("Form cleared.", "warn");
|
||
});
|
||
|
||
// Preset buttons
|
||
$("btnSavePreset").addEventListener("click", () => {
|
||
savePreset($("client").value.trim());
|
||
});
|
||
|
||
$("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);
|
||
});
|
||
|
||
// Local data buttons
|
||
$("btnExportPresets").addEventListener("click", () => {
|
||
const presets = getPresets();
|
||
const count = Object.keys(presets).length;
|
||
if (count === 0) { toast("No presets to export.", "warn"); return; }
|
||
const blob = new Blob([JSON.stringify(presets, null, 2)], { type: "application/json;charset=utf-8" });
|
||
const a = document.createElement("a");
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = "gbp-presets-export.json";
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
a.remove();
|
||
URL.revokeObjectURL(a.href);
|
||
toast(`Exported ${count} preset(s).`, "ok");
|
||
});
|
||
|
||
$("btnClearLocalData").addEventListener("click", () => {
|
||
if (!confirm("Clear all locally stored presets and drafts? This cannot be undone.")) return;
|
||
localStorage.removeItem(SAVE_KEY);
|
||
localStorage.removeItem(PRESETS_KEY);
|
||
populatePresetSelect();
|
||
localStorageDisclosure();
|
||
toast("Local data cleared.", "warn");
|
||
});
|
||
|
||
// 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();
|
||
updateReqMet();
|
||
localStorageDisclosure();
|
||
// Recompute from any URL that might have been loaded
|
||
computePlaceId();
|
||
|
||
// Auto-save every 30s as backup
|
||
setInterval(() => { saveDraft(); localStorageDisclosure(); }, 30000);
|
||
</script>
|
||
</body>
|
||
</html> |