web: Transparenzseiten — was kostet, ohne Abo, loeschen/aendern (Roadmap 2.4)
Drei Seiten mit unterschiedlicher Suchintention, DE/EN, Hub + Spokes: - /was-kostet-eine-pressemitteilung: kurze Antwort im ersten Bildschirm, vier Abrechnungsmodelle ohne Anbieternamen und fremde Preise, sechs Prueffragen fuer Folgekosten, "Was bei uns gilt" (sieben Zusagen mit Einloesung), Rechenbeispiel Einzelabrechnung gegen Starter-Monate mit Zeilen fuer beide Seiten. - /pressemitteilung-veroeffentlichen-ohne-abo: Ablauf, enthalten / nicht enthalten, nichts verfaellt, wann sich ein Tarif lohnt. - /pressemitteilung-loeschen-oder-aendern-lassen: fuenf Wege als Tabelle (Korrektur, Aenderung, DSGVO, Depublikation, DSA), Antragsweg ueber /melden/pm, Korrektur statt Loeschung, Absatz fuer Meldungen auf fremden Portalen ohne Zusagen ueber Dritte. PricingTransparencyData liefert alle Zahlen aus config/billing, credits, legal und dem Starter-Tarif; TransparencyPageController leitet die Marke aus dem Host ab. Tabellen-Typografie fuer .pm-body. Verlinkt aus Preisuebersicht, Hilfe-Center, Sitemaps und llms.txt. Test TransparencyPagesTest (9) erzwingt u. a., dass im Rechenbeispiel beide Modelle Zeilen gewinnen. Protokoll Teil 5, Roadmap 2.4 abgehakt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
84ea617107
commit
b6e4bc5dc0
18 changed files with 953 additions and 4 deletions
|
|
@ -48,6 +48,8 @@ class LlmsTxtController extends Controller
|
|||
'- [Was eine gute Pressemitteilung ausmacht]('.$base.'/de/gute-pressemitteilung): Anlass, Aufbau, Sprache, Prüfablauf',
|
||||
'- [FAQ]('.$base.'/de/faq)',
|
||||
'- [Richtlinien]('.$base.'/de/richtlinien): Was veröffentlicht wird und was nicht',
|
||||
'- [Was kostet eine Pressemitteilung?]('.$base.'/de/was-kostet-eine-pressemitteilung): Abrechnungsmodelle, Prüfliste für Folgekosten, Rechenbeispiel',
|
||||
'- [Pressemitteilung löschen oder ändern lassen]('.$base.'/de/pressemitteilung-loeschen-oder-aendern-lassen)',
|
||||
'- [API]('.$base.'/de/api): Einlieferung per REST-API; OpenAPI-Spezifikation unter '.$base.'/docs/api/v1',
|
||||
'',
|
||||
'## Portale',
|
||||
|
|
@ -106,6 +108,9 @@ class LlmsTxtController extends Controller
|
|||
'',
|
||||
'- [Veröffentlichen]('.$base.'/de/veroeffentlichen): Wie eine Meldung auf das Portal kommt',
|
||||
'- [Preise]('.$base.'/de/preise)',
|
||||
'- [Was kostet eine Pressemitteilung?]('.$base.'/de/was-kostet-eine-pressemitteilung): Abrechnungsmodelle, Prüfliste für Folgekosten, Rechenbeispiel',
|
||||
'- [Pressemitteilung veröffentlichen ohne Abo]('.$base.'/de/pressemitteilung-veroeffentlichen-ohne-abo)',
|
||||
'- [Pressemitteilung löschen oder ändern lassen]('.$base.'/de/pressemitteilung-loeschen-oder-aendern-lassen): Korrektur, Änderung, DSGVO, Depublikation',
|
||||
'- [Was eine gute Pressemitteilung ausmacht]('.$base.'/de/gute-pressemitteilung)',
|
||||
'- [Hilfe]('.$base.'/de/hilfe)',
|
||||
'- [API]('.$base.'/de/api): Einlieferung per REST-API über den Publisher-Bereich '.$hub,
|
||||
|
|
|
|||
|
|
@ -26,12 +26,14 @@ class SitemapController extends Controller
|
|||
/** @var list<string> */
|
||||
private const HUB_PAGES = [
|
||||
'faq', 'hilfe', 'gute-pressemitteilung', 'api', 'kuendigung',
|
||||
'was-kostet-eine-pressemitteilung', 'pressemitteilung-veroeffentlichen-ohne-abo', 'pressemitteilung-loeschen-oder-aendern-lassen',
|
||||
'impressum', 'datenschutz', 'agb', 'richtlinien', 'melden',
|
||||
];
|
||||
|
||||
/** @var list<string> */
|
||||
private const PORTAL_PAGES = [
|
||||
'preise', 'veroeffentlichen', 'newsrooms', 'faq', 'hilfe', 'gute-pressemitteilung', 'api',
|
||||
'was-kostet-eine-pressemitteilung', 'pressemitteilung-veroeffentlichen-ohne-abo', 'pressemitteilung-loeschen-oder-aendern-lassen',
|
||||
'impressum', 'datenschutz', 'agb', 'richtlinien', 'melden',
|
||||
];
|
||||
|
||||
|
|
|
|||
50
app/Http/Controllers/Web/TransparencyPageController.php
Normal file
50
app/Http/Controllers/Web/TransparencyPageController.php
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Web\PricingTransparencyData;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Transparenzseiten (Roadmap 2.4): drei Seiten mit unterschiedlicher
|
||||
* Suchintention, alle mit denselben Zahlen aus PricingTransparencyData.
|
||||
*/
|
||||
class TransparencyPageController extends Controller
|
||||
{
|
||||
public function cost(Request $request, PricingTransparencyData $data): View
|
||||
{
|
||||
return view('web.was-kostet-eine-pressemitteilung', $this->pageData($request, $data));
|
||||
}
|
||||
|
||||
public function noSubscription(Request $request, PricingTransparencyData $data): View
|
||||
{
|
||||
return view('web.pressemitteilung-veroeffentlichen-ohne-abo', $this->pageData($request, $data));
|
||||
}
|
||||
|
||||
public function changeOrDelete(Request $request, PricingTransparencyData $data): View
|
||||
{
|
||||
return view('web.pressemitteilung-loeschen-oder-aendern-lassen', $this->pageData($request, $data));
|
||||
}
|
||||
|
||||
/**
|
||||
* Markenname aus dem Host (wie /melden) — der Theme-Provider ist in
|
||||
* Tests noch nicht auf den Request-Host gelaufen.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function pageData(Request $request, PricingTransparencyData $data): array
|
||||
{
|
||||
$host = $request->getHost();
|
||||
$brandKey = str_contains($host, 'pressekonto')
|
||||
? 'pressekonto'
|
||||
: (str_contains($host, 'presseecho') ? 'presseecho' : 'businessportal24');
|
||||
$brand = (array) config('domains.domains.'.$brandKey.'.brand', []);
|
||||
|
||||
return [
|
||||
...$data->build(),
|
||||
'brandLabel' => ($brand['name'] ?? '').($brand['accent'] ?? ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
87
app/Services/Web/PricingTransparencyData.php
Normal file
87
app/Services/Web/PricingTransparencyData.php
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Web;
|
||||
|
||||
use App\Enums\Tier;
|
||||
use App\Models\Plan;
|
||||
|
||||
/**
|
||||
* Zahlen für die Transparenzseiten (Roadmap 2.4): alles aus config/billing,
|
||||
* config/credits und dem Starter-Tarif, damit die Seiten nie einen
|
||||
* anderen Preis nennen als die Preisübersicht. Das Rechenbeispiel stellt
|
||||
* Einzelabrechnung und einen bei Bedarf gebuchten Starter-Monat gegenüber —
|
||||
* bewusst mit Zeilen, in denen die Einzelabrechnung gewinnt.
|
||||
*/
|
||||
class PricingTransparencyData
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function build(): array
|
||||
{
|
||||
$singleCents = (int) config('billing.single_pm_price_cents');
|
||||
$starter = Plan::query()->active()->where('slug', Tier::Starter->value)->first();
|
||||
$starterMonthlyCents = (int) ($starter->monthly_price_cents ?? 0);
|
||||
$starterQuota = (int) ($starter->press_release_quota ?? 0);
|
||||
$vatRate = (float) config('billing.vat_rate');
|
||||
|
||||
return [
|
||||
'singleCents' => $singleCents,
|
||||
'singleNet' => $this->euro($singleCents),
|
||||
'singleGross' => $this->euro((int) round($singleCents * (1 + $vatRate))),
|
||||
'vatPercent' => (int) round($vatRate * 100),
|
||||
'starterName' => $starter->name ?? 'Starter',
|
||||
'starterMonthlyNet' => $this->euro($starterMonthlyCents),
|
||||
'starterYearlyNet' => $this->euro((int) ($starter->yearly_price_cents ?? 0)),
|
||||
'starterQuota' => $starterQuota,
|
||||
'extraPmStarterNet' => $this->euro((int) config('credits.extra_pm.'.Tier::Starter->value, 0) * 100),
|
||||
'changeNet' => $this->euro((int) config('billing.change_price_cents')),
|
||||
'depublicationNet' => $this->euro((int) config('billing.depublication_price_cents')),
|
||||
'correctionMaxPercent' => (int) config('legal.correction_max_distance_percent'),
|
||||
'examples' => $starterMonthlyCents > 0 && $starterQuota > 0
|
||||
? $this->examples($singleCents, $starterMonthlyCents, $starterQuota)
|
||||
: [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Rechenbeispiel: Meldungen im Jahr, Verteilung, Kosten je Modell.
|
||||
* Der Starter-Monat wird nur in Monaten gebucht, in denen veröffentlicht
|
||||
* wird (monatlich kündbar); je Monat höchstens das Kontingent.
|
||||
*
|
||||
* @return list<array{count: int, spread: string, months: int, single: string, starter: string, cheaper: string}>
|
||||
*/
|
||||
private function examples(int $singleCents, int $starterMonthlyCents, int $starterQuota): array
|
||||
{
|
||||
$cases = [
|
||||
['count' => 1, 'months' => 1, 'spread' => 'one'],
|
||||
['count' => $starterQuota, 'months' => 1, 'spread' => 'same_month'],
|
||||
['count' => $starterQuota, 'months' => $starterQuota, 'spread' => 'spread'],
|
||||
['count' => 12, 'months' => 12, 'spread' => 'monthly'],
|
||||
['count' => 12, 'months' => (int) ceil(12 / $starterQuota), 'spread' => 'clustered'],
|
||||
];
|
||||
|
||||
$rows = [];
|
||||
|
||||
foreach ($cases as $case) {
|
||||
$single = $case['count'] * $singleCents;
|
||||
$starter = $case['months'] * $starterMonthlyCents;
|
||||
|
||||
$rows[] = [
|
||||
'count' => $case['count'],
|
||||
'spread' => $case['spread'],
|
||||
'months' => $case['months'],
|
||||
'single' => $this->euro($single),
|
||||
'starter' => $this->euro($starter),
|
||||
'cheaper' => $single === $starter ? 'equal' : ($single < $starter ? 'single' : 'starter'),
|
||||
];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
private function euro(int $cents): string
|
||||
{
|
||||
return number_format($cents / 100, $cents % 100 === 0 ? 0 : 2, ',', '.');
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue