hh-shipments 0.0.0-stage β†’ 0.3.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,177 @@
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=direct \
56
+ -e UPS_CLIENT_ID=<id> -e UPS_CLIENT_SECRET=<secret> \
57
+ -e FEDEX_CLIENT_ID=<id> -e FEDEX_CLIENT_SECRET=<secret> \
58
+ -e USPS_CLIENT_ID=<id> -e USPS_CLIENT_SECRET=<secret> \
59
+ hh-shipments
60
+ ```
61
+
62
+ Put it behind HTTPS (Caddy/Cloudflare Tunnel/your host's TLS).
63
+
64
+ ## Real tracking with direct carrier APIs (current plan)
65
+
66
+ Verified 2026-10-12. Free developer signups, no per-tracking fee. DHL is not
67
+ wired (skipped by choice β€” add later if needed).
68
+
69
+ 1. **UPS** β€” developer.ups.com β†’ Create Application β†’ enable the **Tracking**
70
+ product β†’ copy Client ID + Client Secret.
71
+ 2. **FedEx** β€” developer.fedex.com β†’ My Projects β†’ new project β†’ add the
72
+ **Track** API β†’ copy API Key + Secret Key (test credentials work immediately;
73
+ production may need approval).
74
+ 3. **USPS** β€” developer.usps.com β†’ register β†’ create an application β†’ copy
75
+ Client ID + Client Secret.
76
+ 4. Set `TRACKING_PROVIDER=direct` plus the six `*_CLIENT_ID`/`*_CLIENT_SECRET`
77
+ env vars on the server. Each number is auto-routed to the right carrier by
78
+ its format (1Z… β†’ UPS, 9400…/AA…US β†’ USPS, FedEx digit patterns β†’ FedEx).
79
+ Or force one carrier for all numbers with `TRACKING_PROVIDER=ups|fedex|usps`.
80
+
81
+ Endpoint/field details verified 2026-10-12 against official docs (UPS
82
+ UPS-API/api-documentation Tracking.yaml, FedEx Track API reference,
83
+ USPS/api-examples).
84
+
85
+ ## Real tracking with 17Track (alternative)
86
+
87
+ Verified 2026-10-12. 17Track's API covers UPS/USPS/FedEx/DHL with auto carrier
88
+ detection and webhooks.
89
+
90
+ 1. Sign up at https://www.17track.net/en/api β€” Basic tier β‰ˆ **Β₯299/year (~$42/yr,
91
+ ~$3.50/mo)** for 10,000 tracking numbers/year (far above ~50/mo need).
92
+ 2. Get your API key from the developer dashboard.
93
+ 3. Set `TRACKING_PROVIDER=17track` and `SEVENTEEN_TRACK_API_KEY` on the server.
94
+ 4. In the 17Track dashboard, add a webhook: `https://YOUR-HOST/api/webhooks/17track?secret=YOUR-SECRET`
95
+ and set the same value as `SEVENTEEN_TRACK_WEBHOOK_SECRET`.
96
+ 5. Verify endpoint/field details against their API docs β€” the integration was
97
+ built from public docs and the response mapping is marked for confirmation.
98
+
99
+ Runner-up: EasyPost tracking API (~$0.50–1.50/mo at this volume, monthly
100
+ billing). Avoid AfterShip (API access starts ~$109/mo). Carrier-direct APIs need
101
+ 4 separate integrations and USPS won't track third-party parcels β€” not viable.
102
+
103
+ ## WhatsApp auto-intake (optional)
104
+
105
+ If the number sellers message is on the **WhatsApp Business app**, Meta supports
106
+ running the app and the Cloud API side-by-side ("coexistence") when the number is
107
+ onboarded through a partner that supports Business-app-number onboarding. A
108
+ dedicated second number registered directly with the Cloud API also works.
109
+ Personal WhatsApp numbers cannot use this β€” converting one requires deleting the
110
+ WhatsApp account and the number stops working in the app.
111
+
112
+ When configured, incoming 1:1 seller messages hit
113
+ `POST /api/webhooks/whatsapp`: text messages are extracted and high-confidence
114
+ tracking numbers are added automatically (sender name captured); photos are
115
+ downloaded and queued for review. Webhook deliveries are idempotent and
116
+ signature-verified. Group chats do not sync to the Cloud API.
117
+
118
+ Setup:
119
+ 1. Create a Meta Business portfolio + WhatsApp Business Account; onboard the
120
+ number (via a coexistence-supporting partner for a Business-app number).
121
+ 2. In Meta's webhook config: callback URL `https://YOUR-HOST/api/webhooks/whatsapp`,
122
+ verify token = your `WHATSAPP_VERIFY_TOKEN`; subscribe to `messages`.
123
+ 3. Set `WHATSAPP_VERIFY_TOKEN`, `WHATSAPP_APP_SECRET`, and (for photo download)
124
+ `WHATSAPP_TOKEN` on the server.
125
+ 4. Inbound seller messages and 24h-window replies are free at typical volumes;
126
+ check Meta's current rate card and any partner platform fee.
127
+
128
+ ## iPhone setup
129
+
130
+ 1. Open the app's HTTPS URL in **Safari** β†’ Share β†’ **Add to Home Screen**.
131
+ 2. Open it from the Home Screen icon β†’ Settings β†’ **Enable push notifications**
132
+ (iOS 16.4+; alerts for out-for-delivery, delivered, exceptions, stalled).
133
+ 3. Build the Shortcut (Settings β†’ iOS Shortcut β†’ New token, then follow the
134
+ in-app guide) for one-tap intake from the share sheet.
135
+
136
+ ## Cost summary
137
+
138
+ | Item | Cost |
139
+ |---|---|
140
+ | Hosting (always-on) | ~$5/mo |
141
+ | 17Track Basic (annual) | ~$3.50/mo effective |
142
+ | OCR (in-browser Tesseract.js) | $0 |
143
+ | Push notifications (Web Push/VAPID) | $0 |
144
+ | **Total** | **~$8.50/mo** |
145
+
146
+ ## Features
147
+
148
+ - Paste / screenshot / Shortcut / manual intake; multi-number; dedupe; review queue
149
+ - Auto carrier detection (UPS, USPS, FedEx, DHL)
150
+ - Server-side scheduled checks (configurable interval) + 17Track webhooks
151
+ - Push alerts: out for delivery, delivered, exceptions, no-movement stall (configurable days)
152
+ - Dashboard: active, due today, needs attention; search by seller / number / device
153
+ - Delivered β†’ auto-archived (searchable); separate **Received & checked** flag
154
+ - Optional seller, device, purchase amount, notes per shipment
155
+ - Invalid numbers and API failures surface as clear errors with retry
156
+ - Single-user sign-in (bcrypt), session cookies, API keys server-side only,
157
+ Shortcut tokens stored as hashes
158
+
159
+ ## Project layout
160
+
161
+ ```
162
+ server.js Express app + API + cron jobs
163
+ src/db.js SQLite schema (node:sqlite)
164
+ src/extract.js tracking-number regexes (shared server + browser)
165
+ src/providers.js simulated + 17track providers
166
+ src/notify.js web-push + notification log
167
+ public/ mobile PWA (index.html, app.js, styles.css, sw.js, …)
168
+ test/run-tests.js end-to-end suite (38 checks, simulated data)
169
+ Dockerfile deploy image
170
+ ```
171
+
172
+ ## Security notes
173
+
174
+ - Never enter your Facebook/WhatsApp password anywhere in this system β€” intake is
175
+ paste/upload/share only.
176
+ - Keep `SEVENTEEN_TRACK_API_KEY` and the webhook secret in server env vars.
177
+ - 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 β€” 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 && node test/run-carriers-tests.js"
19
+ },
20
+ "type": "commonjs",
21
+ "version": "0.3.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,380 @@
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
+ async function handlePhotoFile(f) {
164
+ try {
165
+ const text = await ocrFile(f, $('ocr-status'));
166
+ // Upload original for reference, then run the quick flow on OCR text
167
+ const fd = new FormData();
168
+ fd.append('image', f);
169
+ const up = await fetch('/api/intake/image', { method: 'POST', body: fd });
170
+ if (!up.ok) throw new Error('upload failed');
171
+ await runQuick(text, $('photo-result'));
172
+ loadPending();
173
+ } catch (err) { $('ocr-status').textContent = ''; $('photo-result').innerHTML = `<p class="error">${esc(err.message)}</p>`; }
174
+ }
175
+
176
+ $('photo-input').addEventListener('change', async (e) => {
177
+ const f = e.target.files[0];
178
+ e.target.value = '';
179
+ if (!f) return;
180
+ await handlePhotoFile(f);
181
+ });
182
+
183
+ // Paste a screenshot straight from the clipboard (iOS: tap the paste zone, long-press β†’ Paste)
184
+ $('tab-photo').addEventListener('paste', async (e) => {
185
+ const dt = e.clipboardData;
186
+ if (!dt) return;
187
+ const files = [];
188
+ if (dt.items) for (const it of dt.items) {
189
+ if (it.type && it.type.startsWith('image/')) { const f = it.getAsFile(); if (f) files.push(f); }
190
+ }
191
+ if (!files.length && dt.files) for (const f of dt.files) {
192
+ if (f.type && f.type.startsWith('image/')) files.push(f);
193
+ }
194
+ if (!files.length) return; // let text pastes behave normally
195
+ e.preventDefault();
196
+ $('paste-zone').innerHTML = 'πŸ“‹ <span class="muted">or tap here, then paste your screenshot</span>';
197
+ for (const f of files) await handlePhotoFile(f);
198
+ });
199
+
200
+ async function loadPending() {
201
+ const r = await api.get('/api/intake/pending');
202
+ if (!r) return;
203
+ $('pending-list').innerHTML = r.items.length ? '' : '<p class="muted">None.</p>';
204
+ r.items.forEach((it) => {
205
+ const b = document.createElement('button');
206
+ b.className = 'ship';
207
+ b.innerHTML = `<b>πŸ“Έ Screenshot</b><div class="meta">${esc(it.created_at)}</div><span class="pill pending">Tap to extract</span>`;
208
+ b.onclick = () => reviewPendingImage(it.id);
209
+ $('pending-list').appendChild(b);
210
+ });
211
+ }
212
+
213
+ async function reviewPendingImage(id) {
214
+ $('photo-result').innerHTML = `<img src="/api/intake/image/${id}" style="max-width:100%;border-radius:10px"><p class="muted">Extracting…</p>`;
215
+ try {
216
+ await loadTesseract();
217
+ const { data } = await Tesseract.recognize(`/api/intake/image/${id}`, 'eng');
218
+ await api.patch('/api/intake/' + id, { state: 'reviewed' });
219
+ await runQuick(data.text || '', $('photo-result'));
220
+ loadPending();
221
+ } catch (e) { $('photo-result').innerHTML = `<p class="error">${esc(e.message)}</p>`; }
222
+ }
223
+
224
+ /* ---------- detail ---------- */
225
+ let detailId = null;
226
+ async function openDetail(id) {
227
+ detailId = id;
228
+ const s = await api.get('/api/shipments/' + id);
229
+ if (!s) return;
230
+ $('d-number').textContent = s.tracking_number;
231
+ $('d-sim').hidden = !s.simulated;
232
+ $('d-carrier').textContent = s.carrier.toUpperCase() + ' Β· added ' + new Date(s.created_at + 'Z').toLocaleDateString();
233
+ $('d-status').innerHTML = `<span class="pill ${esc(s.status)}">${esc(STATUS_LABEL[s.status] || s.status)}</span>`;
234
+ $('d-event').textContent = s.latest_event_text || 'Awaiting first tracking update…';
235
+ $('d-eta').textContent = s.estimated_delivery ? 'Est. delivery: ' + s.estimated_delivery : '';
236
+ $('d-checked').textContent = s.last_checked_at ? 'Last checked: ' + new Date(s.last_checked_at + 'Z').toLocaleString() : '';
237
+ $('d-carrierlink').href = (CARRIER_LINK[s.carrier] || ((n) => '#'))(s.tracking_number);
238
+ $('d-seller').value = s.seller_name || '';
239
+ $('d-device').value = s.device || '';
240
+ $('d-amount').value = s.amount_cents != null ? (s.amount_cents / 100).toFixed(2) : '';
241
+ $('d-notes').value = s.notes || '';
242
+ $('btn-received').textContent = s.received_checked_at ? 'βœ… Received & checked (' + new Date(s.received_checked_at + 'Z').toLocaleDateString() + ') β€” tap to undo' : 'βœ… Mark received & checked';
243
+ $('btn-received').dataset.done = s.received_checked_at ? '1' : '';
244
+ $('d-events').innerHTML = s.events.length ? '' : '<p class="muted">No events yet.</p>';
245
+ s.events.forEach((e) => {
246
+ const d = document.createElement('div');
247
+ d.className = 'ev';
248
+ 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>`;
249
+ $('d-events').appendChild(d);
250
+ });
251
+ showView('view-detail');
252
+ }
253
+
254
+ /* ---------- archive ---------- */
255
+ async function loadArchive() {
256
+ const q = $('search-arch').value.trim();
257
+ const r = await api.get('/api/shipments?view=archived' + (q ? '&q=' + encodeURIComponent(q) : ''));
258
+ if (!r) return;
259
+ $('list-arch').innerHTML = r.shipments.length ? '' : '<p class="muted">Nothing delivered yet.</p>';
260
+ r.shipments.forEach((s) => $('list-arch').appendChild(shipCard(s)));
261
+ }
262
+
263
+ /* ---------- settings ---------- */
264
+ async function loadSettings() {
265
+ const s = await api.get('/api/settings');
266
+ if (!s) return;
267
+ $('s-stale').value = s.stale_days;
268
+ $('s-poll').value = s.poll_hours;
269
+ $('s-notif').checked = s.notifications_enabled;
270
+ const t = await api.get('/api/ingest-tokens');
271
+ $('token-list').innerHTML = t.tokens.length ? '' : '<p class="muted">No tokens yet.</p>';
272
+ t.tokens.forEach((tok) => {
273
+ const d = document.createElement('div');
274
+ d.className = 'tokenrow';
275
+ d.innerHTML = `<span>${esc(tok.name)} <span class="muted">${tok.last_used_at ? 'Β· used ' + esc(tok.last_used_at) : 'Β· never used'}</span></span>`;
276
+ const del = document.createElement('button');
277
+ del.className = 'link'; del.textContent = 'Delete';
278
+ del.onclick = async () => { await api.del('/api/ingest-tokens/' + tok.id); loadSettings(); };
279
+ d.appendChild(del);
280
+ $('token-list').appendChild(d);
281
+ });
282
+ const n = await api.get('/api/notifications');
283
+ $('notif-list').innerHTML = n.notifications.length ? '' : '<p class="muted">No alerts yet.</p>';
284
+ n.notifications.slice(0, 10).forEach((x) => {
285
+ const d = document.createElement('div');
286
+ d.className = 'review';
287
+ 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>`;
288
+ $('notif-list').appendChild(d);
289
+ });
290
+ }
291
+
292
+ /* ---------- push ---------- */
293
+ async function enablePush() {
294
+ $('push-msg').textContent = '';
295
+ try {
296
+ if (!('serviceWorker' in navigator) || !('PushManager' in window)) throw new Error('Push not supported in this browser');
297
+ const reg = await navigator.serviceWorker.ready;
298
+ const { key } = await api.get('/api/push/vapid-key');
299
+ const sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlB64(key) });
300
+ await api.post('/api/push/subscribe', sub.toJSON());
301
+ $('push-msg').textContent = 'βœ… Notifications enabled on this device.';
302
+ } catch (e) { $('push-msg').textContent = 'Could not enable: ' + e.message; }
303
+ }
304
+ function urlB64(s) {
305
+ const pad = '='.repeat((4 - (s.length % 4)) % 4);
306
+ const b64 = (s + pad).replace(/-/g, '+').replace(/_/g, '/');
307
+ const raw = atob(b64);
308
+ const out = new Uint8Array(raw.length);
309
+ for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
310
+ return out;
311
+ }
312
+
313
+ /* ---------- wiring ---------- */
314
+ function activateTab(name) {
315
+ document.querySelectorAll('.tab').forEach((t) => t.classList.toggle('active', t.dataset.tab === name));
316
+ ['paste', 'photo', 'manual'].forEach((n) => ($('tab-' + n).hidden = n !== name));
317
+ if (name === 'photo') { try { $('paste-zone').focus({ preventScroll: true }); } catch (_) {} }
318
+ }
319
+ document.querySelectorAll('.tab').forEach((t) => (t.onclick = () => activateTab(t.dataset.tab)));
320
+ document.querySelectorAll('#tabbar button').forEach((b) => {
321
+ b.onclick = () => {
322
+ showView(b.dataset.view);
323
+ if (b.dataset.view === 'view-dash') loadDash();
324
+ if (b.dataset.view === 'view-archive') loadArchive();
325
+ if (b.dataset.view === 'view-settings') loadSettings();
326
+ if (b.dataset.view === 'view-add') loadPending();
327
+ };
328
+ });
329
+ let searchT;
330
+ $('search').addEventListener('input', () => { clearTimeout(searchT); searchT = setTimeout(loadDash, 400); });
331
+ let searchAT;
332
+ $('search-arch').addEventListener('input', () => { clearTimeout(searchAT); searchAT = setTimeout(loadArchive, 400); });
333
+ $('btn-quick').onclick = () => runQuick($('paste-text').value, $('quick-result'));
334
+ $('btn-manual').onclick = async () => {
335
+ $('manual-err').textContent = '';
336
+ try {
337
+ 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' });
338
+ $('m-number').value = $('m-seller').value = $('m-device').value = $('m-amount').value = '';
339
+ showView('view-dash'); loadDash();
340
+ } catch (e) { $('manual-err').textContent = e.message; }
341
+ };
342
+ $('btn-back').onclick = () => { showView('view-dash'); loadDash(); };
343
+ $('btn-save-detail').onclick = async () => {
344
+ await api.patch('/api/shipments/' + detailId, { seller_name: $('d-seller').value, device: $('d-device').value, amount: $('d-amount').value, notes: $('d-notes').value });
345
+ openDetail(detailId);
346
+ };
347
+ $('btn-received').onclick = async () => {
348
+ await api.patch('/api/shipments/' + detailId, { received_checked: !$('btn-received').dataset.done });
349
+ openDetail(detailId);
350
+ };
351
+ $('btn-delete').onclick = async () => {
352
+ if (!confirm('Delete this shipment and its history?')) return;
353
+ await api.del('/api/shipments/' + detailId);
354
+ showView('view-dash'); loadDash();
355
+ };
356
+ $('btn-save-settings').onclick = async () => {
357
+ $('settings-msg').textContent = '';
358
+ try {
359
+ await api.patch('/api/settings', { stale_days: $('s-stale').value, poll_hours: $('s-poll').value, notifications_enabled: $('s-notif').checked });
360
+ $('settings-msg').textContent = 'Saved.';
361
+ } catch (e) { $('settings-msg').textContent = e.message; }
362
+ };
363
+ $('btn-poll-now').onclick = async () => {
364
+ $('settings-msg').textContent = 'Checking…';
365
+ const r = await api.post('/api/poll-now', {});
366
+ $('settings-msg').textContent = r && r.error ? 'Error: ' + r.error : `Checked ${r.checked || 0} shipment(s).`;
367
+ };
368
+ $('btn-push').onclick = enablePush;
369
+ $('btn-token').onclick = async () => {
370
+ const name = $('token-name').value.trim();
371
+ if (!name) return;
372
+ const r = await api.post('/api/ingest-tokens', { name });
373
+ $('token-result').innerHTML = `<p><b>Token (copy now β€” shown once):</b></p><code class="token">${esc(r.token)}</code>`;
374
+ $('token-name').value = '';
375
+ loadSettings();
376
+ };
377
+ $('btn-logout').onclick = async () => { await api.post('/api/logout', {}); initAuth(); };
378
+
379
+ if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js').catch(() => {});
380
+ initAuth();