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.

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.
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;
}We used to lose deals because the customer wanted a quote 'right now' and we couldn't generate one until back at the office. Now our reps hand them a printed quote before walking out of the house. Same-day close rate went up massively.
Continue browsing
Have a project like this in mind? Let's talk.
Send me a brief and I'll respond within 24 hours.