Skip to main content

Quotes & Leads

Turn listings into leads — request-a-quote from a listing or the global Get Quotes page, double opt-in, paid lead subscriptions via Stripe, and an admin Lead Suite.

Why this matters

A directory that only links out leaves money on the table. Get a Quote (RFQ) turns browsing into a warm lead: a visitor describes what they need and it lands with the business — or with the best few businesses for the job. Owners can then pay to receive a steady stream of matching leads.

Turn it on

Flag: quotes: true in config/features.config.ts (on by default; implied by the localBusiness vertical).

Migrations: 0008_add_quote_requests.sql (the RFQ tables) and 0010_add_lead_subscriptions.sql (paid lead subscriptions).

How requests reach businesses

Requests target either one listing or several:

  • Single — a Request a quote button on a listing page reaches that one owner.
  • Multi — from a category/city page, or the standalone /get-quotes page, the request fans out to the top matching pros. Recipient matching reuses the catalogue's own category/city filter, so nothing niche is hardcoded.
The global Get Quotes page

/get-quotes is a front-door request page: the visitor picks a service, city and timeline and the request fans out — no need to visit a listing first. It's linked from the header when the flag is on.

Leads are real by default

Double opt-in is on out of the box (requireEmailVerification: true): the requester verifies an emailed code before the lead is delivered. Codes are stored only as a SHA-256 hash. A hidden honeypot field silently drops bots, and the submission rate-limit bucket caps abuse. Delivery happens in exactly one idempotent place (deliverQuote()), so a lead is never sent twice.

Config knobs

File: config/quotes.config.ts

export const quotesConfig = {
  maxRecipients: 3,               // fan-out cap ("up to N businesses")
  requireEmailVerification: true, // double opt-in
  collectPhone: false,            // ask for a phone number
  consentRequired: true,          // consent checkbox + text
  leadContactGating: 'open',      // 'open' | 'paid'
  honeypotField: 'company_website',
};

Lead-contact gating (monetization)

  • open (default) — every owner sees the full requester contact.
  • paid — free-plan owners see a masked lead plus an upgrade CTA; only plans with the receive_lead_contact feature (in config/plans.config.ts) see full contact.

Owners can subscribe to receive every matching lead in a category/city through a recurring Stripe subscription (dynamic price_data, so there's no Stripe Price to create). Active subscribers are folded into resolveRecipients alongside the top-ranked listings. The subscribe box lives in the owner's leads inbox. See Payments.

Where leads show up

  • Requester: tracks their requests in a dashboard Leads tab.
  • Owner: an inbox of incoming leads (with the subscribe box).
  • Admin: the Lead Suite dashboard — KPIs, approve/delete, top cities.

Owner lead emails carry a CAN-SPAM footer; set BUSINESS_POSTAL_ADDRESS in your env so it's complete — see Email & Notifications.