Back to work
2026·Internal Tool · Field SalesPRODUCTION

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.

SOLAR QUOTATION — live site screenshot
Duration
5 weeks
Team
Solo full-stack
My role
Product engineer
Outcome
Quote turnaround
60s
Offline support
100%
PIN security
4-digit
PDF auto-branded
The story

From brief to production system.

Challenge

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.

Solution

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.

Outcome

Reps now hand the customer a printed quote before leaving the home. Conversion timeline collapsed from ~10 days to same-day in most cases.

Process · 5 weeks

How it shipped, week by week.

Week 1
01 / 4

Field shadowing

Spent a day riding along with sales reps to understand the actual home-visit workflow and what slowed quotes down.

Week 2
02 / 4

PIN + offline shell

Built the PIN auth flow, IndexedDB persistence layer, and PWA service worker — the unsexy plumbing that the whole app rests on.

Week 3–4
03 / 4

Calculator + PDF

Shipped the sizing calculator with branded PDF output. Lots of small typography work to get the printed quote to look professional.

Week 5
04 / 4

Polish + rollout

On-device testing across cheap Android phones, install instructions, and a one-pager guide for the sales team.

Inside the system

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)
Stack
React 18ViteTypeScriptTailwind CSSvite-plugin-pwajsPDFIndexedDBPWAVercel
From the codebase

Annotated excerpts.

01 · Generates the branded PDF entirely in the browser — no server round-trip, works offline.
src/pdf/buildQuote.tstypescript
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");
}
02 · PIN is hashed with the Web Crypto API and stored locally — no plaintext PIN ever touches disk or network.
src/auth/pin.tstypescript
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;
}
What the client said
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.
SL
Sales Lead
Field Operations · Rustam Battery & Solar Energy House
Other projects

Continue browsing

Have a project like this in mind? Let's talk.

Send me a brief and I'll respond within 24 hours.

← Home© 2025 Ali RazzaqContact →