Skip to content

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.

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 in localStorage (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.ts initializes Firestore with persistentLocalCache({ 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 addDoc promises 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):

  1. Authentication → Users → Add user (email + password) → copy the UID.
  2. Firestore → blog_usersAdd document: ID = UID, string fields uid (same), email, name, role = admin.
  3. 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 create on leads only with the exact field set + per-field type/size checks + bucket value lists + createdAt == request.time; read/delete require a signed-in user that exists in blog_users; update denied. Verified live: unauthenticated REST read returns PERMISSION_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 referrer astradial.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_KEY in 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_leads and calls collections are still allow read, write: if true — flip to request.auth != null once confirmed nothing unauthenticated writes to them client-side.
  • PLAY_STORE_URL in lib/links.ts when the Android app launches.