# Convert

*12 August 2026*

A port of the `edconvert` IPS app: contextual sign-up prompts for signed-out
visitors, and the funnel that measures them — shown, clicked, joined, **posted**
— plus what page brought each member in and where they came from.

Pulse answers *who visits*. Community Stats answers *what they have built*. This
answers the question between the two: **which of those visitors joined, and what
were they reading when they decided.**

## 🚨 The seam this port had to add to core

`topic.reply.before` sits inside `@notempty($user)`. A signed-out visitor who
reads a discussion to the end gets the posts, then the page stops — no reply
box, no "you need an account", and **no hook**.

That is the highest-intent moment the software has, and it was unreachable. So
core gained `topic.reply.guest` (1.3.10), rendered where the reply box would be,
firing on locked topics too — somebody who cannot reply either way is still
being invited to join, not to post. Core renders nothing there itself.

Verified the way this project learned to verify hooks, after the IPS original
was caught reasoning about one: a throwaway extension registered on both hooks,
then a real signed-out fetch of a live topic. The guest hook fired with the
right topic id, the member hook did not fire at all.

## The two grains, and why they are never joined in SQL

Impressions and clicks are a **per-day rollup** — one row per prompt per day,
**no row per visitor, ever**. That is what lets this module measure a funnel
without tracking anybody through it, and it is asserted against the schema in
the tests rather than merely written here.

Conversions are **one row per member**, because "which page brought this person
in" is a question about them specifically.

🚨 Aggregated separately and joined in PHP. One query across both multiplies the
impression count by the number of conversions — ten impressions and three
conversions reads as thirty. That is the kind of wrong that still looks
plausible on screen, so a test pins it.

## 🚨 The baseline is the product

Every registration writes a row, **including the ones no prompt produced**
(`prompt = 0`), and the admin screen opens with them rather than with the prompt
table.

"Forty joined unprompted, three through a prompt" is the fact an operator needs
before reading a single click rate. Without those rows the report can only ever
say that prompts work, which is not a measurement — and the IPS original exists
precisely because an audit found the mechanism IPS ships switched on and
converting nobody.

For the same reason a rate with nothing to divide by renders as an em dash, not
0%. Zero claims the prompt was shown and ignored; nothing to divide by says it
has not been shown yet. An operator deletes a prompt over the first and waits
over the second.

## What is kept and what is thrown away

- **Daily counters are pruned** past the retention window. **Conversions never
  are** — they are a fact about members who are still here.
- **Deleting a prompt takes its counters and keeps its people.** They fall back
  to the unattributed baseline, which is where an unknown origin belongs.
- A prompt whose counters have aged out but whose members remain still appears
  in the report, with zero impressions. Dropping it would understate what it did.

## Attribution, and the thing that could have silently broken it

The click and the page it happened on ride in the **session Convoro already
keeps for every visitor** — `StartSession` sets `convoro_session`
unconditionally — so no new tracking is introduced. The only cookie this module
sets is the dismissal, which exists to remember a "no".

🚨 `UserRegistered` fires *after* `Auth::login()`, which calls
`Session::regenerate()`. That rotates the id but **keeps `$data`**;
`invalidate()` is the one that clears, and registration does not call it. If
that ever changes, attribution silently becomes zero and the baseline absorbs
everything — so it is stated here and exercised end to end.

Source is classified **once, on the first page of a session**. Per-page
classification reclassifies every visitor as "internal" the moment they click a
second link and destroys search attribution.

## 🚨 The port-specific bug: a host carries a port and a referrer does not

`Request::host()` returns `HTTP_HOST`, which includes `:8888`. `parse_url()`
never returns a port. Compared raw, **every internal referral on a site not
served from 80 or 443 is filed as traffic from another website**, and the "put
them back where they were" redirect goes to the front page instead.

It fails on exactly the install a developer tests on. Found by clicking the real
dismiss link on the dev server, fixed with `Rules::normaliseHost()`, pinned by a
test.

## What the screenshot pass found that nothing else would

**A 290px hole on every phone.** `flex: 1 1 18rem` is a *width* in a row and a
*height* in a column, so stacking the prompt at 480px forced the text block to
18rem tall and opened a gap between the sentence and the button. The page was
766 device-pixels shorter after one line of CSS.

**All three placement descriptions showing at once.** They sat under a `<select>`,
so choosing where to ask somebody to join — the whole decision this screen exists
for — came with help text about two options nobody had picked. Now radios, each
with its own sentence.

**An unlabelled column.** "Where they came from" read *"Typed the address or a
bookmark … 1"* with no heading, so 1 could have been people, visits or posts.

**`:joined joined unprompted`** rendered literally on the admin screen: Convoro
interpolates `{name}` and the string was written with Laravel's `:name`. It does
not fail, it just prints the placeholder. A test now refuses that syntax
anywhere in the language file.

## Deliberately not carried across

- **`sources/Usage/Usage.php`**, like the other 38 apps that have it. What it
  gated here was the prompt rendering itself on the basic tier; the port simply
  always renders.
- **Per-page classification of content type.** IPS recorded a class name and an
  item id across every app; Convoro records the topic, because a topic is what a
  guest is reading when this fires.
- **A JavaScript impression ping.** Counted server-side at render, as in the
  original — an impression an ad blocker can delete makes the click rate read
  high and wrong.

## Worth knowing

Core already ships a `widget-welcome` block that says *"New here? Join the
conversation"*. It is unconditional, unmeasured and untargeted. An operator
running both will show two similar asks on one page; that is a choice, but the
listing copy should say so.
