Skip to main content

Quote Follow-Up Landing Page

Audience: developers & AI agents · Scope: the no-login, tokenized landing page where a field agent updates a quote's status / stage / dates / comments (or confirms nothing has changed) from a follow-up email link · Last reviewed: 2026-07-31

Sibling spokes (extracted from this doc): the no-login auth + the machine Link API live in authentication.md; the imported Pipedrive pipeline/stage model + the picker live in pipeline-stages.md.

A mobile-first landing page (internally the "Element 7 / Showroom" direction) where a field agent updates a single quote's status, project stage, expected bid date, expected completion date and comments from a link in a follow-up email — plus a quick pass at their other open quotes. On a wide screen it reflows into a two-column desktop console (quote summary on the left, the update form on the right).

The goal is a 30-second update that keeps the sales rep and the forecast current without the agent hunting for a portal password. The email link carries a signed grant token (a "magic link") that authorizes the visitor to update that agent's own follow-up quotes — it deliberately does not log them into their Joomla account, because these emails may be forwarded to untrusted third parties. A visitor without a valid token (and no session) is sent to the normal Joomla login and returned here. See authentication.md.

Front-end URL: index.php?option=com_configbox&view=bcquotelandingpage&serial=AAA0001 → the clean SEF path /quote-follow-up/AAA0001 (see sef-links.md).


Why this exists

Beta-Calco already captures "agent feedback" (won / lost / open + lost reason) on a quote, surfaced inside the logged-in quote views (bcmyquotes, bcquote) and the cold-quote view (bccoldquote), and syncs it to Pipedrive. The friction is that an agent has to be in the portal to give it.

This landing page makes that same feedback loop reachable from a plain email link, adds the richer fields the team wanted (project pipeline stage, expected completion date, free-text comments), and presents it as a polished standalone page rather than a form buried in the account area.

It deliberately reuses the existing agent-feedback + Pipedrive-sync path for status and lost reason, and only adds new storage for the three new fields.


How it fits together

Email link ──► controller: display() ──► view (state machine) ──► templates
/quote-follow-up/<serial> │
├─ form (give an update)
├─ updated (already gave one)
├─ expired (link no longer valid)
└─ notfound (no such quote / not yours — 404)

Agent submits ──► AMD module (AJAX) ──► controller: saveUpdate() ──► model

status / lost reason ──► ConfigboxModelBcquotes ─┴─► Pipedrive sync
stage / date / comments ──► new columns (direct SQL)

The page is rendered by a standard Kenedo controller/model/view triad. The view chooses one of three states; the form posts back over AJAX; the model splits the write between the existing quotes model (so Pipedrive sync stays intact) and the new landing-page-specific columns. The visitor is authorized first — a magic-link grant or a real session — see authentication.md.


Files

All paths are under docroot/components/com_configbox/data/customization/. This table covers the page; the auth files (BcQuoteFollowupAuth.php, bcquotefollowuplink.php) are in authentication.md → Files, and the imported pipeline/stage admin entities are in pipeline-stages.md → Files.

FileRole
controllers/bcquotelandingpage.phpConfigboxControllerBcquotelandingpage. Tasks: display (authenticates via authentication.md, then renders the page by ?serial), saveUpdate (AJAX persist, returns JSON recap), confirmNoChange (AJAX — records a "nothing has changed" response; gated exactly like saveUpdate), requestNewLink (AJAX, re-emails the link). Also implements the SEF router hooks getUrlSegments() / getSegmentMatching() / getViewNameFromUrlSegments() (see sef-links.md).
models/bcquotelandingpage.phpConfigboxModelBcquotelandingpage. The dynamic stage lookups (getStages() / getStageById() / getDefaultStageId() against the imported-stages table — see pipeline-stages.md) + lost-reason vocab, view-model assembly, validation, the split write, confirmNoChange() (bumps only the follow-up timestamp), the recap, and the re-issue email (with a signed grant token via BcQuoteFollowupAuth). Wraps ConfigboxModelBcquotes.
views/bcquotelandingpage/view.html.phpConfigboxViewBcquotelandingpage. Loads CSS/JS, resolves the display state, holds the inline SVG icon set and the server-side recap renderer.
views/bcquotelandingpage/metadata.xmlView metadata.
views/bcquotelandingpage/tmpl/default.phpShell; picks which state template(s) to render.
views/bcquotelandingpage/tmpl/form.phpThe update form (quote summary panel + status / stage / expected bid date + expected completion date / comments).
views/bcquotelandingpage/tmpl/other_quotes.php"Your other open quotes" tiles (shared by the form and confirm screens).
views/bcquotelandingpage/tmpl/state_confirm.phpPost-submit confirmation (hidden until AJAX success).
views/bcquotelandingpage/tmpl/state_updated.php"You've already updated this quote" recap + "update again".
views/bcquotelandingpage/tmpl/state_expired.php"Link no longer valid" deny screen (wrong/expired token) with two actions — "Request a new link" and "Log in" ($d['loginUrl']).
views/bcquotelandingpage/tmpl/state_notfound.phpFriendly "we couldn't find that quote" screen (unknown/removed/deleted quote), shown instead of a raw 404.
assets/javascript/quoteLandingPage.jsAMD module configbox/custom/quoteLandingPage. Drives the form, AJAX save, confirmation, and re-issue flows. (.min.js is the built copy.)
assets/css/quote-landing-page.cssPage styles, scoped under .view-bcquotelandingpage. (.min.css is the built copy.)
system_overrides/BcQuote.phpAdds the new properties to the BcQuote data object (incl. pipedrive_agent_feedback_comment_pending).
updates/0.5.44.phpDB: adds the four new columns.
updates/0.5.45.phpDB: set the legacy stage column's default to spec_quote and backfilled it. Superseded by native stages in 0.5.65; the column itself was dropped by 0.5.87, so this script is history only.
updates/0.5.68.phpDB: adds the pipedrive_agent_feedback_bid_date quote column (the optional "Expected bid date").
updates/0.5.46.phpDB: creates the quote-follow-up Joomla menu item that gives the page its clean SEF URL (see sef-links.md / migrations.md).
updates/0.5.50.phpDB: adds the Pipedrive Project Phase field-key setting and the pipedrive_agent_feedback_comment_pending deal-Note queue column; resolves the field key from the API. See pipedrive/README.md.
updates/0.5.69.phpDB: creates the #__configbox_external_settings_quote_follow_up extension table and moves the feature's settings into it (see Settings below).
updates/0.5.79.phpDB: the two follow-up defaults onto that extension table (pipedrive_default_pipeline_id moved in, pipedrive_default_stage_id added), seeded to pipeline 1 / stage 1, and existing quotes' test pipeline/stage values reset to the same pair. See pipeline-stages.md.

The stage/pipeline tables and the token/API-secret migrations are listed with their features in pipeline-stages.md and authentication.md.

No change to assets/main.js is needed: configbox/custom already maps to the customization JS directory, so configbox/custom/quoteLandingPage resolves automatically. The view registers it via getJsInitCallsOnce() (configbox/custom/quoteLandingPage::initQuoteLandingPage).


Data model

The feature stores everything on the existing quotes table (#__configbox_external_user_quotes), alongside the pre-existing pipedrive_agent_feedback_status and pipedrive_agent_feedback_lost_reason.

Column (added in 0.5.44)TypeMeaning
pipedrive_agent_feedback_completion_dateDATEExpected order-release date (Y-m-d), nullable. Exported to the sync sheet's expected_release_date.
pipedrive_agent_feedback_commentsTEXTFree-text comments, nullable.
pipedrive_agent_feedback_updated_onDATETIMEUTC timestamp of the last submission via this page — also bumped on its own by the "Nothing has changed" confirmation (see below). This is the "when did the agent last give feedback" stamp. It does three jobs: it is the nudge anchor (the follow-up nudge clock takes the later of this and pipedrive_stage_changed_on, so any response restarts the cadence — nudge-cadence.md); it drives the recap's last updated; and it is exported to the quote-stage-sync sheet's last_feedback_on column (col Z — stage-sync.md).
Column (added in 0.5.50)TypeMeaning
pipedrive_agent_feedback_comment_pendingTEXTUnsent comment queued to be folded into the follow-up ACTIVITY on the next deal update, then cleared. Keeps the append-only entry out of the idempotent deal-field payload.
pipedrive_activity_pendingTINYINTSet by EVERY follow-up submission; the next deal update logs one done "Quote Follow-Up" activity on the deal (status/stage/date summary + comment) and clears it.
Column (added in 0.5.68)TypeMeaning
pipedrive_agent_feedback_bid_dateDATEOptional expected bid date (Y-m-d) — when the project is expected to go out to bid. Distinct from the completion/release date. Exported to the sync sheet's expected_bid_date; not pushed to Pipedrive. See stage-sync.md.

The free-text pipedrive_agent_feedback_stage that 0.5.44 originally added was superseded by the native stage columns in 0.5.65 and dropped in 0.5.87.

The chosen stage is stored as pipedrive_stage_id (+ pipedrive_pipeline_id); those columns and the whole imported-stage model are documented in pipeline-stages.md.

Write split (saveAgentUpdate):

  • Status and lost-reason category go through ConfigboxModelBcquotes::updateAgentFeedbackStatus() / updateAgentFeedbackLostReason() so the existing Pipedrive sync and lost-reason-clearing behaviour is preserved. The status becomes won when the chosen stage is is_won_stage (there is no "Won" button).
  • The native Pipedrive deal status is then set authoritatively via updatePipedriveDealStatus(): won-stage → won, non-won → open, lost → lost. For a Lost / Other submission the typed free-form text is written as the native lost reason (pipedrive_lost_reason) while the category ("Other") stays on pipedrive_agent_feedback_lost_reason. See pipeline-stages.md → Won really wins the deal.
  • Stage / completion date / bid date / comments are written by the landing-page model directly (saveExtraFeedback), and pipedrive_agent_feedback_updated_on is touched (touchFeedbackTimestamp). The bid date is site-local (sheet export only) — it is not sent to Pipedrive.
  • A new/changed comment is also queued for a Pipedrive deal Note via BcQuotePipedriveGateway::queueCommentNote() (parked in pipedrive_agent_feedback_comment_pending, added in 0.5.50), which the outbound runner posts once and then clears.

Because updateAgentFeedbackStatus() is always called (with the current status) and it schedules a Pipedrive deal update, every submission flags the quote for a deal update — even a stage- or comment-only edit. The status/lost reason, stage, completion date and comment all flow to Pipedrive (the bid date is the exception — it is sheet-export-only, never pushed to a deal); which column lands on which deal field is documented in pipedrive-deal-updates.md → The Quote Follow-Up landing page:

Landing-page fieldPipedrive target
Status (Open / Lost / won-via-stage)native Status (open/lost/won — won now propagates too) and the Agent Feedback Status custom field (pipedrive_field_key_agent_feedback_status)
Lost reason (category)native Lost reason + Agent Feedback Lost Reason custom field (pipedrive_field_key_agent_feedback_lost_reason); for Other the native Lost reason carries the free-form text
Project stagethe deal's native stage_id — moves the deal along the configured pipeline (Pipedrive applies the stage's win-probability to the weighted value)
Expected completion datestock Expected close date (sent when set)
Commentsa deal Note (append-only; posted once per new/changed comment)

The stage picker, multi-pipeline handling and default-pipeline pre-selection are in pipeline-stages.md.

"Nothing has changed"

Below the Update quote submit sits a deliberately prominent, value-framed callout (a tinted panel with a short "this helps us plan / we'll stop nudging you" message and a bold "Nothing has changed" button) so an agent with nothing to update responds in one tap instead of overlooking it. It matters because otherwise a quote with no movement keeps re-appearing on the nudge sheet — the agent has no way to say "I looked, still the same."

The action stays at the bottom (grouped with the other actions), but a one-liner near the top of the form ("Nothing changed? Jump to the bottom — one tap", .qlp-nochange-jump) smooth-scrolls to the callout and briefly cues it, so it's discoverable without an agent having to scroll to find it (onJumpToNoChange in quoteLandingPage.js).

Clicking it posts to confirmNoChange (AJAX, gated exactly like saveUpdate), which calls the model's confirmNoChange()touchFeedbackTimestamp() and only bumps pipedrive_agent_feedback_updated_on to now. It deliberately does not change status / stage / dates / comments, schedule a Pipedrive deal update, or add a stage-sync row — nothing moved. Two things follow from the bumped stamp:

  • We know they responded (and when) — the timestamp is the record.
  • The nudge clock restarts — the nudge anchor is the later of pipedrive_stage_changed_on and pipedrive_agent_feedback_updated_on, so the quote drops out of the due set for another cadence period.
  • The stage-sync sheet shows the response — the stamp is the sheet's last_feedback_on column (Z). Because no sync row is created, the value is carried over instead: confirmNoChange() calls BcQuoteStageSyncLog::touchLastFeedback() for a still-un-mirrored row, and the export job refreshes the cell of an already-mirrored one. See stage-sync.md → the one refreshed cell.

The confirmation screen reuses state_confirm with alternate "noted" copy (swapped client-side in quoteLandingPage.js; the JS bumps the button, posts, and reveals the acknowledgement — mirroring the save flow).


Settings

The feature's own settings live together in the Quote Follow-Up Landing Page group (admin → ConfigBox settings), stored in a dedicated extension table #__configbox_external_settings_quote_follow_up (one row per environment, keyed settings_id#__configbox_external_settings.id) via each property's storeExternally=true — the same pattern the Pipedrive group uses for _external_settings_pipedrive (see pipedrive/README.md "Pipedrive settings grouping"). Kenedo joins the table back in automatically, so BcHelper::getCustomSettings() exposes them as plain properties.

SettingMeaning
quote_stage_sync_sheetTarget Google Sheet for the quote-stage-sync mirror. Blank keeps the rows local only — recording is always-on either way. See stage-sync.md.
pipedrive_probability_model_versionFree-text label stamped onto each quote-stage-sync row (the name is legacy — the setting lives here, not with Pipedrive).

The two auth settings (quote_followup_token_lifetime_days, quote_followup_api_secret) live in the same group but are documented in authentication.md → Admin settings; the default pipeline + default stage settings (pipedrive_default_pipeline_id, pipedrive_default_stage_id — on this same extension table since 0.5.79) are in pipeline-stages.md → Admin settings.

The table and the one-time data move (out of the base settings table and _external_settings_pipedrive) are in updates/0.5.69.php. To add another follow-up setting: define it storeExternally on this table in ConfigboxModelAdminbcsettings, add its column in a migration, and place it between quote_followup_start and quote_followup_end in the form order.

The Imported Pipedrive Stages list lives in the Quote Follow-ups settings group — that admin group heading was renamed from "Quote Follow-Up Landing Page" to just "Quote Follow-ups" (the feature is still the Quote Follow-Up landing page), and it is where the follow-up stages + nudge cadence are managed. The group's fields are ordered by concern — the two follow-up defaults, follow-up link, nudge sheet, stage-change log, then the imported Stages and Pipelines lists — and the single Import pipelines & stages from Pipedrive button on the stages list imports both.


Page states

ConfigboxViewBcquotelandingpage::resolveState() picks one of:

  • form — the default. Shown when the quote has no feedback on file yet.
  • updated — derived automatically when feedback already exists. Shows a recap of what's on file with an "Update again" button that reveals the form.
  • expired — the "link no longer valid" deny screen. Offers two recovery buttons: "Request a new link" (emails a fresh magic link to the quote's own agent, never a request-supplied address) and "Log in" (routes to the Joomla login with a return back to this follow-up page, so a portal user whose link is wrong/expired can sign in directly and be authorized by session ownership — the login URL is $d['loginUrl'] = buildLoginRedirectUrl(buildLandingUrl(serial))). This is what a visitor with a wrong or expired token lands on (see authentication.md); it shows no quote detail. Despite the state name, its copy covers both invalid and expired links.
  • notfound — the friendly "we couldn't find that quote" screen, shown (with a proper HTTP 404, via displayNotFound()sendNotFoundStatus()) whenever a serial doesn't resolve to a quote the visitor may see: unknown, removed/deleted, a broken/incomplete link, or a quote a signed-in user doesn't own (deliberately indistinguishable, so existence can't be probed). It renders our own page instead of the raw framework 404. It's a standalone screen: prepareTemplateVars() skips all quote loading for this state (there is no quote to show), so it renders without a quoteId. Names the attempted serial and points the visitor to their latest email link / their rep.

For review/QA you can force a state with &state=form|updated|expired|notfound. The confirmation screen (state_confirm) is rendered hidden and shown client-side after a successful save.


Request flow

  1. display() authenticates the visitor (authentication.md), then reads ?serial, loads the quote via ConfigboxModelBcquotes::getQuoteBySerial(), and renders the resolved state. A missing serial, a serial that doesn't resolve to a quote (unknown/removed/deleted), or a quote the signed-in user doesn't own all render the friendly notfound screen with a proper 404 (displayNotFound()), not a raw error page.
  2. saveUpdate() (AJAX, JSON) validates the payload (getValidationIssues), persists via saveAgentUpdate, and returns a recap for the confirmation card. Validation and the recap are mirrored client-side so the confirmation appears without a reload. The write is gated — see authentication.md → Endpoint gating.
  3. requestNewLink() (AJAX, JSON) re-issues the follow-up link. The recipient is resolved from the quote's user, so the endpoint can't be used to mail arbitrary addresses. The email is branded via ConfigboxViewEmailtemplate and mirrors the cold-quote email pattern. The response returns a masked address (i***@betacalco.com).

The quote's "customer" is the firm and the quote's user is the agent/specifier; the rep name comes from account_manager_id, falling back to sales_manager_id, then to "your Beta-Calco rep". Prices are rendered per-quote via BcHelper::getFormattedPrice / BcHelper::getQuoteCurrency, so each quote shows its own currency. Line items use the regulars-only position mode (alternative/substitute lines are excluded from the total).


Testing / preview

Automated (Playwright)

The page's variations are covered end-to-end (see pipedrive/testing.md for how the suite runs and captures Pipedrive writes; the test catalog lists every spec):

  • tests/specs/agent/quote-follow-up-variations.spec.ts — the form / updated / expired states, the "update again" reveal, the win-probability badge on stages, submit gating (Lost needs a reason), the won-via-the-won-flagged-stage rule (now sets the native deal status to won; no "Won" button), the non-won stage puts the native status back to open (revert), the Lost path (records reason, clears stage/date), the Lost / Other free-form path (category on pipedrive_agent_feedback_lost_reason, free text on the native pipedrive_lost_reason), the expired-screen re-issue-link flow, the friendly notfound screen (proper 404, not a raw error page) for an unknown serial, and the non-owner case (a signed-in user who doesn't own the quote gets the identical notfound + 404, via support.setQuoteOwner). Stages are populated deterministically via support.seedStages() (no live import needed).
  • tests/specs/agent/quote-follow-up-corrections.spec.ts"change of mind" user flows: Lost → reopen to Open (native status put back to open, stage restored, both the agent-feedback and native lost reasons cleared) and Won → correct to Lost (the page authoritatively moves the deal off won to lost, overriding the "agents only suggest" guard). Asserts the persisted quote columns (the deal payload is covered by outbound-deal-payload / quote-follow-up-status).
  • tests/specs/agent/outbound-deal-payload.spec.ts — the Open + stage + date + comment path, asserting the resulting deal payload (native stage_id, expected_close_date, custom fields) and the addActivity.
  • The auth-gate specs (quote-follow-up-auth, quote-follow-up-access, quote-follow-up-status) and the Link API spec (quote-follow-up-link-api) are described in authentication.md → Testing.

Minting a grant token in tests. The page requires authorization (not a login), so specs that aren't already in the agent project mint a token the way an emailed link would carry one: support.mintFollowupToken(serial) (→ cb_pipedrive_test_support.php mint-followup-token, which signs a token for the serial's agent via BcQuoteFollowupAuth). The spec then visits /quote-follow-up/<serial>?token=<token> (starts the grant cookie, redirects to the clean URL) and proceeds; the browser context carries the grant cookie for the save. The agent project runs with a real logged-in session and needs no token. For the expired-token case, support.mintFollowupToken(serial, { expired: true }) returns a validly-signed token backdated past the configured lifetime (needs lifetime > 0, the default).

Manual preview

The page authorizes on a grant or a real session that owns the quote, so preview it either while logged in as the quote's agent, or by opening a tokenized URL (which starts a user-scoped grant for that agent — not a login). Mint a token from a CLI/tinker context with BcQuoteFollowupAuth::generateLink($cbUserId, $serial), from the test CLI with cb_pipedrive_test_support.php mint-followup-token <serial>, or trigger the expired-screen "Request a new link" flow (which emails one). A visitor acting on a quote that isn't their agent's is shown the friendly notfound screen (404), the same as a missing quote. A magic-link (grant) visitor sees the agent's own quotes in the tool — the "other open quotes" tiles are shown — but no "My Quotes" deep link (that needs a login).

  • Happy path: open /quote-follow-up/<a quote with no feedback> (logged in or with a token), pick a status, (for open/won) a stage and optional date, add comments, submit → confirmation card, and the quote now shows the updated state on reload.
  • Won via stage: there is no "Won" button — pick the stage flagged as the "won" stage (is_won_stage) → on submit the quote is won, and the native Pipedrive deal status becomes won. Picking a lower stage afterwards puts the native status back to open.
  • Lost / Other: pick Lost → a reason is required; pick Other → a required free-form field appears (its text becomes the native lost reason; the category stays "Other").
  • Lost path: pick Lost → stage/date hide, a lost reason becomes required.
  • Edge screens: append &state=updated, &state=expired or &state=notfound to preview those without changing data. &state=form forces the form even when feedback exists. (Or just open an unknown serial like /quote-follow-up/NOPE to see the real notfound screen.)
  • Re-issue email: on the expired screen, "Request a new link" → check the agent's inbox; the toast shows the masked address.

Validation errors and transport errors surface inline above the submit button; failed sends and re-issues are logged under the bcquotelandingpage log type.


Operational caveats

The go-live security work — tokenized links, the not-a-login grant, the gated view/write endpoints, and "won" propagating to the deal — is done and described here and in authentication.md and pipeline-stages.md → Won really wins the deal. Two operational prerequisites remain worth knowing:

  • Stages must be imported before the picker appears. The picker is the configured pipeline's stages, imported into #__configbox_external_pipedrive_stages. Set pipedrive_pipeline_id and run the import (backend button or cb_pipedrive_import_stages.php); until then the picker is empty and the stage is not required (the form stays submittable). See pipeline-stages.md and pipedrive/admin-manual.md.
  • Stage sync needs a configured pipeline. The outbound stage_id is only sent for a stored stage that belongs to the configured pipedrive_pipeline_id; a stale/foreign stage id is skipped (status, completion date and the comment note are unaffected). See pipedrive/README.md.

Possible follow-ups

  1. Surface the new fields in the admin quote views alongside the existing feedback.

(Two earlier follow-ups are built: the outbound email cadence exists as the nudge sheet — every row carries a fresh magic link for the n8n reminder workflow — and stage history is captured by the stage-sync log.)