conversion: Trichter misst Verwaltungsseite, Anforderung und Firma bei der Einlösung
Roadmap 6.16. Bisher stand jede Einlösung ohne Firma im Trichter (Stufe "Magic Link eingelöst" immer 0 Firmen), zwischen Mail-Klick und Einlösung fehlten "Verwaltungsseite geöffnet" und "Anmeldelink angefordert", und die Erstbuchung stand immer auf organic. Die Leiter hat jetzt sieben Stufen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
f5e3f1d1fe
commit
e8cf684f02
10 changed files with 211 additions and 52 deletions
|
|
@ -12,11 +12,21 @@ namespace App\Enums;
|
|||
* Die Wiedereinreichung nach Ablehnung gehört ausdrücklich NICHT hierher. Sie
|
||||
* misst etwas anderes, nämlich ob die Infoseite (1d) wirkt, und wird gegen den
|
||||
* Basiswert aus 0.2 separat geführt (`press-releases:rejection-rate`).
|
||||
*
|
||||
* Stufen 2 und 3 seit 13.10.2026 (Roadmap 6.16): Vorher sprang die Leiter vom
|
||||
* Klick auf IRGENDEINEN Mail-Link direkt zur Einlösung. Ob jemand die
|
||||
* Verwaltungsseite überhaupt geöffnet und den Link angefordert hatte, war
|
||||
* nicht zu sehen — und damit nicht, ob es am Hinweis, an der Seite oder an
|
||||
* der Zustellung hakt.
|
||||
*/
|
||||
enum ConversionEventType: string
|
||||
{
|
||||
case MailLinkClicked = 'mail_link_clicked';
|
||||
|
||||
case ManagePageOpened = 'manage_page_opened';
|
||||
|
||||
case AccessRequested = 'access_requested';
|
||||
|
||||
case MagicLinkConsumed = 'magic_link_consumed';
|
||||
|
||||
case NewsroomEdited = 'newsroom_edited';
|
||||
|
|
@ -32,10 +42,12 @@ enum ConversionEventType: string
|
|||
{
|
||||
return match ($this) {
|
||||
self::MailLinkClicked => 1,
|
||||
self::MagicLinkConsumed => 2,
|
||||
self::NewsroomEdited => 3,
|
||||
self::AccountCreated => 4,
|
||||
self::FirstBooking => 5,
|
||||
self::ManagePageOpened => 2,
|
||||
self::AccessRequested => 3,
|
||||
self::MagicLinkConsumed => 4,
|
||||
self::NewsroomEdited => 5,
|
||||
self::AccountCreated => 6,
|
||||
self::FirstBooking => 7,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -43,6 +55,8 @@ enum ConversionEventType: string
|
|||
{
|
||||
return match ($this) {
|
||||
self::MailLinkClicked => 'Mail-Link geklickt',
|
||||
self::ManagePageOpened => 'Verwaltungsseite geöffnet',
|
||||
self::AccessRequested => 'Anmeldelink angefordert',
|
||||
self::MagicLinkConsumed => 'Magic Link eingelöst',
|
||||
self::NewsroomEdited => 'Newsroom bearbeitet',
|
||||
self::AccountCreated => 'Konto angelegt',
|
||||
|
|
@ -58,7 +72,9 @@ enum ConversionEventType: string
|
|||
{
|
||||
return match ($this) {
|
||||
self::MailLinkClicked => 'Betreff oder Anlass trägt nicht',
|
||||
self::MagicLinkConsumed => 'Der Verwaltungszugang wird nicht verstanden',
|
||||
self::ManagePageOpened => 'Der Hinweis auf die Verwaltung wird übersehen',
|
||||
self::AccessRequested => 'Der Verwaltungszugang wird nicht verstanden',
|
||||
self::MagicLinkConsumed => 'Der Link kommt nicht an oder wird nicht geöffnet',
|
||||
self::NewsroomEdited => 'Kein Anreiz, das Profil zu pflegen',
|
||||
self::AccountCreated => 'Der Nutzen des Kontos ist nicht erkennbar',
|
||||
self::FirstBooking => 'Preis oder Angebot passt nicht',
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Enums\ConversionEventType;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\MagicLink;
|
||||
use App\Models\PressRelease;
|
||||
|
|
@ -75,13 +74,10 @@ class MagicLinkConsumeController extends Controller
|
|||
return redirect()->route('login')->with('status', __('The magic login link has expired or was already used.'));
|
||||
}
|
||||
|
||||
// Stufe 2 der Konversionsleiter. Die Herkunft kommt vom Token, nicht
|
||||
// Stufe 4 der Konversionsleiter. Die Herkunft kommt vom Token, nicht
|
||||
// aus der Session — die ist beim Weg über Mail und Domainwechsel
|
||||
// längst gerissen (Roadmap 0.4).
|
||||
$this->recorder->record(
|
||||
ConversionEventType::MagicLinkConsumed,
|
||||
$this->recorder->sourceOf($magicLink),
|
||||
);
|
||||
$this->recorder->recordMagicLinkConsumed($magicLink);
|
||||
|
||||
$user = $magicLink->user;
|
||||
|
||||
|
|
@ -123,10 +119,7 @@ class MagicLinkConsumeController extends Controller
|
|||
]);
|
||||
|
||||
if ($claimed > 0) {
|
||||
$this->recorder->record(
|
||||
ConversionEventType::MagicLinkConsumed,
|
||||
$this->recorder->sourceOf($magicLink),
|
||||
);
|
||||
$this->recorder->recordMagicLinkConsumed($magicLink);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Enums\ConversionEventType;
|
||||
use App\Enums\ConversionSource;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Company;
|
||||
|
|
@ -9,6 +10,7 @@ use App\Models\PressRelease;
|
|||
use App\Scopes\PortalScope;
|
||||
use App\Services\Company\MergedNewsroomLookup;
|
||||
use App\Services\Company\NewsroomClaimService;
|
||||
use App\Services\Conversion\ConversionRecorder;
|
||||
use App\Support\WebPortalContext;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
|
@ -29,9 +31,10 @@ class NewsroomClaimController extends Controller
|
|||
public function __construct(
|
||||
private readonly NewsroomClaimService $claims,
|
||||
private readonly MergedNewsroomLookup $mergedNewsrooms,
|
||||
private readonly ConversionRecorder $recorder,
|
||||
) {}
|
||||
|
||||
public function show(string $edition, string $slug): View|Response|RedirectResponse
|
||||
public function show(Request $request, string $edition, string $slug): View|Response|RedirectResponse
|
||||
{
|
||||
$context = WebPortalContext::fromRequest();
|
||||
$company = $this->claims->findClaimable($context->portalValues, $slug);
|
||||
|
|
@ -40,6 +43,8 @@ class NewsroomClaimController extends Controller
|
|||
return $this->resolveMissing($context, $edition, $slug);
|
||||
}
|
||||
|
||||
$this->recorder->recordManagePageOpened($request, $company, $this->sourceOf($request), $this->landingPressRelease($request, $company));
|
||||
|
||||
return view('web.newsroom-claim', [
|
||||
'company' => $company,
|
||||
'recipients' => $this->claims->maskedRecipients($company),
|
||||
|
|
@ -70,7 +75,8 @@ class NewsroomClaimController extends Controller
|
|||
|
||||
// Enumeration-sicher: dieselbe Meldung, ob ein Konto deaktiviert ist
|
||||
// oder nicht — die Adresse ist ohnehin nur maskiert sichtbar.
|
||||
$sent = $this->claims->request($company, $request->ip(), $this->sourceOf($request), $this->landingPressReleaseId($request, $company));
|
||||
$landing = $this->landingPressRelease($request, $company);
|
||||
$sent = $this->claims->request($company, $request->ip(), $this->sourceOf($request), $landing?->getKey());
|
||||
|
||||
// Der Firmenzähler zählt nur tatsächlich versendete Links — sonst
|
||||
// sperrten Dritte den Inhaber mit Fehlversuchen 24 h aus
|
||||
|
|
@ -78,6 +84,10 @@ class NewsroomClaimController extends Controller
|
|||
// Drossel oben.
|
||||
if ($sent > 0) {
|
||||
RateLimiter::hit($this->companyKey($company->getKey()), 86400);
|
||||
|
||||
// Stufe 3 der Konversionsleiter (Roadmap 6.16): nur, wenn
|
||||
// wirklich ein Link verschickt wurde.
|
||||
$this->recorder->record(ConversionEventType::AccessRequested, $this->sourceOf($request), $company, $landing);
|
||||
}
|
||||
|
||||
return back()->with('newsroom-claim-status', __('static.newsroom_claim.flash_sent'));
|
||||
|
|
@ -88,7 +98,7 @@ class NewsroomClaimController extends Controller
|
|||
* Link), landet er nach dem Login auf genau dieser Meldung samt Aufrufen
|
||||
* (Roadmap 1c.2) — sofern sie wirklich zu dieser Firma gehört.
|
||||
*/
|
||||
private function landingPressReleaseId(Request $request, Company $company): ?int
|
||||
private function landingPressRelease(Request $request, Company $company): ?PressRelease
|
||||
{
|
||||
$id = (int) $request->input('pr', 0);
|
||||
|
||||
|
|
@ -96,7 +106,7 @@ class NewsroomClaimController extends Controller
|
|||
return null;
|
||||
}
|
||||
|
||||
return PressRelease::query()->withoutGlobalScope(PortalScope::class)->whereKey($id)->where('company_id', $company->getKey())->exists() ? $id : null;
|
||||
return PressRelease::query()->withoutGlobalScope(PortalScope::class)->whereKey($id)->where('company_id', $company->getKey())->first();
|
||||
}
|
||||
|
||||
private function resolveMissing(WebPortalContext $context, string $edition, string $slug): Response|RedirectResponse
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ use App\Enums\ConversionSource;
|
|||
use App\Models\PressRelease;
|
||||
use App\Scopes\PortalScope;
|
||||
use App\Services\Conversion\ConversionRecorder;
|
||||
use App\Support\CrawlerIdentifier;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
|
|
@ -49,7 +48,7 @@ class RecordMailLinkClick
|
|||
// Nur echte Aufrufe. Mailclients und Sicherheitsscanner holen Links
|
||||
// vorab per HEAD oder mit eigener Kennung; das wäre kein Klick,
|
||||
// sondern eine Zustellung (Umsetzungsaudit 29.09.2026, R-08).
|
||||
if (! $request->isMethod('GET') || $this->isScanner($request->userAgent())) {
|
||||
if (! $request->isMethod('GET') || $this->recorder->isAutomatedVisit($request->userAgent())) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
|
|
@ -81,23 +80,6 @@ class RecordMailLinkClick
|
|||
return $next($request);
|
||||
}
|
||||
|
||||
private function isScanner(?string $userAgent): bool
|
||||
{
|
||||
$userAgent = mb_strtolower(trim((string) $userAgent));
|
||||
|
||||
if ($userAgent === '' || CrawlerIdentifier::identify($userAgent) !== null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach ((array) config('conversion.mail_clicks.scanner_agents', []) as $pattern) {
|
||||
if (str_contains($userAgent, mb_strtolower((string) $pattern))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope-frei aufgelöst: Der Klick landet je nach Link auf dem Hub oder auf
|
||||
* einem der beiden Spokes, der Portal-Scope würde die Meldung dort
|
||||
|
|
|
|||
|
|
@ -248,7 +248,7 @@ class ProcessStripeWebhook
|
|||
return;
|
||||
}
|
||||
|
||||
// Stufe 5 der Konversionsleiter: die erste bezahlte Rechnung eines
|
||||
// Stufe 7 der Konversionsleiter: die erste bezahlte Rechnung eines
|
||||
// Kontos. Deckt Abos und alle über Rechnung abgewickelten Zahlungen ab;
|
||||
// Einmalkäufe melden zusätzlich in fulfillSinglePurchase(). Die Sperre
|
||||
// je Firma sorgt dafür, dass trotzdem nur die erste Buchung zählt.
|
||||
|
|
@ -469,7 +469,7 @@ class ProcessStripeWebhook
|
|||
if ($result['marked_paid']) {
|
||||
Log::channel('billing')->info('Einmalkauf als bezahlt markiert.', ['single_purchase_id' => $purchase->id]);
|
||||
|
||||
// Stufe 5 der Konversionsleiter. recordFirstBooking() zählt je
|
||||
// Stufe 7 der Konversionsleiter. recordFirstBooking() zählt je
|
||||
// Firma nur den ersten Treffer, deshalb dürfen beide Zahlungspfade
|
||||
// melden, ohne sich gegenseitig zu verfälschen.
|
||||
$this->conversions->recordFirstBooking($purchase->user);
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ class RecordAuthActivity
|
|||
meta: ['way' => 'form'],
|
||||
);
|
||||
|
||||
// Stufe 4 der Konversionsleiter. Ohne Firmenbezug — beim Anlegen des
|
||||
// Stufe 6 der Konversionsleiter. Ohne Firmenbezug — beim Anlegen des
|
||||
// Kontos steht noch keine fest; die Zuordnung entsteht erst, wenn ein
|
||||
// Newsroom beansprucht wird.
|
||||
$this->recorder->record(ConversionEventType::AccountCreated, ConversionSource::Organic);
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@ use App\Models\MagicLink;
|
|||
use App\Models\PressRelease;
|
||||
use App\Models\User;
|
||||
use App\Scopes\PortalScope;
|
||||
use App\Support\CrawlerIdentifier;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
|
||||
/**
|
||||
* Schreibt die Ereignisse der Konversionsleiter (Roadmap 0.5).
|
||||
|
|
@ -73,6 +76,83 @@ class ConversionRecorder
|
|||
$this->record($type, $source, $company, $pressRelease);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stufe 2: die Seite „Diesen Newsroom verwalten“ wurde geöffnet.
|
||||
*
|
||||
* Aus einer Mail (`src`) oder über den Hinweis auf der Newsroom- bzw.
|
||||
* Meldungsseite. Wie beim Mail-Klick zählen nur echte Aufrufe, und
|
||||
* dieselbe Adresse zählt je Firma höchstens einmal im Zeitfenster.
|
||||
*/
|
||||
public function recordManagePageOpened(Request $request, Company $company, ConversionSource $source, ?PressRelease $pressRelease = null): void
|
||||
{
|
||||
try {
|
||||
if (! $request->isMethod('GET') || $this->isAutomatedVisit($request->userAgent())) {
|
||||
return;
|
||||
}
|
||||
|
||||
$key = 'manage-page:'.hash('sha256', (string) $request->ip()).':'.$company->getKey();
|
||||
$decay = max(1, (int) config('conversion.mail_clicks.throttle_minutes', 30)) * 60;
|
||||
|
||||
if (! RateLimiter::attempt($key, 1, static fn (): bool => true, $decay)) {
|
||||
return;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->record(ConversionEventType::ManagePageOpened, $source, $company, $pressRelease);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stufe 4: Magic Link eingelöst — mit der Firma und Meldung, die das
|
||||
* Token trägt („Diesen Newsroom verwalten“, Veröffentlichungsmail).
|
||||
* Ohne diesen Bezug landete jede Einlösung in „ohne Firma“, und die
|
||||
* Stufe zeigte bis 13.10.2026 immer null Firmen (Roadmap 6.16).
|
||||
*/
|
||||
public function recordMagicLinkConsumed(MagicLink $magicLink): void
|
||||
{
|
||||
try {
|
||||
$companyId = (int) ($magicLink->payload['company_id'] ?? 0);
|
||||
$pressReleaseId = (int) ($magicLink->payload['press_release_id'] ?? 0);
|
||||
|
||||
$company = $companyId > 0
|
||||
? Company::query()->withoutGlobalScope(PortalScope::class)->find($companyId)
|
||||
: null;
|
||||
$pressRelease = $pressReleaseId > 0
|
||||
? PressRelease::query()->withoutGlobalScope(PortalScope::class)->find($pressReleaseId)
|
||||
: null;
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
$company = null;
|
||||
$pressRelease = null;
|
||||
}
|
||||
|
||||
$this->record(ConversionEventType::MagicLinkConsumed, $this->sourceOf($magicLink), $company, $pressRelease);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mailserver-Scanner, Vorschau-Dienste und Crawler — kein Mensch, also
|
||||
* kein Schritt in der Leiter (Umsetzungsaudit 29.09.2026, R-08).
|
||||
*/
|
||||
public function isAutomatedVisit(?string $userAgent): bool
|
||||
{
|
||||
$userAgent = mb_strtolower(trim((string) $userAgent));
|
||||
|
||||
if ($userAgent === '' || CrawlerIdentifier::identify($userAgent) !== null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach ((array) config('conversion.mail_clicks.scanner_agents', []) as $pattern) {
|
||||
if (str_contains($userAgent, mb_strtolower((string) $pattern))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Herkunft eines Magic-Link-Tokens.
|
||||
*
|
||||
|
|
@ -102,13 +182,17 @@ class ConversionRecorder
|
|||
}
|
||||
|
||||
/**
|
||||
* Stufe 5 der Konversionsleiter.
|
||||
* Stufe 7 der Konversionsleiter.
|
||||
*
|
||||
* Der Trichter läuft über die FIRMA, gezahlt wird aber vom Konto. Die
|
||||
* Brücke ist die Firma des Kontos — bevorzugt die eigene, sonst die erste
|
||||
* zugeordnete. Ohne Firma bleibt der Bezug leer; das Ereignis ist dann
|
||||
* für die Trichter-Auswertung wertlos, aber immer noch eine ehrliche Zeile
|
||||
* im Log.
|
||||
*
|
||||
* Die Herkunft ist wie bei Stufe 5 die des letzten eingelösten Magic
|
||||
* Links; bis 13.10.2026 stand hier immer `organic`, auch nach einem
|
||||
* Claim aus der Veröffentlichungsmail (Roadmap 6.16).
|
||||
*/
|
||||
public function recordFirstBooking(?User $user): void
|
||||
{
|
||||
|
|
@ -125,11 +209,11 @@ class ConversionRecorder
|
|||
return;
|
||||
}
|
||||
|
||||
$this->recordOnce(ConversionEventType::FirstBooking, ConversionSource::Organic, $company);
|
||||
$this->recordOnce(ConversionEventType::FirstBooking, $this->lastMagicLinkSource($user), $company);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stufe 3 der Konversionsleiter: Newsroom gepflegt (Roadmap 1b.3).
|
||||
* Stufe 5 der Konversionsleiter: Newsroom gepflegt (Roadmap 1b.3).
|
||||
*
|
||||
* Einmal je Firma. Die Herkunft ist die des letzten eingelösten Magic
|
||||
* Links dieses Kontos — wer über „Diesen Newsroom verwalten" aus der Mail
|
||||
|
|
@ -138,8 +222,17 @@ class ConversionRecorder
|
|||
*/
|
||||
public function recordNewsroomEdited(Company $company, ?User $user): void
|
||||
{
|
||||
$this->recordOnce(ConversionEventType::NewsroomEdited, $this->lastMagicLinkSource($user), $company);
|
||||
}
|
||||
|
||||
private function lastMagicLinkSource(?User $user): ConversionSource
|
||||
{
|
||||
if ($user === null) {
|
||||
return ConversionSource::Organic;
|
||||
}
|
||||
|
||||
try {
|
||||
$source = $user === null ? null : MagicLink::query()
|
||||
$source = MagicLink::query()
|
||||
->where('user_id', $user->getKey())
|
||||
->whereIn('purpose', MagicLink::LOGIN_PURPOSES)
|
||||
->whereNotNull('consumed_at')
|
||||
|
|
@ -150,7 +243,7 @@ class ConversionRecorder
|
|||
$source = null;
|
||||
}
|
||||
|
||||
$this->recordOnce(ConversionEventType::NewsroomEdited, ConversionSource::parse($source), $company);
|
||||
return ConversionSource::parse($source);
|
||||
}
|
||||
|
||||
private function alreadyRecorded(ConversionEventType $type, Company $company): bool
|
||||
|
|
|
|||
|
|
@ -192,7 +192,7 @@ new #[Layout('components.layouts.app'), Title('Firma')] class extends Component
|
|||
|
||||
$this->resetCompanyForm();
|
||||
|
||||
// Stufe 3 der Konversionsleiter: Newsroom gepflegt (einmal je Firma).
|
||||
// Stufe 5 der Konversionsleiter: Newsroom gepflegt (einmal je Firma).
|
||||
$conversions->recordNewsroomEdited($company, auth()->user());
|
||||
|
||||
// Der öffentliche Newsroom zeigt Name, Logo und Beschreibung — die
|
||||
|
|
|
|||
|
|
@ -140,7 +140,7 @@ test('consuming a magic link records step two with the token origin', function (
|
|||
expect($event->source)->toBe(ConversionSource::MailReject);
|
||||
});
|
||||
|
||||
test('registering records step four', function () {
|
||||
test('registering records the account step', function () {
|
||||
/** @var TestCase $this */
|
||||
event(new Registered(User::factory()->create()));
|
||||
|
||||
|
|
@ -166,6 +166,21 @@ test('the first booking is recorded once per company', function () {
|
|||
->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('the first booking carries the origin of the last redeemed link', function () {
|
||||
/** @var TestCase $this */
|
||||
$user = User::factory()->create();
|
||||
$company = Company::factory()->create(['owner_user_id' => $user->id]);
|
||||
$link = app(MagicLinkGenerator::class)->createLoginLink($user, null, 15, ConversionSource::MailPublish);
|
||||
$link['magic_link']->forceFill(['consumed_at' => now()])->save();
|
||||
|
||||
app(ConversionRecorder::class)->recordFirstBooking($user);
|
||||
|
||||
// Bis 13.10.2026 stand hier immer `organic` — auch wer über die
|
||||
// Veröffentlichungsmail kam und dann buchte (Roadmap 6.16).
|
||||
expect(ConversionEvent::query()->where('type', ConversionEventType::FirstBooking->value)->sole()->source)
|
||||
->toBe(ConversionSource::MailPublish);
|
||||
});
|
||||
|
||||
test('a broken recording never breaks the request it accompanies', function () {
|
||||
/** @var TestCase $this */
|
||||
Schema::drop('conversion_events');
|
||||
|
|
@ -184,7 +199,7 @@ test('the funnel counts distinct companies per step', function () {
|
|||
funnelEvent(ConversionEventType::MailLinkClicked, ConversionSource::MailPublish, $a);
|
||||
funnelEvent(ConversionEventType::MailLinkClicked, ConversionSource::MailPublish, $a);
|
||||
funnelEvent(ConversionEventType::MailLinkClicked, ConversionSource::MailPublish, $b);
|
||||
funnelEvent(ConversionEventType::MagicLinkConsumed, ConversionSource::MailPublish, $a);
|
||||
funnelEvent(ConversionEventType::ManagePageOpened, ConversionSource::MailPublish, $a);
|
||||
|
||||
$data = app(ConversionFunnelReport::class)->build(now()->subMonth());
|
||||
$steps = collect($data['sources'])->firstWhere('source', ConversionSource::MailPublish->value)['steps'];
|
||||
|
|
@ -264,7 +279,7 @@ test('the ladder is complete and ordered', function () {
|
|||
$steps = array_map(fn (ConversionEventType $t): int => $t->step(), ConversionEventType::ladder());
|
||||
|
||||
// Eine Leiter ohne Sprossen sagt nicht, wo es hakt.
|
||||
expect($steps)->toBe([1, 2, 3, 4, 5])
|
||||
expect($steps)->toBe([1, 2, 3, 4, 5, 6, 7])
|
||||
->and(ConversionEventType::NewsroomEdited->diagnosis())->toBe('Kein Anreiz, das Profil zu pflegen');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -81,8 +81,58 @@ test('requesting the link creates the account, sends the login link with profile
|
|||
$this->get('https://pressekonto.test/magic-login/'.ltrim($plain, '/'))
|
||||
->assertRedirect(route('me.press-kits.show', ['id' => $company->id], absolute: false));
|
||||
|
||||
expect(ConversionEvent::query()->where('type', ConversionEventType::MagicLinkConsumed->value)->first()->source)
|
||||
->toBe(ConversionSource::ProfileClaim);
|
||||
$consumed = ConversionEvent::query()->where('type', ConversionEventType::MagicLinkConsumed->value)->sole();
|
||||
expect($consumed->source)->toBe(ConversionSource::ProfileClaim)
|
||||
->and($consumed->company_id)->toBe($company->id);
|
||||
});
|
||||
|
||||
test('the way from the publication mail is recorded step by step with the company', function () {
|
||||
/** @var TestCase $this */
|
||||
$company = claimCompany();
|
||||
$release = PressRelease::factory()->published()->forPortal(Portal::Businessportal24)
|
||||
->create(['company_id' => $company->id, 'published_at' => now()->subDay()]);
|
||||
$query = '?src='.ConversionSource::MailPublish->value.'&pr='.$release->id;
|
||||
|
||||
// Roadmap 6.16: Vorher sprang die Leiter vom Klick auf irgendeinen
|
||||
// Mail-Link direkt zur Einlösung, und die Einlösung hatte keine Firma.
|
||||
$this->get(claimUrl().$query)->assertOk();
|
||||
$this->post(claimUrl(), ['src' => ConversionSource::MailPublish->value, 'pr' => $release->id])
|
||||
->assertSessionHas('newsroom-claim-status');
|
||||
|
||||
$plain = str_replace('/magic-login/', '', parse_url(Mail::queued(MagicLoginLink::class)->first()->loginUrl, PHP_URL_PATH));
|
||||
$this->get('https://pressekonto.test/magic-login/'.ltrim($plain, '/'));
|
||||
|
||||
$events = ConversionEvent::query()->get()->keyBy(fn (ConversionEvent $event): string => $event->type->value);
|
||||
|
||||
foreach ([ConversionEventType::ManagePageOpened, ConversionEventType::AccessRequested, ConversionEventType::MagicLinkConsumed] as $type) {
|
||||
expect($events->get($type->value))->not->toBeNull()
|
||||
->and($events[$type->value]->source)->toBe(ConversionSource::MailPublish)
|
||||
->and($events[$type->value]->company_id)->toBe($company->id)
|
||||
->and($events[$type->value]->press_release_id)->toBe($release->id);
|
||||
}
|
||||
});
|
||||
|
||||
test('opening the page from the newsroom counts as profile claim, once per visitor and never for scanners', function () {
|
||||
/** @var TestCase $this */
|
||||
$company = claimCompany();
|
||||
$browser = ['User-Agent' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/605.1.15 Safari/605.1.15'];
|
||||
|
||||
$this->withHeaders(['User-Agent' => 'Mozilla/5.0 (compatible; Barracuda Sentinel)'])->get(claimUrl())->assertOk();
|
||||
$this->withHeaders($browser)->get(claimUrl())->assertOk();
|
||||
$this->withHeaders($browser)->get(claimUrl())->assertOk();
|
||||
|
||||
$opened = ConversionEvent::query()->where('type', ConversionEventType::ManagePageOpened->value)->sole();
|
||||
expect($opened->source)->toBe(ConversionSource::ProfileClaim)
|
||||
->and($opened->company_id)->toBe($company->id);
|
||||
});
|
||||
|
||||
test('a request that sends nothing is not step three', function () {
|
||||
/** @var TestCase $this */
|
||||
claimCompany(['email' => null]);
|
||||
|
||||
$this->post(claimUrl())->assertSessionHas('newsroom-claim-none');
|
||||
|
||||
expect(ConversionEvent::query()->where('type', ConversionEventType::AccessRequested->value)->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
test('without a company email the link goes to the press contacts', function () {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue