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 +176 -2
- package/package.json +30 -4
- package/public/SHORTCUT.html +45 -0
- package/public/app.js +380 -0
- package/public/extract.js +101 -0
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/index.html +165 -0
- package/public/manifest.webmanifest +1 -0
- package/public/styles.css +61 -0
- package/public/sw.js +29 -0
- package/server.js +575 -0
- package/src/carriers.js +431 -0
- package/src/db.js +117 -0
- package/src/extract.js +101 -0
- package/src/notify.js +71 -0
- package/src/providers.js +162 -0
package/README.md
CHANGED
|
@@ -1,3 +1,177 @@
|
|
|
1
|
-
#
|
|
1
|
+
# π¦ HH Shipments β package tracker for HH Electronics
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
"
|
|
4
|
-
|
|
5
|
-
|
|
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
|
+
β’ URL: <code>https://YOUR-HOST/api/intake</code> (your app's address)<br>
|
|
30
|
+
β’ Method: <b>POST</b><br>
|
|
31
|
+
β’ Headers β Add: <code>Authorization</code> = <code>Bearer PASTE-YOUR-TOKEN</code><br>
|
|
32
|
+
β’ 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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();
|