No sections match your search. Clear search to see everything.
Live in production
Hey Jason —

Your site is live and capturing leads.

This page walks you through what's running, how to wire lead notifications into your Gmail, what to test, and the handful of things I need from you to lock everything in. It's interactive — search at the top, check off items as you go, and use the form at the bottom for any questions.

How it feels to a customer

The estimate tool, end to end

Five steps, under two minutes for most customers, no calling required to see a price. The page is mobile-first — most folks will hit it from a phone.

Pick a service

Three big buttons: Tree Removal, Trimming & Pruning, Lot Clearing.

Answer 4 quick questions

Tailored to the service. Removal: tree count, height, hazards, truck access. Trimming: count, concern type (overhang, shaping, deadwood, clearance), height, access. Lot clearing: lot size, density, equipment access, end goal.

Fill the contact form (required)

Name, phone, email, ZIP, optional notes, optional photos (up to 6, 25 MB total). The price is gated behind this — they can't see the number without giving you contact info. Phone auto-formats as they type. ZIP is USPS-validated.

See the price range

Low / Typical / High with a one-line breakdown like “Based on: tree removal · 1 tree · large · near house · easy access.” Page is clear that this is an online estimate, not a quote.

Pick a next step

Schedule a Follow-Up (we collect best time to call) or Call Now (one-tap dial on mobile).

Try it. Easiest way to feel this is to run through the tool yourself on your phone with a fake name. We'll clean the test rows from the sheet later.
Every lead, three places

What lands in your hands

Every submission writes to your sheet and your Drive folder — all stamped with the same Estimate # (format 2026_00001 by default; say if you'd prefer something else) so a customer's email, row, and photos thread together. The email you actually receive depends on what the customer does next:

1. The email (HTML, attachments included)

This is what hits your inbox. Phone is one-tap to dial. Photos are both linked AND attached when total size is under 15 MB.

New Lead

Estimate #: 2026_00001

Estimate submitted 5+ minutes ago, no callback scheduled yet. If they schedule one later, you'll get a follow-up email referencing this same Estimate #.

Name:John Smith
Phone:(508) 555-1234
Email:john@example.com
ZIP:01524 (Leicester, MA)
Distance:0.4 mi from Leicester
Service:Tree Removal
Tree count:2-3 trees
Tree height:large (50-75 ft)
Hazards:near house/structure
Access:easy truck access
Low:$1,610
Typical:$2,012
High:$2,516

One tree leans toward the deck. Looking to do this in next 2 weeks if possible.

Source: pinepointtrees.com/estimate.html

2. The Google Sheet — three tabs in your Drive

The All Leads tab is the one you'll live in day-to-day. Two detail tabs back it up.

Estimate #NamePhoneEmailZIP Lead TypeJob TypeEstimate Range
2026_00003Jane Doe(774) 555-9012jane@…01608 CallbackTrimming & Pruning$280–$450
2026_00002Bob Reed(508) 555-3456bob@…01562 LeadLot Clearing$3,500–$6,000
2026_00001John Smith(508) 555-1234john@…01524 CallbackTree Removal$1,610–$2,516
One row per customer. “Lead” = got an estimate; “Callback” = scheduled a follow-up call.

3. The Drive folder for photos

Auto-organized by month and estimate — every customer's photos live in their own folder.

📁 Pine Point Lead Photos/ 📁 May_26/ 📁 MAY26_Removal_JohnSmith_2026_00001/# one folder per estimate 🖼 tree-front.jpg 🖼 tree-back.jpg 🖼 deck-view.jpg 📁 MAY26_Trimming_JaneDoe_2026_00003/ 🖼 oak1.jpg 📁 Jun_26/# auto-created next month
Two ways the timing plays out

Lead vs callback timing

Schedules right away (within 5 min)

You get one email: "Callback Requested." The lead email is suppressed because they already raised their hand.

Schedules later (after the 5-min mark)

You'll have already gotten the "New Lead" email. The "Callback Requested" arrives with a yellow ℹ Update on existing lead banner so you know it's the same person, not a fresh one. Same Estimate #.

Out-of-service-area leads

If a customer's ZIP is more than 15 miles from Leicester, the email gets a yellow ⚠ banner. Customer doesn't see it — just you.

Name:Tom Nguyen
Phone:(617) 555-2233
ZIP:02134 (Allston, MA)
Distance:36.3 mi from Leicester

There's an option in What we need from you below — do you want them blocked at the form, auto-declined, or do you want to triage them yourself?

Already plugged in

Google Analytics

Traffic and visitor data is being captured. The wiring is in place — I'll let you know when the dashboard is ready for you to log in and review.

Captured today (out of the box)

  • Pageviews and time on each page
  • Traffic sources — Google search, Facebook, direct, referral
  • Device and browser breakdown
  • Geographic region (where visitors are coming from)

Worth adding once you've used it for a couple weeks

  • Conversion tracking — flag "submitted estimate" and "scheduled callback" as goals so you can see what % of visitors become leads.
  • Source attribution — know which Facebook ad / Google search / business card / referral source produced which lead.
  • Phone-call tracking — when someone taps the "Call Now" button on the site, that becomes a conversion event.
Each of those is ~30 minutes of work whenever you're ready. Best to do once you have a few weeks of baseline so we know what's normal.
Wire lead notifications into your Gmail

Setting up the lead pipeline

Right now, lead notifications are wired to my account for testing. To flip that to your Gmail (so you own the data), you'll need to set up a small Google Apps Script. It's a one-time thing, no coding required — follow the 10 numbered steps below or hop on a screenshare with me.

Setup progress: 0 / 10

Steps are below — should take about 5 minutes. Easy task; if you'd rather we hop on a quick screenshare and walk through it together, just say so.

Sign in to Google as the right account

Open drive.google.com in a browser logged in as pinepointtreeservice@gmail.com. Verify the avatar in the top-right is the right account — if multiple Google accounts are signed in, the wrong one is the most common reason this whole flow ends up where you don't expect.

Create the spreadsheet that will hold your leads

In Drive, click + New → Google Sheets → Blank spreadsheet.

Rename it to Pine Point Leads (top left, where it says “Untitled spreadsheet”).

Leave the empty sheet as-is — the script will create the proper tabs (All Leads, Estimate Leads, Schedule Requests) automatically on the first submission.

Open the Apps Script editor

In the open spreadsheet, click Extensions → Apps Script. A new tab opens with a code editor.

The default Code.gs file shows a stub function myFunction() {}. Delete everything in that file — we're replacing it.

Copy the lead-capture script

Click Copy all script, then paste into the empty Code.gs tab in the Apps Script editor. Save with the floppy-disk icon (or ⌘S / Ctrl-S).

Code.gs — paste this
/**
 * Pine Point — Lead Capture Endpoint
 *
 * Receives JSON POSTs from pinepointtrees.com (estimate.html):
 *   - formType: "estimate_contact" — user submitted contact form on the estimate page
 *   - formType: "schedule"         — user clicked "Schedule a Follow-Up" after seeing price
 *
 * Email behavior — exactly ONE email per user journey:
 *   - Estimate filled, no schedule within EMAIL_DELAY_MINUTES → "New Lead" email
 *   - Estimate filled + schedule from same phone/email      → "Callback Requested" email only
 * Both submissions still write rows to their respective sheet tabs regardless.
 *
 * The trigger that sends "New Lead" emails also defends against a race where
 * the schedule POST lands at the server before the estimate_contact POST: it
 * checks the Schedule Requests sheet for a recent matching row and skips the
 * lead email if one exists.
 *
 * Setup once per deployment — see docs/LEAD-CAPTURE-SETUP.md for the full walk-through.
 * Short version:
 *   1. Paste this code into Code.gs in the Apps Script editor
 *   2. Run → installTrigger (creates the 1-min time trigger)
 *   3. Deploy → New deployment → Web app → "Anyone" access
 */

const NOTIFY_EMAIL = Session.getEffectiveUser().getEmail();
const EMAIL_DELAY_MINUTES = 5;
const ATTACHMENT_TOTAL_LIMIT_BYTES = 15 * 1024 * 1024;  // skip attaching once the total exceeds this

const SHEETS = {
  // Clean, deduplicated view that combines Estimate Leads + Schedule Requests
  // into one row per Estimate # with just the columns Jason cares about for
  // follow-up. Each customer = exactly one row, Lead Type updates from
  // "Lead" → "Callback" when they schedule.
  summary: {
    name: 'All Leads',
    headers: [
      'Estimate #', 'Name', 'Phone', 'Email', 'ZIP',
      'Lead Type', 'Job Type', 'Estimate Range'
    ]
  },
  estimate_contact: {
    name: 'Estimate Leads',
    headers: [
      'Estimate #', 'Timestamp', 'Name', 'Phone', 'Email',
      'ZIP', 'City', 'State', 'Distance (mi)', 'Outside Area',
      'Service', 'Tree Count', 'Tree Height', 'Hazards', 'Access',
      'Prune Type', 'Lot Size', 'Lot Density', 'End Goal',
      'Price Low', 'Price Typical', 'Price High',
      'Notes', 'Photos', 'Page'
    ]
  },
  schedule: {
    name: 'Schedule Requests',
    headers: [
      'Estimate #', 'Timestamp', 'Name', 'Phone', 'Email',
      'ZIP', 'City', 'State', 'Distance (mi)', 'Outside Area',
      'Best Time',
      'Service', 'Details', 'Price Typical', 'Notes', 'Photos', 'Page'
    ]
  },
  pending: {
    name: '_Pending Lead Emails',
    headers: ['Queued At', 'Phone Key', 'Email Key', 'Payload JSON'],
    hidden: true  // internal queue used by the script — not for Jason to look at
  }
};

// Pine Point's service center (Leicester, MA) and operating radius.
const SERVICE_CENTER_LAT = 42.2459;
const SERVICE_CENTER_LNG = -71.9087;
const SERVICE_RADIUS_MILES = 20;

const PHOTO_FOLDER_NAME = 'Pine Point Lead Photos';

const SERVICE_NAMES = {
  removal: 'Tree Removal',
  trimming: 'Trimming & Pruning',
  lot_clearing: 'Lot Clearing'
};

// Short PascalCase name used in folder + filename patterns.
const SERVICE_SHORT = {
  removal: 'Removal',
  trimming: 'Trimming',
  lot_clearing: 'LotClearing'
};

const LABELS = {
  removal: {
    treeCount:  { '1': '1 tree', '2-3': '2-3 trees', '4-6': '4-6 trees', '7+': '7 or more trees' },
    treeHeight: { small: 'small (under 25 ft)', medium: 'medium (25-50 ft)', large: 'large (50-75 ft)', xlarge: 'very large (75+ ft)' },
    hazards:    { none: 'open area', house: 'near house/structure', powerlines: 'near power lines', both: 'near house & power lines' },
    access:     { easy: 'easy truck access', limited: 'tight but possible', none: 'difficult / backyard' }
  },
  trimming: {
    treeCount:  { '1': '1 tree', '2-3': '2-3 trees', '4-6': '4-6 trees', '7+': '7 or more trees' },
    pruneType:  { overhang: 'overhang clearing', shaping: 'shaping/thinning', deadwood: 'deadwood removal', clearance: 'clearance from structure/lines' },
    treeHeight: { small: 'small (under 25 ft)', medium: 'medium (25-50 ft)', large: 'large (50-75 ft)', xlarge: 'very large (75+ ft)' },
    access:     { easy: 'easy truck access', limited: 'tight but possible', none: 'difficult / backyard' }
  },
  lot_clearing: {
    lotSize:    { small: 'small area (<1/4 acre)', medium: '1/4-1/2 acre', large: '1/2-1 acre', xlarge: '1+ acres' },
    lotDensity: { brush: 'brush/small trees', mixed: 'mix of brush and large trees', heavy: 'dense woods/hardwoods' },
    access:     { easy: 'easy access for heavy equipment', limited: 'limited / tight', none: 'difficult — no clear route' },
    endGoal:    { build: 'construction prep', yard: 'yard/lawn', thin: 'selective thinning' }
  }
};

const KEY_LABELS = {
  treeCount: 'Tree count',
  treeHeight: 'Tree height',
  hazards: 'Hazards',
  access: 'Access',
  pruneType: 'Prune type',
  lotSize: 'Lot size',
  lotDensity: 'Lot density',
  endGoal: 'End goal'
};

// ============================================================
// HTTP entrypoints
// ============================================================

function doPost(e) {
  try {
    const data = JSON.parse(e.postData.contents);
    const ss = SpreadsheetApp.getActiveSpreadsheet();

    if (data.formType === 'estimate_contact') {
      if (!isValidContact(data.contact, data.formType)) {
        return jsonResponse({ error: 'invalid contact' });
      }
      if (data.contact) data.contact.phone = normalizePhone(data.contact.phone);
      data.estimateNumber = nextEstimateNumber();
      data.areaCheck = checkServiceArea((data.contact || {}).zip);
      data.photoLinks = savePhotosToDrive(data.photos, data.estimateNumber, data.service, (data.contact || {}).name);
      delete data.photos;
      writeEstimate(ss, data);
      upsertSummary(ss, data, 'Lead');
      queueLeadEmail(ss, data);
    } else if (data.formType === 'schedule') {
      if (!isValidContact(data.contact, data.formType)) {
        return jsonResponse({ error: 'invalid contact' });
      }
      if (data.contact) data.contact.phone = normalizePhone(data.contact.phone);
      data.areaCheck = checkServiceArea((data.contact || {}).zip);
      const cancelled = cancelPendingFor(ss, data);
      if (cancelled && cancelled.estimateNumber) {
        // Schedule arrived inside the EMAIL_DELAY_MINUTES window — the
        // pending lead email was cancelled in time, so this is the only
        // notification Jason will see for this customer.
        data.estimateNumber = cancelled.estimateNumber;
        if (cancelled.photoLinks && cancelled.photoLinks.length) {
          data.photoLinks = cancelled.photoLinks;
        }
      } else {
        // Schedule arrived AFTER the lead email already went out (or the
        // pending row was already drained). Customer linkage still holds
        // via the matching Estimate # — flag the email so Jason knows
        // it's the same lead, not a fresh one.
        const prior = lookupRecentEstimate(ss, data.contact);
        if (prior) {
          data.estimateNumber = prior.estimateNumber;
          data.priorEstimateAt = prior.timestamp;
          // Lead email goes out EMAIL_DELAY_MINUTES after estimate timestamp.
          const leadSentBy = new Date(prior.timestamp.getTime() + EMAIL_DELAY_MINUTES * 60 * 1000);
          data.leadEmailAlreadySent = (new Date() >= leadSentBy);
        }
      }
      writeSchedule(ss, data);
      // Once they've scheduled a callback they're no longer a "lead awaiting
      // follow-up" — remove them from the Estimate Leads tab so that tab
      // really means "open leads to call." Full detail lives in Schedule
      // Requests + the All Leads summary.
      removeEstimateRow(ss, data.estimateNumber);
      upsertSummary(ss, data, 'Callback');
      sendCallbackEmail(data);
    } else if (data.formType === 'handoff_input' || data.formType === 'handoff_test') {
      // Posted from the FITFO handoff page (fitfo-systems.github.io/pinepoint-handoff/).
      // Just forward as an email — no sheet writes, no lead-capture machinery.
      sendHandoffEmail(data);
    } else {
      writeUnknown(ss, data);
    }

    return jsonResponse({ ok: true });
  } catch (err) {
    return jsonResponse({ error: String(err) });
  }
}

/**
 * Returns a fresh, monotonic-per-year estimate number like "26_00001".
 * Counter is stored in ScriptProperties and incremented under a script-level
 * lock so concurrent submissions can't collide on the same number.
 */
function nextEstimateNumber() {
  const lock = LockService.getScriptLock();
  lock.waitLock(10000);
  try {
    const props = PropertiesService.getScriptProperties();
    const yyyy = String(new Date().getFullYear());
    const key = 'estimate_counter_' + yyyy;
    const next = parseInt(props.getProperty(key) || '0', 10) + 1;
    props.setProperty(key, String(next));
    return yyyy + '_' + String(next).padStart(5, '0');
  } finally {
    lock.releaseLock();
  }
}

/**
 * Removes any Estimate Leads rows matching the given Estimate #. Called
 * after a schedule submission so a customer doesn't appear in both the
 * Estimate Leads and Schedule Requests tabs simultaneously — they've
 * upgraded from "lead" to "callback" and only belong in one place.
 */
function removeEstimateRow(ss, estimateNumber) {
  if (!estimateNumber) return;
  const sheet = ss.getSheetByName(SHEETS.estimate_contact.name);
  if (!sheet) return;
  const lastRow = sheet.getLastRow();
  if (lastRow < 2) return;
  const ids = sheet.getRange(2, 1, lastRow - 1, 1).getValues();
  for (let i = ids.length - 1; i >= 0; i--) {
    if (ids[i][0] === estimateNumber) {
      sheet.deleteRow(i + 2);
    }
  }
}

function lookupRecentEstimate(ss, contact) {
  const sheet = ss.getSheetByName(SHEETS.estimate_contact.name);
  if (!sheet) return null;
  const lastRow = sheet.getLastRow();
  if (lastRow < 2) return null;
  const phoneKey = normalizeKey(contact && contact.phone);
  const emailKey = normalizeKey(contact && contact.email);
  if (!phoneKey && !emailKey) return null;
  // Estimate Leads columns (1-indexed): 1=Estimate #, 2=Timestamp, 3=Name, 4=Phone, 5=Email
  const rows = sheet.getRange(2, 1, lastRow - 1, 5).getValues();
  for (let i = rows.length - 1; i >= 0; i--) {
    if ((phoneKey && normalizeKey(rows[i][3]) === phoneKey) ||
        (emailKey && normalizeKey(rows[i][4]) === emailKey)) {
      return {
        estimateNumber: rows[i][0],
        timestamp: rows[i][1] instanceof Date ? rows[i][1] : new Date(rows[i][1])
      };
    }
  }
  return null;
}

/**
 * Defense-in-depth: same checks the client runs, applied server-side.
 * Keeps automated garbage out of the sheet/email even if the client JS
 * is bypassed.
 */
/**
 * Defense-in-depth validation. ZIP is mandatory on the initial estimate
 * contact form, but the schedule modal doesn't collect ZIP — the client
 * carries it forward from state.contact, but we still want schedule
 * submissions to succeed if it's missing for any reason.
 */
function isValidContact(c, formType) {
  if (!c) return false;
  const phoneDigits = String(c.phone || '').replace(/[^0-9]/g, '');
  if (phoneDigits.length < 10 || phoneDigits.length > 15) return false;
  if (/^(\d)\1+$/.test(phoneDigits)) return false; // all same digit
  if (c.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(c.email)) return false;

  const zip = String(c.zip || '').trim();
  if (formType === 'estimate_contact') {
    if (!/^\d{5}(-\d{4})?$/.test(zip)) return false;
  } else if (zip) {
    // Schedule etc.: zip is optional, but if provided must be valid.
    if (!/^\d{5}(-\d{4})?$/.test(zip)) return false;
  }
  return true;
}

/**
 * Normalize an inbound phone string to a consistent display format so
 * the sheet and email always show "(508) 555-1234" or "+1 (508) 555-1234"
 * regardless of what the user typed.
 */
function normalizePhone(s) {
  const digits = String(s || '').replace(/[^0-9]/g, '');
  if (digits.length === 10) {
    return '(' + digits.slice(0, 3) + ') ' + digits.slice(3, 6) + '-' + digits.slice(6);
  }
  if (digits.length === 11 && digits[0] === '1') {
    return '+1 (' + digits.slice(1, 4) + ') ' + digits.slice(4, 7) + '-' + digits.slice(7);
  }
  if (digits.length > 0) return digits;
  return '';
}

/**
 * Looks up a US ZIP via the free zippopotam.us API to confirm it's a real
 * USPS ZIP and to compute distance from our service center. Returns:
 *   { ok: true,  city, state, lat, lng, miles, withinServiceArea }
 *   { ok: false, reason: 'not_found' | 'http_error' | 'fetch_failed' }
 *
 * Failure modes never block the submission — they just leave the
 * area-check empty in the email/sheet.
 */
function checkServiceArea(zip) {
  if (!zip) return { ok: false, reason: 'no_zip' };
  const zip5 = String(zip).split('-')[0];
  if (!/^\d{5}$/.test(zip5)) return { ok: false, reason: 'bad_format' };
  try {
    const resp = UrlFetchApp.fetch('https://api.zippopotam.us/us/' + zip5, { muteHttpExceptions: true });
    if (resp.getResponseCode() !== 200) {
      return { ok: false, reason: 'not_found', status: resp.getResponseCode() };
    }
    const data = JSON.parse(resp.getContentText());
    const place = data && data.places && data.places[0];
    if (!place) return { ok: false, reason: 'no_place' };
    const lat = parseFloat(place.latitude);
    const lng = parseFloat(place.longitude);
    const miles = haversineMiles(SERVICE_CENTER_LAT, SERVICE_CENTER_LNG, lat, lng);
    return {
      ok: true,
      city: place['place name'] || '',
      state: place['state abbreviation'] || '',
      lat: lat,
      lng: lng,
      miles: miles,
      withinServiceArea: miles <= SERVICE_RADIUS_MILES
    };
  } catch (err) {
    return { ok: false, reason: 'fetch_failed', error: String(err) };
  }
}

function haversineMiles(lat1, lng1, lat2, lng2) {
  const R = 3958.8;
  const toRad = function (d) { return d * Math.PI / 180; };
  const dLat = toRad(lat2 - lat1);
  const dLng = toRad(lng2 - lng1);
  const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
            Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) *
            Math.sin(dLng / 2) * Math.sin(dLng / 2);
  return 2 * R * Math.asin(Math.sqrt(a));
}

function doGet() {
  return jsonResponse({ status: 'ready' });
}

// ============================================================
// Sheet writes
// ============================================================

function writeEstimate(ss, d) {
  const sheet = getOrCreate(ss, SHEETS.estimate_contact);
  const a = d.answers || {};
  const c = d.contact || {};
  const p = d.price || {};
  const ac = d.areaCheck || {};
  const distance = (ac.ok && typeof ac.miles === 'number') ? Number(ac.miles.toFixed(1)) : '';
  const outside = ac.ok ? (ac.withinServiceArea ? '' : 'YES') : '';
  sheet.appendRow([
    d.estimateNumber || '',
    new Date(),
    c.name || '', c.phone || '', c.email || '',
    c.zip || '', ac.city || '', ac.state || '', distance, outside,
    d.service || '',
    a.treeCount || '', a.treeHeight || '', a.hazards || '', a.access || '',
    a.pruneType || '', a.lotSize || '', a.lotDensity || '', a.endGoal || '',
    p.low || '', p.typical || '', p.high || '',
    c.notes || '', formatPhotoUrls(d.photoLinks), d.page || ''
  ]);
}

function writeSchedule(ss, d) {
  const sheet = getOrCreate(ss, SHEETS.schedule);
  const c = d.contact || {};
  const p = d.price || {};
  const ac = d.areaCheck || {};
  const distance = (ac.ok && typeof ac.miles === 'number') ? Number(ac.miles.toFixed(1)) : '';
  const outside = ac.ok ? (ac.withinServiceArea ? '' : 'YES') : '';
  sheet.appendRow([
    d.estimateNumber || '',
    new Date(),
    c.name || '', c.phone || '', c.email || '',
    c.zip || '', ac.city || '', ac.state || '', distance, outside,
    d.scheduledTime || '',
    d.service || '', JSON.stringify(d.answers || {}),
    p.typical || '',
    d.scheduleNotes || '',
    formatPhotoUrls(d.photoLinks),
    d.page || ''
  ]);
}

function writeUnknown(ss, d) {
  const sheet = getOrCreate(ss, { name: 'Other', headers: ['Timestamp', 'Raw'] });
  sheet.appendRow([new Date(), JSON.stringify(d)]);
}

/**
 * Maintains the "All Leads" summary tab — one row per Estimate #.
 * Called with leadType "Lead" on estimate_contact and "Callback" on
 * schedule. If a row for this Estimate # already exists, updates it in
 * place (so a Lead row becomes a Callback row when the customer
 * schedules). Otherwise appends a new row.
 */
function upsertSummary(ss, d, leadType) {
  if (!d.estimateNumber) return;
  const sheet = getOrCreate(ss, SHEETS.summary);
  const c = d.contact || {};
  const p = d.price || {};
  const range = (p.low && p.high) ? '$' + p.low + '–$' + p.high : '';
  const jobType = SERVICE_NAMES[d.service] || d.service || '';
  const row = [
    d.estimateNumber,
    c.name || '',
    c.phone || '',
    c.email || '',
    c.zip || '',
    leadType,
    jobType,
    range
  ];

  const lastRow = sheet.getLastRow();
  if (lastRow >= 2) {
    const ids = sheet.getRange(2, 1, lastRow - 1, 1).getValues();
    for (let i = 0; i < ids.length; i++) {
      if (ids[i][0] === d.estimateNumber) {
        sheet.getRange(i + 2, 1, 1, row.length).setValues([row]);
        return;
      }
    }
  }
  sheet.appendRow(row);
}

// ============================================================
// Pending-email queue (deduplicates lead vs callback emails)
// ============================================================

function queueLeadEmail(ss, d) {
  const c = d.contact || {};
  const phoneKey = normalizeKey(c.phone);
  const emailKey = normalizeKey(c.email);

  // Guard: if this phone/email already appears in Schedule Requests, the
  // customer scheduled before (or simultaneously with) submitting contact —
  // the callback email will fire, so we must NOT also send a lead email.
  const scheduledKeys = collectAllScheduleKeys(ss);
  if ((phoneKey && scheduledKeys.has(phoneKey)) || (emailKey && scheduledKeys.has(emailKey))) {
    return;
  }

  const sheet = getOrCreate(ss, SHEETS.pending);
  sheet.appendRow([
    new Date(),
    phoneKey,
    emailKey,
    JSON.stringify(d)
  ]);
}

/**
 * Removes pending lead-email rows that match the schedule submission
 * (by normalized phone or email). Returns the most recently matched
 * pending payload (parsed) so the caller can carry over any photoLinks
 * etc. into the callback email; returns null if nothing matched.
 */
function cancelPendingFor(ss, d) {
  const sheet = ss.getSheetByName(SHEETS.pending.name);
  if (!sheet) return null;
  const lastRow = sheet.getLastRow();
  if (lastRow < 2) return null;

  const c = d.contact || {};
  const phoneKey = normalizeKey(c.phone);
  const emailKey = normalizeKey(c.email);
  if (!phoneKey && !emailKey) return null;

  const rows = sheet.getRange(2, 1, lastRow - 1, 4).getValues();
  let matchedPayload = null;
  for (let i = rows.length - 1; i >= 0; i--) {
    const pendingPhone = rows[i][1];
    const pendingEmail = rows[i][2];
    const matchPhone = phoneKey && pendingPhone && phoneKey === pendingPhone;
    const matchEmail = emailKey && pendingEmail && emailKey === pendingEmail;
    if (matchPhone || matchEmail) {
      if (!matchedPayload) {
        try { matchedPayload = JSON.parse(rows[i][3]); } catch (e) {}
      }
      sheet.deleteRow(i + 2);
    }
  }
  return matchedPayload;
}

/**
 * Called by the 1-minute time trigger installed by installTrigger().
 * Walks the pending queue and sends a "New Lead" email for any entry older
 * than EMAIL_DELAY_MINUTES *unless* a matching schedule row already exists
 * (which would mean the user did schedule — they just raced the network).
 */
function processPendingLeads() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = ss.getSheetByName(SHEETS.pending.name);
  if (!sheet) return;
  const lastRow = sheet.getLastRow();
  if (lastRow < 2) return;

  // Check ALL schedule rows — no time limit. If someone's phone or email
  // appears anywhere in Schedule Requests (regardless of when they scheduled),
  // we never send a lead email for them. This covers the case where the person
  // spends more than EMAIL_DELAY_MINUTES on the result screen before clicking
  // "Schedule a Follow-Up" — the old time-bounded check would miss them.
  const scheduledKeys = collectAllScheduleKeys(ss);

  const cutoff = new Date(Date.now() - EMAIL_DELAY_MINUTES * 60 * 1000);
  const rows = sheet.getRange(2, 1, lastRow - 1, 4).getValues();

  for (let i = rows.length - 1; i >= 0; i--) {
    const queuedAt = new Date(rows[i][0]);
    if (queuedAt > cutoff) continue;

    const phoneKey = rows[i][1];
    const emailKey = rows[i][2];
    const alreadyScheduled =
      (phoneKey && scheduledKeys.has(phoneKey)) ||
      (emailKey && scheduledKeys.has(emailKey));

    if (!alreadyScheduled) {
      try {
        const data = JSON.parse(rows[i][3]);
        sendLeadEmail(data);
      } catch (e) {
        // Skip malformed rows but still remove from the queue.
      }
    }
    sheet.deleteRow(i + 2);
  }
}

/**
 * Returns a Set of all normalized phone and email keys that appear in the
 * Schedule Requests sheet — no time filter. Used by processPendingLeads to
 * ensure a lead email is never sent once someone has scheduled, regardless
 * of whether they scheduled before or after the EMAIL_DELAY_MINUTES window.
 */
function collectAllScheduleKeys(ss) {
  const sheet = ss.getSheetByName(SHEETS.schedule.name);
  const keys = new Set();
  if (!sheet) return keys;
  const lastRow = sheet.getLastRow();
  if (lastRow < 2) return keys;
  // Schedule Requests columns (1-indexed): 1=Estimate#, 2=Timestamp, 3=Name, 4=Phone, 5=Email
  const rows = sheet.getRange(2, 1, lastRow - 1, 5).getValues();
  for (const r of rows) {
    const pk = normalizeKey(r[3]);
    const ek = normalizeKey(r[4]);
    if (pk) keys.add(pk);
    if (ek) keys.add(ek);
  }
  return keys;
}

function collectRecentScheduleKeys(ss, minutesBack) {
  const sheet = ss.getSheetByName(SHEETS.schedule.name);
  const keys = new Set();
  if (!sheet) return keys;
  const lastRow = sheet.getLastRow();
  if (lastRow < 2) return keys;

  const cutoff = new Date(Date.now() - minutesBack * 60 * 1000);
  // Schedule Requests columns: 1=Estimate#, 2=Timestamp, 3=Name, 4=Phone, 5=Email
  // (Was 4-col read at 1=Timestamp, 3=Phone, 4=Email before Estimate # was
  //  added as col 1 — that staleness caused a stray Bobo lead email to fire
  //  even after the callback was confirmed.)
  const rows = sheet.getRange(2, 1, lastRow - 1, 5).getValues();
  for (const r of rows) {
    const ts = r[1] instanceof Date ? r[1] : new Date(r[1]);
    if (!isNaN(ts.getTime()) && ts < cutoff) continue;
    const pk = normalizeKey(r[3]);
    const ek = normalizeKey(r[4]);
    if (pk) keys.add(pk);
    if (ek) keys.add(ek);
  }
  return keys;
}

// ============================================================
// Email composition (HTML + plain-text fallback, with photo attachments)
// ============================================================

function sendLeadEmail(d) {
  const c = d.contact || {};
  const p = d.price || {};
  const num = d.estimateNumber || '';
  const service = SERVICE_NAMES[d.service] || d.service || '';
  const subject = 'New Lead - ' + (service ? service + ' ' : '') + (c.name || 'Pine Point');

  const html = [
    '<div style="font-family:Arial,Helvetica,sans-serif;max-width:600px;color:#222">',
    '<h2 style="margin:0 0 4px;color:#2D5A27">New Lead</h2>',
    num ? '<p style="margin:0 0 4px;font-size:13px;color:#444">Estimate #: <strong>' + escapeHtml(num) + '</strong></p>' : '',
    '<p style="margin:0 0 12px;color:#666;font-size:13px">' +
      'Estimate submitted ' + EMAIL_DELAY_MINUTES + '+ minutes ago, no callback scheduled yet. ' +
      'If they schedule one later, you\'ll get a follow-up email referencing this same Estimate #.</p>',
    outsideAreaBanner(d.areaCheck),
    htmlSection('Contact', contactRows(c, d.areaCheck)),
    htmlSection('Job', [
      ['Service', '<strong>' + escapeHtml(SERVICE_NAMES[d.service] || d.service || '') + '</strong>']
    ].concat(answerRows(d.service, d.answers))),
    htmlSection('Estimated price', [
      ['Low',     '$' + escapeHtml(p.low || '?')],
      ['Typical', '<strong>$' + escapeHtml(p.typical || '?') + '</strong>'],
      ['High',    '$' + escapeHtml(p.high || '?')]
    ]),
    c.notes ? htmlSection('Their notes', [['', escapeHtml(c.notes)]]) : '',
    formatPhotosHtml(d.photoLinks),
    '<p style="color:#888;font-size:12px;margin-top:24px">Source: ' + escapeHtml(d.page || '') + '</p>',
    '</div>'
  ].join('');

  sendHtmlEmail(subject, html, buildAttachments(d.photoLinks));
}

function sendCallbackEmail(d) {
  const c = d.contact || {};
  const p = d.price || {};
  const num = d.estimateNumber || '';
  const service = SERVICE_NAMES[d.service] || d.service || '';

  // Heading + subject reflect how the customer wants to be reached
  const method = d.contactMethod || 'phone_call';
  const methodHeading = method === 'text'  ? 'Followup Text Requested'
                      : method === 'email' ? 'Followup Email Requested'
                      :                     'Callback Requested';
  const subject = methodHeading + ' - ' + (service ? service + ' ' : '') + (c.name || 'Pine Point');

  // Contact reach-back row: show phone for call/text, email for email method
  const reachLabel  = method === 'email' ? 'Reply to'    : 'Call / text';
  const reachValue  = method === 'email' ? emailLink(c.email) : phoneLink(c.phone);
  const callerRows  = contactRows(c, d.areaCheck).concat([
    ['Reach via', '<strong>' + escapeHtml(methodHeading) + '</strong>'],
    [reachLabel,  reachValue],
    // Best time only relevant for phone call
    ...(method === 'phone_call'
      ? [['Best time', '<strong>' + escapeHtml(d.scheduledTime || '(any)') + '</strong>']]
      : [])
  ]);

  const html = [
    '<div style="font-family:Arial,Helvetica,sans-serif;max-width:600px;color:#222">',
    '<h2 style="margin:0 0 4px;color:#2D5A27">' + escapeHtml(methodHeading) + '</h2>',
    num ? '<p style="margin:0 0 12px;font-size:13px;color:#444">Estimate #: <strong>' + escapeHtml(num) + '</strong></p>' : '<div style="height:12px"></div>',
    leadFollowupBanner(d),
    outsideAreaBanner(d.areaCheck),
    htmlSection('Caller', callerRows),
    htmlSection('Job', [
      ['Service', '<strong>' + escapeHtml(SERVICE_NAMES[d.service] || d.service || '') + '</strong>']
    ].concat(answerRows(d.service, d.answers))),
    htmlSection('Estimate', [
      ['Typical price', '<strong>$' + escapeHtml(p.typical || '?') + '</strong>']
    ]),
    d.scheduleNotes ? htmlSection('Their note', [['', escapeHtml(d.scheduleNotes)]]) : '',
    formatPhotosHtml(d.photoLinks),
    '</div>'
  ].join('');

  sendHtmlEmail(subject, html, buildAttachments(d.photoLinks));
}

/**
 * Builds the standard contact rows used in both lead and callback emails.
 * Pulls USPS city/state from areaCheck when available so the customer's
 * actual location is visible at a glance, with the typed town as a fallback.
 */
function contactRows(c, areaCheck) {
  c = c || {};
  const usps = areaCheck && areaCheck.ok ? areaCheck : null;
  const cityState = usps ? (usps.city + ', ' + usps.state) : '';
  const zip = c.zip || '';
  const zipLine = zip + (cityState ? ' (' + cityState + ')' : '');
  const rows = [
    ['Name', escapeHtml(c.name || '(none)')],
    ['Phone', phoneLink(c.phone)],
    ['Email', emailLink(c.email)],
    ['ZIP', escapeHtml(zipLine || '(none)')]
  ];
  if (usps && typeof usps.miles === 'number') {
    rows.push(['Distance', escapeHtml(usps.miles.toFixed(1) + ' mi from Leicester')]);
  }
  return rows;
}

/**
 * Yellow info banner shown on callback emails when the lead email
 * already went out earlier — tells Jason this is the SAME customer
 * he's already been notified about, not a brand-new lead.
 */
function leadFollowupBanner(d) {
  if (!d.leadEmailAlreadySent) return '';
  const when = d.priorEstimateAt
    ? Utilities.formatDate(new Date(d.priorEstimateAt), Session.getScriptTimeZone(), "MMM d, h:mm a")
    : '';
  const num = d.estimateNumber || '';
  return [
    '<div style="margin:0 0 16px;padding:10px 14px;background:#fff8d4;border:1px solid #d4ad1a;border-radius:4px;color:#5a4a00;font-size:14px">',
    '<strong>ℹ Update on existing lead' + (num ? ' #' + escapeHtml(num) : '') + '.</strong> ',
    'A "New Lead" email was already sent for this customer',
    when ? ' (estimate submitted ' + escapeHtml(when) + ')' : '',
    '. They are now scheduling the callback.',
    '</div>'
  ].join('');
}

/**
 * Returns an HTML banner if the submission's ZIP geocoded outside our
 * service radius. Empty string if the area check passed or never ran.
 * The customer never sees this — it's an internal flag to be discussed
 * with Jason before deciding policy.
 */
function outsideAreaBanner(areaCheck) {
  if (!areaCheck || !areaCheck.ok) return '';
  if (areaCheck.withinServiceArea) return '';
  const miles = (areaCheck.miles || 0).toFixed(1);
  return [
    '<div style="margin:0 0 16px;padding:10px 14px;background:#fff5e6;border:1px solid #c85a28;border-radius:4px;color:#5a2c0d;font-size:14px">',
    '<strong>⚠ Outside service area:</strong> ',
    escapeHtml(miles), ' mi from Leicester ',
    '(service radius is ', String(SERVICE_RADIUS_MILES), ' mi).',
    '</div>'
  ].join('');
}

function htmlSection(title, rows) {
  const inner = rows.map(function (r) {
    const label = r[0]
      ? '<td style="padding:4px 12px 4px 0;color:#666;font-size:13px;vertical-align:top;white-space:nowrap">' + escapeHtml(r[0]) + ':</td>'
      : '<td></td>';
    return '<tr>' + label + '<td style="padding:4px 0;font-size:14px">' + r[1] + '</td></tr>';
  }).join('');
  return [
    '<h3 style="margin:18px 0 6px;font-size:14px;letter-spacing:0.5px;text-transform:uppercase;color:#444">' + escapeHtml(title) + '</h3>',
    '<table cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse">',
    inner,
    '</table>'
  ].join('');
}

function answerRows(service, answers) {
  if (!service || !answers) return [];
  const labelMap = LABELS[service] || {};
  const rows = [];
  for (const key of Object.keys(answers)) {
    const labelName = KEY_LABELS[key] || humanizeKey(key);
    const valueLabels = labelMap[key];
    const display = (valueLabels && valueLabels[answers[key]]) || answers[key];
    rows.push([labelName, escapeHtml(display)]);
  }
  return rows;
}

function formatPhotosHtml(links) {
  if (!links || !links.length) return '';
  const items = links.map(function (l) {
    return '<li style="margin:4px 0"><a href="' + escapeHtml(l.url) + '" style="color:#2D5A27">' + escapeHtml(l.name) + '</a></li>';
  }).join('');
  return [
    '<h3 style="margin:18px 0 6px;font-size:14px;letter-spacing:0.5px;text-transform:uppercase;color:#444">Photos (' + links.length + ')</h3>',
    '<p style="margin:4px 0;color:#666;font-size:12px">Attached below + Drive links:</p>',
    '<ul style="margin:4px 0 0 18px;padding:0;font-size:14px">' + items + '</ul>'
  ].join('');
}

function escapeHtml(s) {
  return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
    return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
  });
}

function phoneLink(p) {
  if (!p) return '<em style="color:#888">(none)</em>';
  const digits = String(p).replace(/[^0-9]/g, '');
  if (!digits) return escapeHtml(p);
  const tel = digits.length === 10 ? '+1' + digits : '+' + digits;
  return '<a href="tel:' + escapeHtml(tel) + '" style="color:#2D5A27;font-weight:600">' + escapeHtml(p) + '</a>';
}

function emailLink(e) {
  if (!e) return '<em style="color:#888">(none)</em>';
  return '<a href="mailto:' + escapeHtml(e) + '" style="color:#2D5A27">' + escapeHtml(e) + '</a>';
}

function htmlToText(html) {
  return String(html)
    .replace(/<\/(p|h[1-6]|li|tr|div)>/gi, '\n')
    .replace(/<br\s*\/?>(?!\n)/gi, '\n')
    .replace(/<[^>]+>/g, '')
    .replace(/&nbsp;/g, ' ')
    .replace(/&amp;/g, '&')
    .replace(/&lt;/g, '<')
    .replace(/&gt;/g, '>')
    .replace(/&quot;/g, '"')
    .replace(/&#39;/g, "'")
    .replace(/\n{3,}/g, '\n\n')
    .trim();
}

function sendHtmlEmail(subject, html, attachments) {
  const opts = { htmlBody: html };
  if (attachments && attachments.length) opts.attachments = attachments;
  try {
    MailApp.sendEmail(NOTIFY_EMAIL, subject, htmlToText(html), opts);
  } catch (e) {
    // Sheet writes are the source of truth — never fail the whole submission.
  }
}

function buildAttachments(links) {
  if (!links || !links.length) return [];
  const blobs = [];
  let total = 0;
  for (const l of links) {
    if (!l.fileId) continue;
    try {
      const file = DriveApp.getFileById(l.fileId);
      const size = file.getSize();
      if (total + size > ATTACHMENT_TOTAL_LIMIT_BYTES) break;
      blobs.push(file.getBlob());
      total += size;
    } catch (e) {
      // File may have been deleted/moved — skip; the email still has the link.
    }
  }
  return blobs;
}

function humanizeKey(key) {
  return key.replace(/([A-Z])/g, ' $1').replace(/^./, function (c) { return c.toUpperCase(); }).trim();
}

function normalizeKey(s) {
  return (s || '').toString().toLowerCase().replace(/[^0-9a-z@.]/g, '');
}

// ============================================================
// Photo upload to Drive (organized: <root>/<MMM_YY>/<MMM_YY_Service_Name>/files)
// ============================================================

/**
 * Decodes each base64 data-URL photo from the payload, saves it to the
 * appropriate subfolder, and returns an array of { name, url, fileId }.
 *
 * Folder structure:
 *   Pine Point Lead Photos/
 *     May_26/
 *       MAY26_Removal_JohnSmith_2026_00001/
 *         originalfilename.jpg
 *
 * The estimate-number suffix on the per-estimate folder keeps the name
 * unique even if the same customer submits twice in the same month.
 */
function savePhotosToDrive(photos, estimateNumber, service, customerName) {
  if (!photos || !photos.length) return [];
  const now = new Date();
  const monthYear = formatMonthYear(now);             // "May_26"   — parent folder
  const monthYearUpper = formatMonthYearUpper(now);   // "MAY26"    — folder name prefix
  const safeService = SERVICE_SHORT[service] || 'Other';
  const safeName = sanitizeName(customerName) || 'Anonymous';
  const folderName = monthYearUpper + '_' + safeService + '_' + safeName +
                     (estimateNumber ? '_' + estimateNumber : '');

  const root = getOrCreatePhotoFolder();
  const monthFolder = getOrCreateChildFolder(root, monthYear);
  const estimateFolder = getOrCreateChildFolder(monthFolder, folderName);

  const links = [];
  for (let i = 0; i < photos.length; i++) {
    const photo = photos[i];
    try {
      const match = (photo.dataUrl || '').match(/^data:(.+?);base64,(.*)$/);
      if (!match) continue;
      const mimeType = match[1];
      const base64 = match[2];
      const filename = (photo.name || ('photo-' + (i + 1) + '.jpg')).replace(/[\\/]/g, '_');
      const blob = Utilities.newBlob(Utilities.base64Decode(base64), mimeType, filename);
      const file = estimateFolder.createFile(blob);
      file.setSharing(DriveApp.Access.ANYONE_WITH_LINK, DriveApp.Permission.VIEW);
      links.push({ name: filename, url: file.getUrl(), fileId: file.getId() });
    } catch (e) {
      // Skip individual photo failures rather than losing the whole submission.
    }
  }
  return links;
}

function formatMonthYear(d) {
  const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
  return months[d.getMonth()] + '_' + String(d.getFullYear()).slice(2);
}

function formatMonthYearUpper(d) {
  const months = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC'];
  return months[d.getMonth()] + String(d.getFullYear()).slice(2);
}

function sanitizeName(s) {
  return (s || '').toString().replace(/[^a-zA-Z0-9]/g, '');
}

function getOrCreatePhotoFolder() {
  const folders = DriveApp.getFoldersByName(PHOTO_FOLDER_NAME);
  if (folders.hasNext()) return folders.next();
  return DriveApp.createFolder(PHOTO_FOLDER_NAME);
}

function getOrCreateChildFolder(parent, name) {
  const folders = parent.getFoldersByName(name);
  if (folders.hasNext()) return folders.next();
  return parent.createFolder(name);
}

function formatPhotoUrls(links) {
  if (!links || !links.length) return '';
  return links.map(function (l) { return l.url; }).join('\n');
}

// ============================================================
// Helpers
// ============================================================

function getOrCreate(ss, def) {
  let s = ss.getSheetByName(def.name);
  if (!s) {
    s = ss.insertSheet(def.name);
    s.appendRow(def.headers);
    s.setFrozenRows(1);
    s.getRange(1, 1, 1, def.headers.length).setFontWeight('bold');
    if (def.hidden) s.hideSheet();
  } else if (def.hidden && !s.isSheetHidden()) {
    s.hideSheet();
  }
  return s;
}

/**
 * Forwards a handoff-page submission as a plain email. Subject + body
 * come from the form fields; no sheet writes, no lead pipeline.
 *
 * Sent to fitfo@fitfosystems.com explicitly so it lands at the FITFO
 * inbox regardless of which Google account this script is deployed
 * under.
 */
function sendHandoffEmail(data) {
  const HANDOFF_NOTIFY = 'fitfo@fitfosystems.com';
  const subject = data._subject || ('[Pine Point handoff] ' + (data.formType || 'note'));
  const lines = [];
  if (data.message) lines.push(String(data.message));
  if (data.kind) lines.push('', '---', 'Kind: ' + data.kind);
  if (data.page) lines.push('Page: ' + data.page);
  if (data.ok_count != null || data.issue_count != null) {
    lines.push('OK: ' + (data.ok_count || 0) + ', Issues: ' + (data.issue_count || 0) + ', Untested: ' + (data.untested_count || 0));
  }
  const emailOpts = {};
  if (data.invoice_attachments) {
    try {
      const files = JSON.parse(data.invoice_attachments);
      if (Array.isArray(files) && files.length) {
        emailOpts.attachments = files.map(f =>
          Utilities.newBlob(Utilities.base64Decode(f.data_base64), f.mimeType || 'application/octet-stream', f.filename)
        );
      }
    } catch (e) {
      lines.push('(Note: invoice attachment decode failed — ' + String(e) + ')');
    }
  }
  try {
    MailApp.sendEmail(HANDOFF_NOTIFY, subject, lines.join('\n') || '(empty body)', emailOpts);
  } catch (e) {
    // Don't crash the request if email fails — POSTer doesn't need to know.
  }
}

function jsonResponse(obj) {
  return ContentService
    .createTextOutput(JSON.stringify(obj))
    .setMimeType(ContentService.MimeType.JSON);
}

// ============================================================
// One-time setup — run this from the Apps Script editor (Run → installTrigger)
// ============================================================

/**
 * Installs the 1-minute time trigger that drains the pending lead-email queue.
 * Re-runs are safe — any existing trigger for processPendingLeads is removed first.
 */
function installTrigger() {
  const triggers = ScriptApp.getProjectTriggers();
  for (const t of triggers) {
    if (t.getHandlerFunction() === 'processPendingLeads') {
      ScriptApp.deleteTrigger(t);
    }
  }
  ScriptApp.newTrigger('processPendingLeads')
    .timeBased()
    .everyMinutes(1)
    .create();
  console.log('Trigger installed — pending lead emails are sent ' +
    EMAIL_DELAY_MINUTES + ' minutes after submission unless cancelled by a schedule.');
}
Install the time trigger

The script delays the "lead" email by 5 minutes so it can cancel itself if the customer goes on to schedule a callback (you get one email instead of two). That delay is driven by a 1-minute time trigger — set it up once:

  1. In the toolbar, find the function dropdown (says “Choose function”)
  2. Pick installTrigger
  3. Click Run
  4. Authorize when prompted (see step 6 below)
  5. Verify in the Triggers tab (clock icon, left sidebar): you should see a row for processPendingLeads running every 1 minute

If you re-paste the script later and re-run installTrigger, it's safe — it removes the old trigger before creating a new one.

Authorize Google access

The first time you run anything, Google asks for permission. The flow:

  1. Click Authorize access
  2. Pick pinepointtreeservice@gmail.com
  3. You'll see a scary-looking "Google hasn't verified this app" screen — that's normal because you are the developer of this private script. Click AdvancedGo to (your project name) (unsafe)Allow.

Permissions you're granting: send email (for notifications), edit your Drive (for the photo folder), edit this spreadsheet. All scoped to your own account, only this script can use them.

Deploy as a Web App

Click Deploy → New deployment (top right of the Apps Script editor).

Click the gear icon next to "Select type" → choose Web app.

Configure:

  • Description: Pine Point Lead Capture v1
  • Execute as: Me (pinepointtreeservice@gmail.com)
  • Who has access: Anyone ← critical, must be “Anyone”

Click Deploy. Authorize again if prompted (same flow as step 6).

If you accidentally pick Only myself instead of Anyone, the website won't be able to POST to your script and leads will silently disappear. If you've already deployed, fix via Deploy → Manage deployments → pencil → Who has access → Anyone.
Copy the Web App URL

After deploy, Google shows you a Web app URL. It looks like this:

https://script.google.com/macros/s/AKfyc...long string.../exec

Copy that whole URL. This is the only thing I need from you to finish wiring the website to your account.

Send Keith the URL

Email or text it to me. Once I have it, I paste it into the website's code (5 minutes), and from that moment on every estimate goes to your Gmail, not mine.

You can either reply to my email or use the Ask a question form at the bottom of this page — I'll see it either way.

Send a test estimate to confirm it's working

After I update the URL, send a test estimate from your phone:

  1. Open pinepointtrees.com/estimate.html
  2. Run through the flow with a fake name + your real phone
  3. Submit — within seconds, the row should appear in your All Leads tab
  4. Wait 5+ min for the lead email, OR click Schedule a Follow-Up to trigger the callback email

If both arrive cleanly, you're done. If anything's missing, send me a screenshot of what you see in the Apps Script Executions tab (left sidebar in the editor) and I'll diagnose.

15–20 minutes total

Test everything

Once your end is set up, run through this list. Submitting fake leads is fine — we'll clean the sheet afterward. Check items off as you go; your progress saves automatically (in your browser, just for you).

Test progress: 0 / 0

Estimate flow

Tree Removal: small job (1 tree, easy access)
Tree Removal: big job (5+ trees, near house, no access)
Trimming: overhang clearing
Trimming: shaping / thinning
Trimming: deadwood removal
Trimming: structure / line clearance
Lot Clearing: small lot, brush-only
Lot Clearing: 1+ acres, dense woods

Photos & emails

Attach 2–3 photos to a submission — confirm they show in the email AND the Drive folderDrive: Pine Point Lead Photos / [month_year] / [estimate folder]
Submit and wait 5+ min without scheduling — confirm the "New Lead" email arrives
Submit, then immediately Schedule a Follow-Up — only the "Callback Requested" email should arrive (no separate lead email)
Submit, wait 6+ min for the lead email, THEN come back and schedule — callback email should show the yellow ℹ "Update on existing lead" banner

Edge cases

Try ZIP 02134 (Boston) — should still submit, but the email gets a yellow ⚠ "Outside service area" banner
Try entering a 7-digit phone — should show a clear orange "Enter a 10-digit phone number" message
Try selecting 7 photos — only first 6 accepted; warning if total exceeds 25 MB

The site itself (best from your phone)

Open pinepointtrees.com on your phone — does everything look right?
Tap the phone number in the header — does it open your dialer?
Scroll the homepage: services, video, before/after slider, gallery, reviews, map
Click any review's source link (Google / Facebook / BBB) — does it open the right page?

Sheets & Drive

Each submission shows up as one row in the All Leads tab with the right Lead Type
Estimate Leads + Schedule Requests tabs have full details for every event
Phone numbers in email bodies are clickable on your phone

Pricing review (most important)

For each service path you tested, does the price feel right vs. what you'd actually quote?
Does the Low / Typical / High spread feel reasonable?
Is the one-line breakdown ("Based on: ...") accurate?
0 OK 0 issues 25 untested

When you've worked through the list, hit submit and I'll get a single email with everything you marked — including any issue notes you typed.

Mark at least one item to enable.
Filter by status
Full conversation — all rounds combined

What we need from you

Items addressed: 0 / 8

1. Pricing & wood removal Followup Requested

Base rates, multipliers, and the trunk wood question — all pricing feedback across both rounds in one thread.

FITFO

Are price ranges appropriately wide? $500 minimum on all jobs? For lot clearing, are the size buckets the right cut points?

PINE POINT

All the pricing looks good. Yes, $500 minimum should show. For lot clearing, anything 1+ acre → call for price. Pricing should be about +15% higher. Also want to add a question for whether trunk wood stays on property. Remove the descriptions under pricing — just show Low / Typical / High with the dollar values.

FITFO

✓ Done — lot clearing +15% applied, 1+ acre shows "Call for pricing," tier descriptions removed (Low / Typical / High labels only), trunk wood question added to Tree Removal and Lot Clearing flows.

FITFO

Does leaving trunk wood vs. hauling it away change what you charge? If yes — roughly how much?

PINE POINT

Yes, varies by job size and type of wood. If no wood removed, target large trees at $1,400 for median price. Also, can we adjust the descriptions to reflect anything 9" diameter or larger?

FITFO

Confirmed — $1,400 median for large trees when wood is left on property. Wood removal pricing varies by job — need your removal price range before wiring into the estimator. 9" diameter descriptor for large trees: will add once you confirm placement. Also — if anything above looks off or we misread your intent anywhere, call it out here and we'll correct it before moving forward.

Saved as you type. Submit at the bottom sends everything in one go.

2. Invoicing In Progress

Google Sheets invoice template — lives in the same Drive folder as your leads. Estimate #, customer name, and job details pre-filled when you open it.

FITFO

What are you currently using for invoices? Open to a Google Sheets template in the same Drive as your leads?

PINE POINT

Definitely interested in trying out a Sheets template. Would be much easier than my current process.

FITFO

What does your current invoice look like? Do you itemize, charge tax, track deposits, or keep it to one total line? Attach a sample if you have one.

PINE POINT

No tax, no deposits. I'm ok with one total line as long as there is functionality to itemize — optional.

FITFO

Building the template now — no tax line, no deposit tracking, one total row with optional itemized breakdown. In queue.

Saved as you type. Attached files are sent with the submit below.

3. Scheduling & callbacks Complete

How customers reach you after seeing their estimate, and how you manage your calendar.

FITFO

Time-window picker (current) or Calendly-style specific slots?

PINE POINT

Could we switch to preferred contact method: email, text, or phone call? If phone call, then select a day/time slot like you have.

FITFO

✓ Done — contact method picker now shows Email / Text / Phone Call. Choosing phone shows the time-window selector.

FITFO

How do you manage your schedule today? Do you always call/text before heading out? Calendar link, Calendly, or keep the current flow?

PINE POINT

No need to change this right now — I don't handle any of it and it's a lot of coordination between subcontractors and employees. We only work weekends for now.

FITFO

✓ Complete — no scheduling changes at this stage. Keeping the current flow. Revisit when weekend-only scheduling changes.

Saved as you type. Submit at the bottom sends everything in one go.

4. Out-of-area policy Complete

Today the email gets a yellow ⚠ banner so you can decide per-lead. What do you actually want to happen?

  • (a) Keep flagging, you decide per lead
  • (b) Distance-tiered — triage anything close to the boundary (you tell us the cutoff, e.g. 15–25 mi from Leicester, still gets to your inbox flagged), but auto-decline anything well outside (e.g. 25+ mi). Customer gets a polite "thanks, we don't currently serve your area" message instead of the price.
  • (c) Disqualify upfront for everything beyond 15 mi — polite message at the start of the form, no lead at all
  • (d) Take the lead but auto-reply with a polite decline + referral list
  • (e) Take it but auto-add a travel surcharge
  • (f) No different from any other lead
FITFO

Which option (a–f)? Keep flagging per lead, distance-tiered, or auto-decline outside the zone?

PINE POINT

We can keep and decide based on each lead.

FITFO

✓ Confirmed — option (a) locked in. Out-of-area leads get a ⚠ yellow banner in your email so you can triage case-by-case. No customer-facing changes needed.

Saved as you type. Submit at the bottom of this section sends everything in one go.

5. Already confirmed Complete

Service area townsLeicester, Spencer, Paxton, Auburn, Charlton, Oxford, Worcester, Rutland, Holden, Sterling, Grafton, Boylston, Northbridge, and surrounding towns — ✓ expanded
Years in business"over 3 years" — ✓ on site
Storm / emergency callsYes — ✓ on site
Custom stump carvingOut — ✓ removed from site

6. Open small items Followup Requested

  • Better photos? Got any sharper before/afters or specific job shots you'd want swapped in?
  • Add your personal cell alongside the main 774-262-2145?
  • Anyone else who should see the leads? Employees, partners — send their Google email and I'll give them sheet access.
FITFO

Better photos? Personal cell to add? Anyone else who needs sheet access?

PINE POINT

I have some land clearing photos I can send over. Working on getting some better before/afters. What's the process to update?

FITFO

Send photos to fitfo@fitfosystems.com — we'll swap them in on the next update. The gallery carousel is already built and ready. For future self-service photo management, an admin upload page is queued for a later stage.

Saved as you type. Submit at the bottom of this section sends everything in one go.

7. Anything else? Complete

Ideas, things that look wrong, stuff that wasn't covered above — whatever's on your mind. This card gets sent along with the rest in the same batch.

PINE POINT

Add Holden, Rutland, Grafton, Boylston, Northbridge — maybe surrounding towns?

FITFO

✓ Done — all five towns added to the service map and every town listing across the site. Added "and surrounding towns" to the service area text so nearby leads aren't turned away by an incomplete list.

Saved as you type. Submit at the bottom of this section sends everything in one go.

8. Apps Script URL Complete

Confirming the correct deployment URL so leads actually reach your inbox.

FITFO

The URL you sent was a preview URL (googleusercontent.com/macros/echo?…), not the live deployment. The site needs the /exec format. Can you grab the correct one from Deploy → Manage deployments?

PINE POINT

https://script.google.com/macros/s/AKfycby5Ss1I9EUZP2e0ZuGGkP48JPDYIWjllVY7BadNjRpqiK2JHUyEDgzc_e5-VaVzosi6QQ/exec

FITFO

✓ Done — correct /exec URL confirmed and swapped into the site. Leads now routing directly to your script. One action needed: copy the latest script from the Apps Script source section above and redeploy (Deploy → Manage deployments → Edit → Deploy) to pick up all the latest fixes.

Saved as you type. Submit at the bottom sends everything in one go.
0 of 8 cards have notes

Hit submit anytime — partial replies are fine. Each submission is timestamped and shown under the card you replied to.

Type anything to enable. Drafts auto-save to your browser as you go.