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
Client context

Numbers about the client's business — useful context, not my engineering output.

Quote turnaround
60s
Offline support
100%
PIN security
4-digit
PDF auto-branded
My engineering output

Numbers I produced — measurable, attributable to the work I did.

Open → PDF on mid-range Android
TODO seconds
Works fully offline
Yes
Backend cost / month
$0 (no backend)
What I built

My specific scope on this project — separate from anything the client team supplied.

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.

Real constraints

The boundaries that shaped the build.

Honest tradeoffs

What I chose, and what I gave up.

Decision

Client-side everything (no backend, no central DB)

What we gave up

Centralized analytics and cross-device sync. Won zero hosting cost, true offline, and instant privacy between reps — the right trade for this product.

Decision

4-digit PIN instead of full auth

What we gave up

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.

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;
}
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 →