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.
| File | Role |
|---|---|
controllers/bcquotelandingpage.php | ConfigboxControllerBcquotelandingpage. 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.php | ConfigboxModelBcquotelandingpage. 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.php | ConfigboxViewBcquotelandingpage. Loads CSS/JS, resolves the display state, holds the inline SVG icon set and the server-side recap renderer. |
views/bcquotelandingpage/metadata.xml | View metadata. |
views/bcquotelandingpage/tmpl/default.php | Shell; picks which state template(s) to render. |
views/bcquotelandingpage/tmpl/form.php | The 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.php | Post-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.php | Friendly "we couldn't find that quote" screen (unknown/removed/deleted quote), shown instead of a raw 404. |
assets/javascript/quoteLandingPage.js | AMD 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.css | Page styles, scoped under .view-bcquotelandingpage. (.min.css is the built copy.) |
system_overrides/BcQuote.php | Adds the new properties to the BcQuote data object (incl. pipedrive_agent_feedback_comment_pending). |
updates/0.5.44.php | DB: adds the four new columns. |
updates/0.5.45.php | DB: 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.php | DB: adds the pipedrive_agent_feedback_bid_date quote column (the optional "Expected bid date"). |
updates/0.5.46.php | DB: 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.php | DB: 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.php | DB: 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.php | DB: 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) | Type | Meaning |
|---|---|---|
pipedrive_agent_feedback_completion_date | DATE | Expected order-release date (Y-m-d), nullable. Exported to the sync sheet's expected_release_date. |
pipedrive_agent_feedback_comments | TEXT | Free-text comments, nullable. |
pipedrive_agent_feedback_updated_on | DATETIME | UTC 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) | Type | Meaning |
|---|---|---|
pipedrive_agent_feedback_comment_pending | TEXT | Unsent 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_pending | TINYINT | Set 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) | Type | Meaning |
|---|---|---|
pipedrive_agent_feedback_bid_date | DATE | Optional 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 isis_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 onpipedrive_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), andpipedrive_agent_feedback_updated_onis 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 inpipedrive_agent_feedback_comment_pending, added in0.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 field | Pipedrive 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 stage | the 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 date | stock Expected close date (sent when set) |
| Comments | a 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_onandpipedrive_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_oncolumn (Z). Because no sync row is created, the value is carried over instead:confirmNoChange()callsBcQuoteStageSyncLog::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.
| Setting | Meaning |
|---|---|
quote_stage_sync_sheet | Target 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_version | Free-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 areturnback 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, viadisplayNotFound()→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 aquoteId. 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
display()authenticates the visitor (authentication.md), then reads?serial, loads the quote viaConfigboxModelBcquotes::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 friendlynotfoundscreen with a proper 404 (displayNotFound()), not a raw error page.saveUpdate()(AJAX, JSON) validates the payload (getValidationIssues), persists viasaveAgentUpdate, and returns arecapfor 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.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 viaConfigboxViewEmailtemplateand 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— theform/updated/expiredstates, 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 towon; no "Won" button), the non-won stage puts the native status back toopen(revert), the Lost path (records reason, clears stage/date), the Lost / Other free-form path (category onpipedrive_agent_feedback_lost_reason, free text on the nativepipedrive_lost_reason), the expired-screen re-issue-link flow, the friendlynotfoundscreen (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 identicalnotfound+ 404, viasupport.setQuoteOwner). Stages are populated deterministically viasupport.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 toopen, stage restored, both the agent-feedback and native lost reasons cleared) and Won → correct to Lost (the page authoritatively moves the deal offwontolost, overriding the "agents only suggest" guard). Asserts the persisted quote columns (the deal payload is covered byoutbound-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 (nativestage_id,expected_close_date, custom fields) and theaddActivity.- 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
agentproject 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 viaBcQuoteFollowupAuth). 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. Theagentproject 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 theupdatedstate 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 becomeswon. Picking a lower stage afterwards puts the native status back toopen. - 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=expiredor&state=notfoundto preview those without changing data.&state=formforces the form even when feedback exists. (Or just open an unknown serial like/quote-follow-up/NOPEto see the realnotfoundscreen.) - 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. Setpipedrive_pipeline_idand run the import (backend button orcb_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_idis only sent for a stored stage that belongs to the configuredpipedrive_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
- 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.)
Related docs
- authentication.md — the no-login magic link + the machine Link API.
- pipeline-stages.md — the imported Pipedrive pipelines & stages behind the picker.
- stage-sync.md · nudge-cadence.md · the Quote Follow-Up hub.