hh-shipments 0.0.0-stage β†’ 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,155 @@
1
- # Temporary Holding Version
1
+ # πŸ“¦ HH Shipments β€” package tracker for HH Electronics
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Mobile-friendly web app. Paste a seller's message or upload a screenshot β†’ tracking
4
+ numbers are extracted automatically β†’ shipments are tracked (UPS, USPS, FedEx, DHL)
5
+ until delivery, with push alerts and an archive.
6
+
7
+ ## How intake works
8
+
9
+ Personal Messenger and personal WhatsApp have **no supported API access** (Meta only
10
+ opens inboxes to Facebook Pages and WhatsApp Business numbers), so there's no
11
+ automatic message reading β€” and no passwords, cookies, or scraping involved.
12
+
13
+ Instead:
14
+ 1. **Paste text** in the app β†’ tracking numbers extracted, seller name + device
15
+ guessed, added automatically when confidence is high.
16
+ 2. **Upload a screenshot** β†’ OCR runs in your browser (free, nothing uploaded
17
+ for processing), review, one-tap add.
18
+ 3. **iOS Shortcut** (recommended) β†’ share a message/screenshot straight from
19
+ Messenger/WhatsApp into the app. See `public/SHORTCUT.html` (also linked in
20
+ the app's Settings). Note: a normal website **cannot** appear in the iPhone
21
+ share sheet β€” that requires a native app's share extension. The Shortcut is
22
+ the no-native-code equivalent.
23
+
24
+ Multiple numbers per submission are supported; duplicates are rejected; uncertain
25
+ results go to a review screen.
26
+
27
+ ## Quick start (local test)
28
+
29
+ Needs Node.js 22.5+ (uses the built-in SQLite, zero native dependencies).
30
+
31
+ ```bash
32
+ cd hh-shipments
33
+ npm install
34
+ npm start # -> http://localhost:3000
35
+ ```
36
+
37
+ Open the URL, create your sign-in (first visit), and you're in. Default tracking
38
+ provider is `simulated` β€” clearly-labeled fake data for testing the whole flow.
39
+ Run `npm test` for the 38-check end-to-end suite.
40
+
41
+ ## Going live
42
+
43
+ The app must stay running 24/7 for scheduled tracking checks. It needs HTTPS
44
+ (required for Home-Screen install, push notifications, and the Shortcut).
45
+
46
+ | Option | Approx. cost | Notes |
47
+ |---|---|---|
48
+ | Fly.io (1 small VM + volume) | ~$5/mo | Docker deploy, good fit |
49
+ | Hetzner VPS (CX22) | ~€4.5/mo | Full control, run Docker |
50
+ | Render (paid) | ~$7/mo+ | Free tier sleeps β€” don't use free |
51
+
52
+ ```bash
53
+ docker build -t hh-shipments .
54
+ docker run -d -p 3000:3000 -v hh-data:/data \
55
+ -e TRACKING_PROVIDER=17track \
56
+ -e SEVENTEEN_TRACK_API_KEY=your-key \
57
+ -e SEVENTEEN_TRACK_WEBHOOK_SECRET=pick-a-secret \
58
+ hh-shipments
59
+ ```
60
+
61
+ Put it behind HTTPS (Caddy/Cloudflare Tunnel/your host's TLS).
62
+
63
+ ## Real tracking with 17Track (recommended)
64
+
65
+ Verified 2026-10-12. 17Track's API covers UPS/USPS/FedEx/DHL with auto carrier
66
+ detection and webhooks.
67
+
68
+ 1. Sign up at https://www.17track.net/en/api β€” Basic tier β‰ˆ **Β₯299/year (~$42/yr,
69
+ ~$3.50/mo)** for 10,000 tracking numbers/year (far above ~50/mo need).
70
+ 2. Get your API key from the developer dashboard.
71
+ 3. Set `TRACKING_PROVIDER=17track` and `SEVENTEEN_TRACK_API_KEY` on the server.
72
+ 4. In the 17Track dashboard, add a webhook: `https://YOUR-HOST/api/webhooks/17track?secret=YOUR-SECRET`
73
+ and set the same value as `SEVENTEEN_TRACK_WEBHOOK_SECRET`.
74
+ 5. Verify endpoint/field details against their API docs β€” the integration was
75
+ built from public docs and the response mapping is marked for confirmation.
76
+
77
+ Runner-up: EasyPost tracking API (~$0.50–1.50/mo at this volume, monthly
78
+ billing). Avoid AfterShip (API access starts ~$109/mo). Carrier-direct APIs need
79
+ 4 separate integrations and USPS won't track third-party parcels β€” not viable.
80
+
81
+ ## WhatsApp auto-intake (optional)
82
+
83
+ If the number sellers message is on the **WhatsApp Business app**, Meta supports
84
+ running the app and the Cloud API side-by-side ("coexistence") when the number is
85
+ onboarded through a partner that supports Business-app-number onboarding. A
86
+ dedicated second number registered directly with the Cloud API also works.
87
+ Personal WhatsApp numbers cannot use this β€” converting one requires deleting the
88
+ WhatsApp account and the number stops working in the app.
89
+
90
+ When configured, incoming 1:1 seller messages hit
91
+ `POST /api/webhooks/whatsapp`: text messages are extracted and high-confidence
92
+ tracking numbers are added automatically (sender name captured); photos are
93
+ downloaded and queued for review. Webhook deliveries are idempotent and
94
+ signature-verified. Group chats do not sync to the Cloud API.
95
+
96
+ Setup:
97
+ 1. Create a Meta Business portfolio + WhatsApp Business Account; onboard the
98
+ number (via a coexistence-supporting partner for a Business-app number).
99
+ 2. In Meta's webhook config: callback URL `https://YOUR-HOST/api/webhooks/whatsapp`,
100
+ verify token = your `WHATSAPP_VERIFY_TOKEN`; subscribe to `messages`.
101
+ 3. Set `WHATSAPP_VERIFY_TOKEN`, `WHATSAPP_APP_SECRET`, and (for photo download)
102
+ `WHATSAPP_TOKEN` on the server.
103
+ 4. Inbound seller messages and 24h-window replies are free at typical volumes;
104
+ check Meta's current rate card and any partner platform fee.
105
+
106
+ ## iPhone setup
107
+
108
+ 1. Open the app's HTTPS URL in **Safari** β†’ Share β†’ **Add to Home Screen**.
109
+ 2. Open it from the Home Screen icon β†’ Settings β†’ **Enable push notifications**
110
+ (iOS 16.4+; alerts for out-for-delivery, delivered, exceptions, stalled).
111
+ 3. Build the Shortcut (Settings β†’ iOS Shortcut β†’ New token, then follow the
112
+ in-app guide) for one-tap intake from the share sheet.
113
+
114
+ ## Cost summary
115
+
116
+ | Item | Cost |
117
+ |---|---|
118
+ | Hosting (always-on) | ~$5/mo |
119
+ | 17Track Basic (annual) | ~$3.50/mo effective |
120
+ | OCR (in-browser Tesseract.js) | $0 |
121
+ | Push notifications (Web Push/VAPID) | $0 |
122
+ | **Total** | **~$8.50/mo** |
123
+
124
+ ## Features
125
+
126
+ - Paste / screenshot / Shortcut / manual intake; multi-number; dedupe; review queue
127
+ - Auto carrier detection (UPS, USPS, FedEx, DHL)
128
+ - Server-side scheduled checks (configurable interval) + 17Track webhooks
129
+ - Push alerts: out for delivery, delivered, exceptions, no-movement stall (configurable days)
130
+ - Dashboard: active, due today, needs attention; search by seller / number / device
131
+ - Delivered β†’ auto-archived (searchable); separate **Received & checked** flag
132
+ - Optional seller, device, purchase amount, notes per shipment
133
+ - Invalid numbers and API failures surface as clear errors with retry
134
+ - Single-user sign-in (bcrypt), session cookies, API keys server-side only,
135
+ Shortcut tokens stored as hashes
136
+
137
+ ## Project layout
138
+
139
+ ```
140
+ server.js Express app + API + cron jobs
141
+ src/db.js SQLite schema (node:sqlite)
142
+ src/extract.js tracking-number regexes (shared server + browser)
143
+ src/providers.js simulated + 17track providers
144
+ src/notify.js web-push + notification log
145
+ public/ mobile PWA (index.html, app.js, styles.css, sw.js, …)
146
+ test/run-tests.js end-to-end suite (38 checks, simulated data)
147
+ Dockerfile deploy image
148
+ ```
149
+
150
+ ## Security notes
151
+
152
+ - Never enter your Facebook/WhatsApp password anywhere in this system β€” intake is
153
+ paste/upload/share only.
154
+ - Keep `SEVENTEEN_TRACK_API_KEY` and the webhook secret in server env vars.
155
+ - Shortcut tokens are shown once and stored hashed; revoke anytime in Settings.
package/package.json CHANGED
@@ -1,6 +1,32 @@
1
1
  {
2
+ "dependencies": {
3
+ "bcryptjs": "^2.4.3",
4
+ "cookie-parser": "^1.4.6",
5
+ "express": "^4.21.2",
6
+ "multer": "^1.4.5-lts.1",
7
+ "node-cron": "^3.0.3",
8
+ "web-push": "^3.6.7"
9
+ },
10
+ "description": "HH Electronics shipment tracker \u2014 intake tracking numbers from seller messages/screenshots, auto-track via 17Track, push notifications, archive.",
11
+ "engines": {
12
+ "node": ">=22.5"
13
+ },
14
+ "main": "server.js",
2
15
  "name": "hh-shipments",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
16
+ "scripts": {
17
+ "start": "node server.js",
18
+ "test": "node test/run-tests.js"
19
+ },
20
+ "type": "commonjs",
21
+ "version": "0.1.0",
22
+ "bin": {
23
+ "hh-shipments": "server.js"
24
+ },
25
+ "files": [
26
+ "server.js",
27
+ "src/",
28
+ "public/",
29
+ "README.md",
30
+ "SHORTCUT.md"
31
+ ]
32
+ }
@@ -0,0 +1,45 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6
+ <title>HH Shipments β€” iOS Shortcut setup</title>
7
+ <link rel="stylesheet" href="/styles.css">
8
+ </head>
9
+ <body>
10
+ <div id="app">
11
+ <section class="view">
12
+ <header class="topbar"><a href="/" class="link">β€Ή Back</a><h1>Shortcut setup</h1></header>
13
+ <div class="card">
14
+ <h3>Why a Shortcut?</h3>
15
+ <p>iPhones don't let normal websites appear in the share sheet β€” that requires a native app. This Shortcut does the same job with no native code: share a screenshot or text from any app, and it lands in HH Shipments.</p>
16
+ </div>
17
+ <div class="card">
18
+ <h3>Step 1 β€” Get a token</h3>
19
+ <p>1. In HH Shipments go to <b>Settings β†’ iOS Shortcut</b>.<br>
20
+ 2. Type a name (e.g. <i>iPhone</i>) and tap <b>New token</b>.<br>
21
+ 3. <b>Copy the token</b> β€” it's shown once.</p>
22
+ </div>
23
+ <div class="card">
24
+ <h3>Step 2 β€” Build the Shortcut</h3>
25
+ <p>1. Open the <b>Shortcuts</b> app β†’ <b>+</b> β†’ name it <b>Send to HH Shipments</b>.<br>
26
+ 2. Tap the <b>β“˜</b> at the bottom β†’ turn on <b>Show in Share Sheet</b> β†’ under accepted types enable <b>Images</b> and <b>Text</b>.<br>
27
+ 3. Add action <b>If</b> β†’ set to <i>Shortcut Input</i> <b>is</b> <i>Image</i>.<br>
28
+ 4. Inside <b>If</b>: add <b>Get Contents of URL</b>:<br>
29
+ &nbsp;&nbsp;β€’ URL: <code>https://YOUR-HOST/api/intake</code> (your app's address)<br>
30
+ &nbsp;&nbsp;β€’ Method: <b>POST</b><br>
31
+ &nbsp;&nbsp;β€’ Headers β†’ Add: <code>Authorization</code> = <code>Bearer PASTE-YOUR-TOKEN</code><br>
32
+ &nbsp;&nbsp;β€’ Request Body: <b>Form</b> β†’ Add field <code>image</code> (File) = <b>Shortcut Input</b><br>
33
+ 5. Inside <b>Otherwise</b>: add another <b>Get Contents of URL</b> with the same URL/method/header, but Request Body Form field <code>text</code> (Text) = <b>Shortcut Input</b>.<br>
34
+ 6. After the If block add <b>Show Notification</b> with text <i>Sent to HH Shipments</i>.<br>
35
+ 7. Tap <b>Done</b>.</p>
36
+ </div>
37
+ <div class="card">
38
+ <h3>Step 3 β€” Use it</h3>
39
+ <p>In Messenger or WhatsApp: long-press a tracking-number message β†’ <b>Share</b> β†’ <b>Send to HH Shipments</b>. For a screenshot: take it, open Photos β†’ Share β†’ <b>Send to HH Shipments</b>.</p>
40
+ <p>Then open HH Shipments β†’ you'll see a <b>screenshot waiting for review</b> banner. Tap it, hit extract, confirm β€” done. Pasted text is extracted and added automatically when the app is confident.</p>
41
+ </div>
42
+ </section>
43
+ </div>
44
+ </body>
45
+ </html>
package/public/app.js ADDED
@@ -0,0 +1,358 @@
1
+ /* HH Shipments frontend */
2
+ const $ = (id) => document.getElementById(id);
3
+ const api = {
4
+ async req(method, url, body) {
5
+ const r = await fetch(url, {
6
+ method,
7
+ headers: body ? { 'Content-Type': 'application/json' } : {},
8
+ body: body ? JSON.stringify(body) : undefined,
9
+ });
10
+ if (r.status === 401 && !url.includes('/login') && !url.includes('/setup')) { showAuth(); return null; }
11
+ const j = await r.json().catch(() => ({}));
12
+ if (!r.ok) throw new Error(j.error || ('HTTP ' + r.status));
13
+ return j;
14
+ },
15
+ get: (u) => api.req('GET', u),
16
+ post: (u, b) => api.req('POST', u, b),
17
+ patch: (u, b) => api.req('PATCH', u, b),
18
+ del: (u) => api.req('DELETE', u),
19
+ };
20
+
21
+ const STATUS_LABEL = { pending: 'Label created', in_transit: 'In transit', out_for_delivery: 'Out for delivery', delivered: 'Delivered', exception: 'Exception', invalid: 'Invalid', unknown: 'Unknown' };
22
+ const CARRIER_LINK = {
23
+ ups: (n) => `https://www.ups.com/track?tracknum=${n}`,
24
+ usps: (n) => `https://tools.usps.com/go/TrackConfirmAction?tLabels=${n}`,
25
+ fedex: (n) => `https://www.fedex.com/fedextrack/?trknbr=${n}`,
26
+ dhl: (n) => `https://www.dhl.com/us-en/home/tracking/tracking-express.html?submit=1&tracking-id=${n}`,
27
+ };
28
+
29
+ function shipCard(s) {
30
+ const b = document.createElement('button');
31
+ b.className = 'ship';
32
+ const sim = s.simulated ? ' <span class="sim-badge">SIMULATED</span>' : '';
33
+ b.innerHTML = `<b>${esc(s.tracking_number)}</b>${sim}
34
+ <div class="meta">${esc(s.carrier.toUpperCase())}${s.seller_name ? ' Β· ' + esc(s.seller_name) : ''}${s.device ? ' Β· ' + esc(s.device) : ''}</div>
35
+ <div class="meta">${esc(s.latest_event_text || 'Awaiting first tracking update…')}</div>
36
+ <span class="pill ${esc(s.status)}">${esc(STATUS_LABEL[s.status] || s.status)}</span>
37
+ ${s.received_checked_at ? '<span class="pill delivered">βœ“ Received</span>' : ''}`;
38
+ b.onclick = () => openDetail(s.id);
39
+ return b;
40
+ }
41
+ function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); }
42
+ function money(c) { return c == null ? '' : '$' + (c / 100).toFixed(2); }
43
+
44
+ /* ---------- views ---------- */
45
+ function showView(id) {
46
+ document.querySelectorAll('.view').forEach((v) => (v.hidden = true));
47
+ $(id).hidden = false;
48
+ document.querySelectorAll('#tabbar button').forEach((b) => b.classList.toggle('active', b.dataset.view === id));
49
+ window.scrollTo(0, 0);
50
+ }
51
+ function showAuth() {
52
+ showView('view-auth');
53
+ $('tabbar').hidden = true;
54
+ }
55
+ function showApp() {
56
+ $('tabbar').hidden = false;
57
+ showView('view-dash');
58
+ loadDash();
59
+ }
60
+
61
+ /* ---------- auth ---------- */
62
+ async function initAuth() {
63
+ const { needed } = await api.get('/api/setup-needed');
64
+ $('auth-setup').hidden = !needed;
65
+ $('auth-login').hidden = needed;
66
+ showAuth();
67
+ $('btn-setup').onclick = async () => {
68
+ $('auth-err').textContent = '';
69
+ try { await api.post('/api/setup', { email: $('setup-email').value, password: $('setup-pass').value }); enterApp(); }
70
+ catch (e) { $('auth-err').textContent = e.message; }
71
+ };
72
+ $('btn-login').onclick = async () => {
73
+ $('auth-err').textContent = '';
74
+ try { await api.post('/api/login', { email: $('login-email').value, password: $('login-pass').value }); enterApp(); }
75
+ catch (e) { $('auth-err').textContent = e.message; }
76
+ };
77
+ }
78
+ async function enterApp() {
79
+ const me = await api.get('/api/me');
80
+ if (!me) return initAuth();
81
+ $('sim-badge').hidden = !me.simulated;
82
+ showApp();
83
+ }
84
+
85
+ /* ---------- dashboard ---------- */
86
+ async function loadDash() {
87
+ const d = await api.get('/api/dashboard');
88
+ if (!d) return;
89
+ $('st-active').textContent = d.active;
90
+ $('st-today').textContent = d.today;
91
+ $('st-attn').textContent = d.attention;
92
+ $('st-arch').textContent = d.archived;
93
+ if (d.pending_intake > 0) {
94
+ $('intake-nudge').hidden = false;
95
+ $('intake-nudge').innerHTML = `πŸ“Έ <b>${d.pending_intake}</b> screenshot${d.pending_intake > 1 ? 's' : ''} waiting for review β€” <a href="#" id="go-review" class="link">review now</a>`;
96
+ $('go-review').onclick = (e) => { e.preventDefault(); showView('view-add'); activateTab('photo'); loadPending(); };
97
+ } else $('intake-nudge').hidden = true;
98
+
99
+ const q = $('search').value.trim();
100
+ const attn = await api.get('/api/shipments?view=attention' + (q ? '&q=' + encodeURIComponent(q) : ''));
101
+ $('sec-attn-h').hidden = !attn.shipments.length;
102
+ $('list-attn').innerHTML = '';
103
+ attn.shipments.forEach((s) => $('list-attn').appendChild(shipCard(s)));
104
+
105
+ const act = await api.get('/api/shipments?view=active' + (q ? '&q=' + encodeURIComponent(q) : ''));
106
+ $('list-active').innerHTML = act.shipments.length ? '' : '<p class="muted">No active shipments.</p>';
107
+ act.shipments.forEach((s) => $('list-active').appendChild(shipCard(s)));
108
+ }
109
+
110
+ /* ---------- add: paste ---------- */
111
+ async function runQuick(text, targetEl, preset) {
112
+ targetEl.innerHTML = '<p class="muted">Extracting…</p>';
113
+ try {
114
+ const r = await api.post('/api/intake/quick', { text, seller_name: preset && preset.seller, device: preset && preset.device });
115
+ let html = '';
116
+ r.added.forEach((a) => { html += `<div class="review">βœ… Added <b>${esc(a.number)}</b> (${esc(a.carrier.toUpperCase())})${r.simulated ? ' <span class="sim-badge">SIMULATED</span>' : ''}</div>`; });
117
+ r.duplicates.forEach((n) => { html += `<div class="review">β›” <b>${esc(n)}</b> is already tracked β€” skipped.</div>`; });
118
+ r.needs_review.forEach((c, i) => {
119
+ html += `<div class="review" id="rev-${i}"><span class="warn">Needs review</span> β€” <b>${esc(c.number)}</b> (${esc(c.carrier.toUpperCase())}, ${esc(c.confidence)} confidence)${c.error ? ' β€” ' + esc(c.error) : ''}
120
+ <div class="row"><button class="secondary" data-add="${i}">Add anyway</button></div></div>`;
121
+ });
122
+ if (r.context && (r.context.seller_name || r.context.device)) {
123
+ html = `<div class="review">πŸ“ Detected: ${esc(r.context.seller_name || 'β€”')} Β· ${esc(r.context.device || 'β€”')}</div>` + html;
124
+ }
125
+ if (!r.added.length && !r.needs_review.length && !r.duplicates.length) html = '<div class="review">No tracking numbers found in that text.</div>';
126
+ targetEl.innerHTML = html;
127
+ targetEl.querySelectorAll('[data-add]').forEach((btn) => {
128
+ btn.onclick = async () => {
129
+ const c = r.needs_review[+btn.dataset.add];
130
+ try {
131
+ const s = await api.post('/api/shipments', { tracking_number: c.number, carrier: c.carrier, seller_name: (r.context || {}).seller_name, device: (r.context || {}).device, source: 'paste' });
132
+ $('rev-' + btn.dataset.add).innerHTML = `βœ… Added <b>${esc(s.number)}</b>`;
133
+ } catch (e) { $('rev-' + btn.dataset.add).innerHTML = `<span class="error">${esc(e.message)}</span>`; }
134
+ };
135
+ });
136
+ loadDashCounts();
137
+ } catch (e) { targetEl.innerHTML = `<p class="error">${esc(e.message)}</p>`; }
138
+ }
139
+ async function loadDashCounts() { /* counts refresh on next dashboard visit */ }
140
+
141
+ /* ---------- add: photo / OCR ---------- */
142
+ let tesseractLoading = null;
143
+ function loadTesseract() {
144
+ if (window.Tesseract) return Promise.resolve();
145
+ if (tesseractLoading) return tesseractLoading;
146
+ tesseractLoading = new Promise((resolve, reject) => {
147
+ const s = document.createElement('script');
148
+ s.src = 'https://cdn.jsdelivr.net/npm/tesseract.js@6/dist/tesseract.min.js';
149
+ s.onload = resolve; s.onerror = () => reject(new Error('Could not load OCR engine (need internet once)'));
150
+ document.head.appendChild(s);
151
+ });
152
+ return tesseractLoading;
153
+ }
154
+
155
+ async function ocrFile(file, statusEl) {
156
+ await loadTesseract();
157
+ statusEl.textContent = 'Reading screenshot…';
158
+ const { data } = await Tesseract.recognize(file, 'eng', { logger: (m) => { if (m.status === 'recognizing text') statusEl.textContent = `Reading… ${Math.round(m.progress * 100)}%`; } });
159
+ statusEl.textContent = '';
160
+ return data.text || '';
161
+ }
162
+
163
+ $('photo-input').addEventListener('change', async (e) => {
164
+ const f = e.target.files[0];
165
+ if (!f) return;
166
+ try {
167
+ const text = await ocrFile(f, $('ocr-status'));
168
+ // Upload original for reference, then run the quick flow on OCR text
169
+ const fd = new FormData();
170
+ fd.append('image', f);
171
+ const up = await fetch('/api/intake/image', { method: 'POST', body: fd });
172
+ if (!up.ok) throw new Error('upload failed');
173
+ await runQuick(text, $('photo-result'));
174
+ loadPending();
175
+ } catch (err) { $('ocr-status').textContent = ''; $('photo-result').innerHTML = `<p class="error">${esc(err.message)}</p>`; }
176
+ e.target.value = '';
177
+ });
178
+
179
+ async function loadPending() {
180
+ const r = await api.get('/api/intake/pending');
181
+ if (!r) return;
182
+ $('pending-list').innerHTML = r.items.length ? '' : '<p class="muted">None.</p>';
183
+ r.items.forEach((it) => {
184
+ const b = document.createElement('button');
185
+ b.className = 'ship';
186
+ b.innerHTML = `<b>πŸ“Έ Screenshot</b><div class="meta">${esc(it.created_at)}</div><span class="pill pending">Tap to extract</span>`;
187
+ b.onclick = () => reviewPendingImage(it.id);
188
+ $('pending-list').appendChild(b);
189
+ });
190
+ }
191
+
192
+ async function reviewPendingImage(id) {
193
+ $('photo-result').innerHTML = `<img src="/api/intake/image/${id}" style="max-width:100%;border-radius:10px"><p class="muted">Extracting…</p>`;
194
+ try {
195
+ await loadTesseract();
196
+ const { data } = await Tesseract.recognize(`/api/intake/image/${id}`, 'eng');
197
+ await api.patch('/api/intake/' + id, { state: 'reviewed' });
198
+ await runQuick(data.text || '', $('photo-result'));
199
+ loadPending();
200
+ } catch (e) { $('photo-result').innerHTML = `<p class="error">${esc(e.message)}</p>`; }
201
+ }
202
+
203
+ /* ---------- detail ---------- */
204
+ let detailId = null;
205
+ async function openDetail(id) {
206
+ detailId = id;
207
+ const s = await api.get('/api/shipments/' + id);
208
+ if (!s) return;
209
+ $('d-number').textContent = s.tracking_number;
210
+ $('d-sim').hidden = !s.simulated;
211
+ $('d-carrier').textContent = s.carrier.toUpperCase() + ' Β· added ' + new Date(s.created_at + 'Z').toLocaleDateString();
212
+ $('d-status').innerHTML = `<span class="pill ${esc(s.status)}">${esc(STATUS_LABEL[s.status] || s.status)}</span>`;
213
+ $('d-event').textContent = s.latest_event_text || 'Awaiting first tracking update…';
214
+ $('d-eta').textContent = s.estimated_delivery ? 'Est. delivery: ' + s.estimated_delivery : '';
215
+ $('d-checked').textContent = s.last_checked_at ? 'Last checked: ' + new Date(s.last_checked_at + 'Z').toLocaleString() : '';
216
+ $('d-carrierlink').href = (CARRIER_LINK[s.carrier] || ((n) => '#'))(s.tracking_number);
217
+ $('d-seller').value = s.seller_name || '';
218
+ $('d-device').value = s.device || '';
219
+ $('d-amount').value = s.amount_cents != null ? (s.amount_cents / 100).toFixed(2) : '';
220
+ $('d-notes').value = s.notes || '';
221
+ $('btn-received').textContent = s.received_checked_at ? 'βœ… Received & checked (' + new Date(s.received_checked_at + 'Z').toLocaleDateString() + ') β€” tap to undo' : 'βœ… Mark received & checked';
222
+ $('btn-received').dataset.done = s.received_checked_at ? '1' : '';
223
+ $('d-events').innerHTML = s.events.length ? '' : '<p class="muted">No events yet.</p>';
224
+ s.events.forEach((e) => {
225
+ const d = document.createElement('div');
226
+ d.className = 'ev';
227
+ d.innerHTML = `<div>${esc(e.text)}${e.simulated ? ' <span class="sim-badge">SIM</span>' : ''}</div><div class="t">${esc(e.ts)}${e.location ? ' Β· ' + esc(e.location) : ''}</div>`;
228
+ $('d-events').appendChild(d);
229
+ });
230
+ showView('view-detail');
231
+ }
232
+
233
+ /* ---------- archive ---------- */
234
+ async function loadArchive() {
235
+ const q = $('search-arch').value.trim();
236
+ const r = await api.get('/api/shipments?view=archived' + (q ? '&q=' + encodeURIComponent(q) : ''));
237
+ if (!r) return;
238
+ $('list-arch').innerHTML = r.shipments.length ? '' : '<p class="muted">Nothing delivered yet.</p>';
239
+ r.shipments.forEach((s) => $('list-arch').appendChild(shipCard(s)));
240
+ }
241
+
242
+ /* ---------- settings ---------- */
243
+ async function loadSettings() {
244
+ const s = await api.get('/api/settings');
245
+ if (!s) return;
246
+ $('s-stale').value = s.stale_days;
247
+ $('s-poll').value = s.poll_hours;
248
+ $('s-notif').checked = s.notifications_enabled;
249
+ const t = await api.get('/api/ingest-tokens');
250
+ $('token-list').innerHTML = t.tokens.length ? '' : '<p class="muted">No tokens yet.</p>';
251
+ t.tokens.forEach((tok) => {
252
+ const d = document.createElement('div');
253
+ d.className = 'tokenrow';
254
+ d.innerHTML = `<span>${esc(tok.name)} <span class="muted">${tok.last_used_at ? 'Β· used ' + esc(tok.last_used_at) : 'Β· never used'}</span></span>`;
255
+ const del = document.createElement('button');
256
+ del.className = 'link'; del.textContent = 'Delete';
257
+ del.onclick = async () => { await api.del('/api/ingest-tokens/' + tok.id); loadSettings(); };
258
+ d.appendChild(del);
259
+ $('token-list').appendChild(d);
260
+ });
261
+ const n = await api.get('/api/notifications');
262
+ $('notif-list').innerHTML = n.notifications.length ? '' : '<p class="muted">No alerts yet.</p>';
263
+ n.notifications.slice(0, 10).forEach((x) => {
264
+ const d = document.createElement('div');
265
+ d.className = 'review';
266
+ d.innerHTML = `<b>${esc(x.title)}</b><div class="muted">${esc(x.body)}</div><div class="muted">${esc(x.sent_at)}${x.simulated ? ' Β· <span class="sim-badge">SIM</span>' : ''} Β· push ${x.push_ok ? 'sent' : 'not sent (' + esc(x.push_error || '?') + ')'}</div>`;
267
+ $('notif-list').appendChild(d);
268
+ });
269
+ }
270
+
271
+ /* ---------- push ---------- */
272
+ async function enablePush() {
273
+ $('push-msg').textContent = '';
274
+ try {
275
+ if (!('serviceWorker' in navigator) || !('PushManager' in window)) throw new Error('Push not supported in this browser');
276
+ const reg = await navigator.serviceWorker.ready;
277
+ const { key } = await api.get('/api/push/vapid-key');
278
+ const sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlB64(key) });
279
+ await api.post('/api/push/subscribe', sub.toJSON());
280
+ $('push-msg').textContent = 'βœ… Notifications enabled on this device.';
281
+ } catch (e) { $('push-msg').textContent = 'Could not enable: ' + e.message; }
282
+ }
283
+ function urlB64(s) {
284
+ const pad = '='.repeat((4 - (s.length % 4)) % 4);
285
+ const b64 = (s + pad).replace(/-/g, '+').replace(/_/g, '/');
286
+ const raw = atob(b64);
287
+ const out = new Uint8Array(raw.length);
288
+ for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
289
+ return out;
290
+ }
291
+
292
+ /* ---------- wiring ---------- */
293
+ function activateTab(name) {
294
+ document.querySelectorAll('.tab').forEach((t) => t.classList.toggle('active', t.dataset.tab === name));
295
+ ['paste', 'photo', 'manual'].forEach((n) => ($('tab-' + n).hidden = n !== name));
296
+ }
297
+ document.querySelectorAll('.tab').forEach((t) => (t.onclick = () => activateTab(t.dataset.tab)));
298
+ document.querySelectorAll('#tabbar button').forEach((b) => {
299
+ b.onclick = () => {
300
+ showView(b.dataset.view);
301
+ if (b.dataset.view === 'view-dash') loadDash();
302
+ if (b.dataset.view === 'view-archive') loadArchive();
303
+ if (b.dataset.view === 'view-settings') loadSettings();
304
+ if (b.dataset.view === 'view-add') loadPending();
305
+ };
306
+ });
307
+ let searchT;
308
+ $('search').addEventListener('input', () => { clearTimeout(searchT); searchT = setTimeout(loadDash, 400); });
309
+ let searchAT;
310
+ $('search-arch').addEventListener('input', () => { clearTimeout(searchAT); searchAT = setTimeout(loadArchive, 400); });
311
+ $('btn-quick').onclick = () => runQuick($('paste-text').value, $('quick-result'));
312
+ $('btn-manual').onclick = async () => {
313
+ $('manual-err').textContent = '';
314
+ try {
315
+ await api.post('/api/shipments', { tracking_number: $('m-number').value, seller_name: $('m-seller').value, device: $('m-device').value, amount: $('m-amount').value, source: 'manual' });
316
+ $('m-number').value = $('m-seller').value = $('m-device').value = $('m-amount').value = '';
317
+ showView('view-dash'); loadDash();
318
+ } catch (e) { $('manual-err').textContent = e.message; }
319
+ };
320
+ $('btn-back').onclick = () => { showView('view-dash'); loadDash(); };
321
+ $('btn-save-detail').onclick = async () => {
322
+ await api.patch('/api/shipments/' + detailId, { seller_name: $('d-seller').value, device: $('d-device').value, amount: $('d-amount').value, notes: $('d-notes').value });
323
+ openDetail(detailId);
324
+ };
325
+ $('btn-received').onclick = async () => {
326
+ await api.patch('/api/shipments/' + detailId, { received_checked: !$('btn-received').dataset.done });
327
+ openDetail(detailId);
328
+ };
329
+ $('btn-delete').onclick = async () => {
330
+ if (!confirm('Delete this shipment and its history?')) return;
331
+ await api.del('/api/shipments/' + detailId);
332
+ showView('view-dash'); loadDash();
333
+ };
334
+ $('btn-save-settings').onclick = async () => {
335
+ $('settings-msg').textContent = '';
336
+ try {
337
+ await api.patch('/api/settings', { stale_days: $('s-stale').value, poll_hours: $('s-poll').value, notifications_enabled: $('s-notif').checked });
338
+ $('settings-msg').textContent = 'Saved.';
339
+ } catch (e) { $('settings-msg').textContent = e.message; }
340
+ };
341
+ $('btn-poll-now').onclick = async () => {
342
+ $('settings-msg').textContent = 'Checking…';
343
+ const r = await api.post('/api/poll-now', {});
344
+ $('settings-msg').textContent = r && r.error ? 'Error: ' + r.error : `Checked ${r.checked || 0} shipment(s).`;
345
+ };
346
+ $('btn-push').onclick = enablePush;
347
+ $('btn-token').onclick = async () => {
348
+ const name = $('token-name').value.trim();
349
+ if (!name) return;
350
+ const r = await api.post('/api/ingest-tokens', { name });
351
+ $('token-result').innerHTML = `<p><b>Token (copy now β€” shown once):</b></p><code class="token">${esc(r.token)}</code>`;
352
+ $('token-name').value = '';
353
+ loadSettings();
354
+ };
355
+ $('btn-logout').onclick = async () => { await api.post('/api/logout', {}); initAuth(); };
356
+
357
+ if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js').catch(() => {});
358
+ initAuth();