SOLAR QUOTATION
On-site solar quotes in 60 seconds. Offline. PIN-locked.
PWA quotation generator for solar sales reps who visit customers' homes. Generates fully branded PDF quotes in under a minute, works completely offline, and stays PIN-locked so reps can't see each other's pipelines.

Numbers about the client's business — useful context, not my engineering output.
Numbers I produced — measurable, attributable to the work I did.
My specific scope on this project — separate from anything the client team supplied.
- Full PWA shell with installable manifest + service worker
- PIN auth flow with Web Crypto hashing (no plaintext)
- Offline-first quote storage (IndexedDB)
- Client-side branded PDF generation (jsPDF) — header, customer block, system specs, pricing
- Mobile-first responsive UI tested on cheap Android phones
From brief to production system.
Field sales reps were sketching quotes on paper during home visits, then emailing properly formatted versions days later — by which point the customer had already taken a competitor's same-day quote. Manual math errors were frequent. Each rep also wanted their own pipeline kept private.
Built an offline-first PWA. Reps install it on their phones, set a 4-digit PIN on first launch, and can generate quotes during the actual site visit. PDF generation happens client-side via jsPDF so no internet is needed; quotes sync to the cloud when the phone comes back online.
Reps now hand the customer a printed quote before leaving the home. Conversion timeline collapsed from ~10 days to same-day in most cases.
The boundaries that shaped the build.
- 01No reliable internet during home visits — must work fully offline
- 02Phones are cheap Android devices, often older Chrome versions
- 03Each rep wants their own pipeline private from colleagues
- 04Zero ongoing cloud cost — small business can't justify a backend bill
What I chose, and what I gave up.
Client-side everything (no backend, no central DB)
Centralized analytics and cross-device sync. Won zero hosting cost, true offline, and instant privacy between reps — the right trade for this product.
4-digit PIN instead of full auth
Strong account-level security. The PIN is enough to keep colleagues out of each other's pipelines on a shared-feeling app, which was the actual threat model.
How it shipped, week by week.
Field shadowing
Spent a day riding along with sales reps to understand the actual home-visit workflow and what slowed quotes down.
PIN + offline shell
Built the PIN auth flow, IndexedDB persistence layer, and PWA service worker — the unsexy plumbing that the whole app rests on.
Calculator + PDF
Shipped the sizing calculator with branded PDF output. Lots of small typography work to get the printed quote to look professional.
Polish + rollout
On-device testing across cheap Android phones, install instructions, and a one-pager guide for the sales team.
What it does. How it's built.
Features
- 4-digit PIN auth per device
- Fully offline-first via PWA + IndexedDB
- Customer details capture (name, address, phone)
- Solar system sizing (kW, panel count, battery kWh)
- Auto pricing from a versioned price book
- Client-side branded PDF generation
- Quote history (last 50 quotes, searchable)
- Install-to-home-screen on Android + iOS
Architecture
- 01Vite + React 18 single-page app
- 02vite-plugin-pwa for service worker + manifest
- 03IndexedDB for persistent quote storage
- 04jsPDF for client-side PDF rendering with embedded logo
- 05Price book versioned as TypeScript constants (no backend)
- 06PIN hashed with Web Crypto API (no plaintext)
- 07Vercel static hosting (zero ongoing cost)
Annotated excerpts.
import jsPDF from "jspdf";
import { logoDataURI } from "@/assets/logo";
interface QuoteData {
customer: { name: string; address: string; phone: string };
system: { kw: number; panels: number; batteryKwh: number };
pricing: { total: number; deposit: number; installments: number };
quoteNo: string;
date: string;
}
export function buildQuotePdf(q: QuoteData): Blob {
const pdf = new jsPDF({ unit: "pt", format: "a4" });
// Header with logo
pdf.addImage(logoDataURI, "PNG", 40, 30, 80, 80);
pdf.setFontSize(22).text("Solar Quotation", 140, 60);
pdf.setFontSize(10).text(`Quote #${q.quoteNo} · ${q.date}`, 140, 80);
// Customer block
pdf.setFontSize(11);
pdf.text(`Customer: ${q.customer.name}`, 40, 140);
pdf.text(`Address: ${q.customer.address}`, 40, 156);
pdf.text(`Phone: ${q.customer.phone}`, 40, 172);
// System block
pdf.setFontSize(13).text("System Specification", 40, 220);
pdf.setFontSize(11);
pdf.text(`Capacity: ${q.system.kw} kW`, 40, 244);
pdf.text(`Panels: ${q.system.panels}`, 40, 260);
pdf.text(`Battery storage: ${q.system.batteryKwh} kWh`, 40, 276);
// Pricing
pdf.setFontSize(13).text("Investment", 40, 324);
pdf.setFontSize(11);
pdf.text(`Total: PKR ${q.pricing.total.toLocaleString()}`, 40, 348);
pdf.text(`Deposit: PKR ${q.pricing.deposit.toLocaleString()}`, 40, 364);
pdf.text(`Installments: ${q.pricing.installments} months`, 40, 380);
return pdf.output("blob");
}const SALT = "solar-quote-pwa-v1";
async function hash(pin: string): Promise<string> {
const enc = new TextEncoder().encode(SALT + pin);
const buf = await crypto.subtle.digest("SHA-256", enc);
return Array.from(new Uint8Array(buf))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
export async function setPin(pin: string) {
if (!/^\d{4}$/.test(pin)) throw new Error("PIN must be 4 digits");
localStorage.setItem("pin_hash", await hash(pin));
}
export async function verifyPin(pin: string): Promise<boolean> {
const stored = localStorage.getItem("pin_hash");
if (!stored) return false;
return (await hash(pin)) === stored;
}Continue browsing
Have a project like this in mind? Let's talk.
Send me a brief and I'll respond within 24 hours.