Exhibition Leads Form & QR App Links (astradial.com)¶
Offline-first lead capture at exhibition stalls plus platform-detecting QR short links, all on the marketing site. Built 2026-07-18/19, live on astradial.com. Repo: astradial/landing-page (local checkout astradial-landing-page-canonical — NOT the stale landingpage/ fork).
Deploy model: push to master auto-deploys astradial.com in ~2 minutes (host wired in a dashboard, no config in repo). The site is a Next.js static export (output: "export") — no server, no middleware, so everything below is client-side. Local npm run build:static fails without the Firebase service account; that's expected — the deploy env has it.
QR + app short links¶
| Route | Behaviour |
|---|---|
/qr | UA sniff: iOS (incl. iPadOS-as-Mac) → App Store; Android → /download (until Play URL exists); desktop → /download. This is the URL on stall stickers. |
/ios | → https://apps.apple.com/il/app/astradial/id6787581661 |
/android | → /download ("Coming soon" card) until the Play Store URL exists |
/web | → https://editor.astradial.com/ |
/download | Themed page listing iOS / Android / Web cards |
All URLs live in lib/links.ts. When the Android app ships, set PLAY_STORE_URL there — that one line flips /android, /qr's Android branch, and the /download card simultaneously. Redirects are tiny "use client" pages (components/Redirect.tsx) because a static export can't do server redirects.
/leads — offline-first lead capture form¶
No login (deliberate: unauthenticated Firestore writes queue offline without needing a token first — see Security below). One screen, optimized for seconds-per-lead:
- Sticky fields — Pincode, District, State, Trial-visit date, and the employee's own name ("Collected by") persist between leads in
localStorage; consecutive leads at a stall share these. - Hospital search (Google Places) — typing 3+ chars queries Places API (New) autocomplete (hospitals, India only); selecting one autofills the hospital name, reception number, pincode, district, state, and lat/lng. Degrades to plain typing when offline, key missing, or >10 s response.
- Pincode → State/District via
api.postalpincode.in; every lookup is cached inlocalStorage(pincache:<pin>) so repeats work offline. - 📍 Here button — GPS → OSM Nominatim reverse geocode → pincode / district / state + lat/lng. Coordinates are kept even if the reverse lookup times out.
- Bucket chips — Beds:
0-15 / 15-30 / 30-50 / 50-100 / 100-300 / 300+; Calls/day:<100 / 100-500 / 500-1000 / 1000+. Designation chips + free text. Required fields: Name + SPOC number only. - Online/Offline toggle (top bar, where Start Free normally sits) — shows real network status; tapping forces offline mode (skips all lookups on flaky venue networks). Persisted per device (
leads:forceOffline).
Offline architecture¶
lib/firebase.tsinitializes Firestore withpersistentLocalCache({ tabManager: persistentMultipleTabManager() })— offline writes queue in IndexedDB and sync automatically on reconnect.public/sw.js— minimal service worker (network-first navigations with cache fallback, cache-first for hashed/_next/static) so the page loads after a reload with no network. Registered only on non-localhost — in dev it caches unhashed chunks stale (if localhost styles look wrong: unregister the SW + clear caches in devtools).- Pending-sync badge = count of
addDocpromises not yet server-acked. - Failed-save stash: if the server rejects a save (rules mismatch — distinct from being offline), the lead is appended to
localStorage["leads:failed"]on the phone and the employee sees an error. Nothing is silently lost; recover the JSON from that key.
Viewing leads — /dashboard/leads¶
Auth-gated (same login as the blog dashboard, astradial.com/login). Table of all fields newest-first (shadcn-style primitives in components/ui/table.tsx), a Map column linking to Google Maps at the lead's lat/lng, per-row delete, and Export CSV (includes lat/lng).
Dashboard account runbook (Firebase console, project misssellerai)¶
Access = a Firebase Auth user plus a blog_users/{uid} Firestore doc. To create an admin (no existing login needed — console writes bypass rules):
- Authentication → Users → Add user (email + password) → copy the UID.
- Firestore →
blog_users→ Add document: ID = UID, string fieldsuid(same),email,name,role=admin. - Log in at astradial.com/login.
To offboard someone: delete their blog_users doc and their Authentication user (their blog posts survive). Done 2026-07-19 for the intern's accounts; current admins are hari@astradial.com and admin@astradial.com. The 403 screen shows the signed-in email + a Sign out button (added after an account-confusion incident).
Security model¶
- Firestore rules (published 2026-07-19): public
createonleadsonly with the exact field set + per-field type/size checks + bucket value lists +createdAt == request.time;read/deleterequire a signed-in user that exists inblog_users;updatedenied. Verified live: unauthenticated REST read returnsPERMISSION_DENIED, form save passes. Accepted trade-off of no-login: anyone with the URL can insert well-formed junk (visible + deletable in the dashboard; App Check is the upgrade path if it becomes real). - Google Maps key (
NEXT_PUBLIC_GOOGLE_MAPS_KEY): browser keys ship in the public bundle by design — the protection is the console restrictions (HTTP referrerastradial.com/*, API restriction = Places API (New)). The key lives in.env.local(gitignored) and must also be set in the hosting dashboard env; never committed (audited 2026-07-19: not in git history, repo private). - Firebase web config env vars are the same public-by-design category.
Open items¶
- Set
NEXT_PUBLIC_GOOGLE_MAPS_KEYin the hosting dashboard + rebuild (until then, live hospital search is silently off; typing works). - After the exhibition: remove the
http://localhost:3790/*referrer from the Maps key. -
crm_leadsandcallscollections are stillallow read, write: if true— flip torequest.auth != nullonce confirmed nothing unauthenticated writes to them client-side. -
PLAY_STORE_URLinlib/links.tswhen the Android app launches.