Phase 9F: Tarif-Seite mit Stripe-Checkout und Billing Portal
- Buchungs-Seite zeigt das echte 4-Tier-Raster aus plans (Monat/Jahr-
Toggle, Jahrespreis als "2 Monate gratis") mit Checkout-Buttons,
Einzel-PM als separaten No-Abo-Block und Enterprise-Hinweis;
Credit-Konzept-Mock entfernt (Credits folgen mit 9I bzw. Phase 2)
- Aktueller-Tarif-Panel real: Abo (Preis, Kontingent, Kündigungsstatus),
Bestandstarif (unbegrenzt, nächste MAN-Rechnung), offene Einzelkäufe;
Kontingent-Kachel zeigt "Unbegrenzt" bei Bestandsschutz
- "Abo verwalten" über das Stripe Billing Portal
(me.checkout.billing-portal; Zahlungsmethode, Rechnungen, Kündigung)
- Aktive Buchungen + Verlauf aus echten Daten (Abo, Legacy-Vereinbarung,
offene/eingelöste Einzelkäufe mit PM-Verknüpfung)
- Tests: BookingsPageTest (9 Tests), PanelConsolidationTest angepasst;
Suite 519 passed / 4 skipped
- Doku: PHASE-9-Plan 9F ✅, Billing-Doku (Routen, Stripe Tax aktiviert),
STATUS-ABGLEICH, Checkliste, PROGRESS
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
c8dc99c3c8
commit
23ac8bc7f1
11 changed files with 581 additions and 316 deletions
|
|
@ -60,6 +60,21 @@ class CheckoutController extends Controller
|
|||
return $this->checkout->forSinglePurchase($request->user(), $purchase);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stripe Billing Portal: Selbstverwaltung des Abos (Zahlungsmethode,
|
||||
* Rechnungen, Kündigung). Nur mit aktivem Abo sinnvoll.
|
||||
*/
|
||||
public function billingPortal(Request $request): RedirectResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if (! $user->hasStripeId() || ! $user->subscribed()) {
|
||||
return $this->backToBookings(__('Es besteht kein aktives Abo, das verwaltet werden könnte.'));
|
||||
}
|
||||
|
||||
return redirect()->away($this->checkout->billingPortalUrl($user));
|
||||
}
|
||||
|
||||
private function backToBookings(string $notice): RedirectResponse
|
||||
{
|
||||
return redirect()
|
||||
|
|
|
|||
|
|
@ -35,6 +35,15 @@ class StripeCheckoutService
|
|||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* URL zum Stripe Billing Portal (Zahlungsmethode, Rechnungen, Kündigung).
|
||||
* Rücksprung auf die Buchungs-Seite.
|
||||
*/
|
||||
public function billingPortalUrl(User $user): string
|
||||
{
|
||||
return $user->billingPortalUrl(route('me.bookings.index'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Stripe-Checkout für eine Einzel-PM. Die `single_purchase_id` in den
|
||||
* Session-Metadaten schließt den Kreis: `checkout.session.completed`
|
||||
|
|
|
|||
|
|
@ -5,6 +5,28 @@
|
|||
|
||||
---
|
||||
|
||||
## 2026-06-12 · Phase 9F · Tarif-Seite + Checkout-UI ✅
|
||||
|
||||
- **Was**: „Buchungen & Add-ons" vom Credit-Konzept-Mock auf echte Daten
|
||||
umgestellt: 4-Tier-Raster aus `plans` (Alpine Monat/Jahr-Toggle,
|
||||
„2 Monate gratis"), Checkout-Buttons auf die 9E-Routen, Einzel-PM als
|
||||
separater No-Abo-Block, Aktueller-Tarif-Panel (Abo / Bestandstarif
|
||||
unbegrenzt / offene Einzelkäufe / leer) mit Kontingent-Kachel,
|
||||
„Abo verwalten" → Stripe Billing Portal (neue Route
|
||||
`me.checkout.billing-portal`), aktive Buchungen + Verlauf real.
|
||||
Credit-Pakete/Marktplatz/Platzierungen entfernt (→ 9I bzw. Phase 2).
|
||||
Stripe Tax im Dashboard aktiviert („SaaS – business use", exklusiv).
|
||||
- **Dateien**: `resources/views/livewire/customer/bookings.blade.php`
|
||||
(Neufassung), `app/Http/Controllers/CheckoutController.php` +
|
||||
`app/Services/Billing/StripeCheckoutService.php` (Billing Portal),
|
||||
`routes/customer.php`.
|
||||
- **Build/Test**: Suite 519 passed / 4 skipped, Pint clean; 9 neue Tests
|
||||
in `BookingsPageTest`, `PanelConsolidationTest` auf neue Seite angepasst.
|
||||
- **Offene Fragen**: Stripe Tax + Produkt-Sync vor Relaunch im Live-Mode
|
||||
wiederholen.
|
||||
- **Nächster Schritt**: 9G Tageslimit (`plans.daily_limit` beim
|
||||
Veröffentlichen), dann 9H Einzel-PM-Abo-Brücke, 9I Launch-Credits.
|
||||
|
||||
## 2026-06-12 · Phase 9E · Stripe-Anbindung komplett ✅
|
||||
|
||||
- **Was**: Produkt-Sync nach Stripe (Tarife + Einzel-PM, Netto-Preise,
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ Phase 9 setzt das Decision-Update vom 11./12.06.2026 um — in zwei Blöcken:
|
|||
| — | **Review-Stopp mit User** | | |
|
||||
| **9D** ✅ | Tarif-Datenmodell: Pläne, Einzelkäufe, Cashier, Rechnungskreise STR-/MAN-, MAN-Fälligkeitslauf (Stub-Ablösung folgt mit 9E) | L | hoch (Datenmodell) |
|
||||
| **9E** ✅ | Stripe-Anbindung: Produkt-Sync (Tarife + Einzel-PM), Webhook-Verarbeitung (STR-Spiegelung, Einmalkauf-Erfüllung, Endpoint registriert), Checkout-Flows (Backend), Slot-Logik auf Plan-Kontingent (Grandfathered = unbegrenzt), Stripe Tax | L | mittel |
|
||||
| **9F** | Tarif-Seite + Checkout-UI (Raster, Einzel-PM-Block, „2 Monate gratis", Enterprise-Hinweis) | M | gering |
|
||||
| **9F** ✅ | Tarif-Seite + Checkout-UI: Buchungs-Seite mit echtem 4-Tier-Raster (Monat/Jahr-Toggle, „2 Monate gratis"), Einzel-PM-Block, Bestandstarif-Anzeige, „Abo verwalten" (Stripe Billing Portal), Enterprise-Hinweis | M | gering |
|
||||
| **9G** | Tageslimit je Tier (Business 2 / Pro 3 / Agency 5; gilt auch für Extra-PMs) | S | gering |
|
||||
| **9H** | Einzel-PM-Kauf (19 €) + Einzel→Abo-Brücke (Anrechnung 30 Tage) | M | mittel |
|
||||
| **9I** | Launch-Credits: Extra-PM, Boost (nur Grün), Veröffentlichungsnachweis-PDF | L | mittel |
|
||||
|
|
@ -201,12 +201,22 @@ ist hybrid mit zwei getrennten Rechnungskreisen (plus Altbestand):
|
|||
- Offen → §7 der Billing-Doku: Stripe Tax im Dashboard aktivieren,
|
||||
Live-Mode-Sync vor Relaunch.
|
||||
|
||||
### 9F · Tarif-Seite + Checkout-UI
|
||||
### 9F · Tarif-Seite + Checkout-UI ✅ (12.06.2026)
|
||||
|
||||
- Raster mit 4 Tiers; Einzel-PM als separater No-Abo-Block (nicht als
|
||||
billigste Spalte); Enterprise als dezenter Sales-Hinweis unter der Tabelle.
|
||||
- Jahrespreis kommuniziert als „2 Monate gratis".
|
||||
- Einstieg aus dem Submit-Gate-Hinweis (9C) und aus „Buchungen & Add-ons".
|
||||
- ✅ „Buchungen & Add-ons" zeigt das echte 4-Tier-Raster aus `plans`
|
||||
(Monat/Jahr-Toggle, Jahrespreis als „2 Monate gratis") mit
|
||||
Checkout-Buttons auf `me.checkout.subscription`; Einzel-PM als
|
||||
separater No-Abo-Block (`me.checkout.single-pm`); Enterprise als
|
||||
dezenter Hinweis unter dem Raster. Der Credit-Konzept-Mock ist
|
||||
abgelöst (Credits → 9I bzw. Phase 2).
|
||||
- ✅ Aktueller Tarif real: Abo (Preis, Kontingent, Kündigungsstatus),
|
||||
Bestandstarif (unbegrenzt, nächste MAN-Rechnung) oder offene
|
||||
Einzelkäufe; Kontingent-Kachel (`Unbegrenzt` bei Bestandsschutz).
|
||||
- ✅ „Abo verwalten" → Stripe Billing Portal (`me.checkout.billing-portal`:
|
||||
Zahlungsmethode, Rechnungen, Kündigung).
|
||||
- ✅ Aktive Buchungen + Verlauf aus echten Daten (Abo, Legacy-Vereinbarung,
|
||||
offene/eingelöste Einzelkäufe mit PM-Verknüpfung).
|
||||
- Einstieg aus dem Submit-Gate-Hinweis (9C) führt bereits hierher.
|
||||
|
||||
### 9G · Tageslimit
|
||||
|
||||
|
|
|
|||
|
|
@ -128,8 +128,8 @@ Zentrale Billing-Referenz: [`user-admin/Billing-und-Rechnungskreise.md`](./user-
|
|||
| Rechnungen mit Legacy-Archiv | umgesetzt | ✅ |
|
||||
| Hybride Rechnungskreise STR-/MAN- (Decision 12.06.) | umgesetzt (Phase 9D) — Nummern-Generator, MAN-Fälligkeitslauf, Grandfather-Migration, USt-Logik (`VatResolver`) | ✅ |
|
||||
| Tarif-Datenmodell + Cashier | umgesetzt (Phase 9D) — `plans`, `single_purchases`, `User` ist Billable | ✅ |
|
||||
| Stripe-Checkout/Webhooks + STR-Spiegelung | umgesetzt (Phase 9E) — Produkt-Sync, Webhook-Verarbeitung, Checkout-Backend, Plan-Kontingent | ✅ (UI → 9F) |
|
||||
| Buchungen & Add-ons (UI) | nur Stub | 📝 (mit 9F Tarif-Seite) |
|
||||
| Stripe-Checkout/Webhooks + STR-Spiegelung | umgesetzt (Phase 9E) — Produkt-Sync, Webhook-Verarbeitung, Checkout-Backend, Plan-Kontingent | ✅ |
|
||||
| Buchungen & Add-ons (UI) | umgesetzt (Phase 9F) — Tarif-Raster, Einzel-PM-Block, Bestandstarife, Billing Portal | ✅ |
|
||||
| Zahlungsmethoden firmenscharf | **fehlt** | 📝 (Phase 2) |
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
# Billing & Rechnungskreise (hybrides Modell)
|
||||
|
||||
Stand: 12.06.2026 — Datenmodell, MAN-Kreis, USt-Behandlung (Phase 9D) sowie
|
||||
Stripe-Sync, Webhook-Verarbeitung, Checkout-Flows und Plan-Kontingent
|
||||
(Phase 9E) umgesetzt. Es fehlt die Checkout-UI (Phase 9F).
|
||||
Stripe-Sync, Webhook-Verarbeitung, Checkout-Flows, Plan-Kontingent
|
||||
(Phase 9E) und Tarif-Seite/Checkout-UI (Phase 9F) umgesetzt.
|
||||
|
||||
Dieses Dokument ist die zentrale Referenz für das Abrechnungssystem:
|
||||
Rechnungskreise, Tarif-Datenmodell, Steuerlogik, Befehle und Konfiguration.
|
||||
|
|
@ -61,12 +61,13 @@ monatlicher Reset), danach wird der älteste bezahlte Einmalkauf eingelöst
|
|||
(`single_purchases.status → consumed`, verknüpft mit der PM). Die frühere
|
||||
Stub-Spalte `users.press_release_quota` ist entfernt.
|
||||
|
||||
**Checkout-Einstiege** (Phase 9E; UI-Anbindung folgt in 9F):
|
||||
**Checkout-Einstiege** (Phase 9E/9F — verdrahtet auf der Buchungs-Seite):
|
||||
|
||||
| Route | Zweck |
|
||||
|---|---|
|
||||
| `me.checkout.subscription` (`/admin/me/checkout/abo/{slug}/{monthly\|yearly}`) | Stripe-Checkout für ein Tarif-Abo |
|
||||
| `me.checkout.single-pm` (`/admin/me/checkout/einzel-pm`) | Stripe-Checkout Einzel-PM (legt `single_purchases`-Eintrag `pending` an; Webhook setzt `paid`) |
|
||||
| `me.checkout.billing-portal` (`/admin/me/checkout/abo-verwalten`) | Stripe Billing Portal (Zahlungsmethode, Rechnungen, Kündigung) |
|
||||
|
||||
Erfolg/Abbruch landen auf der Buchungs-Seite (`?checkout=erfolg|abbruch`).
|
||||
Die Steuer ergänzt **Stripe Tax** automatisch (`Cashier::calculateTaxes()`
|
||||
|
|
@ -180,12 +181,12 @@ CLI ausgegebene `whsec_…` temporär als `STRIPE_WEBHOOK_SECRET` in die `.env`.
|
|||
Routen siehe Abschnitt 2), Slot-Logik auf Plan-Kontingent umgestellt
|
||||
(Grandfathered = unbegrenzt, Entscheidung 12.06.2026), Stub-Spalte
|
||||
entfernt, Stripe Tax aktiviert (`Cashier::calculateTaxes()`).
|
||||
1. **Phase 9F**: Tarif-Seite/Buchungs-UI an die Checkout-Routen anbinden
|
||||
(die Buchungs-Seite ist noch Konzept-Mock mit deaktivierten Buttons);
|
||||
echte Tarif-/Buchungsdaten statt Platzhalter anzeigen.
|
||||
2. **Stripe Tax im Dashboard aktivieren** (Ursprungsadresse/Registrierung
|
||||
hinterlegen) — ohne das schlägt der Checkout mit automatischer Steuer
|
||||
fehl. Im Test-Mode prüfen, dann im Live-Mode wiederholen; dort auch
|
||||
1. **Phase 9F erledigt** (12.06.2026): Die Buchungs-Seite zeigt das echte
|
||||
Tarif-Raster (Monat/Jahr-Toggle), den Einzel-PM-Block, Bestandstarife
|
||||
und „Abo verwalten" (Stripe Billing Portal, `me.checkout.billing-portal`).
|
||||
2. **Stripe Tax**: im Dashboard aktiviert (12.06.2026, Produkt-Steuercode
|
||||
„SaaS – business use", Steuer nicht im Preis enthalten — passt zu den
|
||||
Netto-Preisen). Vor Relaunch im **Live-Mode** wiederholen; dort auch
|
||||
`billing:sync-stripe-plans` erneut ausführen (Live-Produkt-IDs).
|
||||
3. **VIES-Validierung** der USt-ID (aktuell Formatprüfung; Stripe prüft
|
||||
die im Checkout erfasste USt-ID asynchron selbst).
|
||||
|
|
|
|||
|
|
@ -129,6 +129,7 @@ Verbindliche Entscheidungen: `docs/Decision-Update Preisstruktur & Veröffentlic
|
|||
- [x] USt-Behandlung (12.06.): alle neuen Preise netto; `VatResolver` (DE immer Steuer, EU nur mit USt-ID befreit/Reverse Charge, Drittland befreit), `vat_id` an Rechnungsadresse + Rechnungs-Snapshot, `tax_note` auf Rechnungen; Grandfathered rechnen auf Netto-Basis der letzten Legacy-Rechnung (Brutto bleibt fuer DE-Bestandskunden gleich).
|
||||
- [ ] VIES-Validierung der USt-ID (aktuell Formatpruefung) — vor Gate-/Checkout-Aktivierung.
|
||||
- [x] Stripe-Checkout + Webhooks (Phase 9E, 12.06.): Produkt-Sync nach Stripe (Tarife + Einzel-PM, netto, Stripe Tax), STR-Rechnungsspiegelung + Einmalkauf-Erfuellung per Webhook (Endpoint registriert), Checkout-Flows als Backend (`me.checkout.subscription`/`me.checkout.single-pm`), Slot-Logik auf Plan-Kontingent umgestellt (Grandfathered = unbegrenzt, Bestandsschutz), Quota-Stub-Spalte entfernt. UI-Anbindung folgt in 9F. Doku: `docs/user-admin/Billing-und-Rechnungskreise.md`.
|
||||
- [x] Tarif-Seite + Checkout-UI (Phase 9F, 12.06.): Buchungs-Seite mit echtem 4-Tier-Raster (Monat/Jahr-Toggle, "2 Monate gratis"), Einzel-PM-Block, Bestandstarif-Anzeige (unbegrenzt), "Abo verwalten" via Stripe Billing Portal; Credit-Mock abgeloest (Credits → 9I/Phase 2).
|
||||
- [ ] Tageslimit je Tier (Business 2 / Pro 3 / Agency 5), gilt auch fuer Extra-PMs.
|
||||
- [ ] Launch-Credits: Extra-PM, Boost (nur gruene PMs), Veroeffentlichungsnachweis-PDF; Credit-Anker 1 Credit = 1 €.
|
||||
- [ ] Einzel→Abo-Bruecke (19 € Anrechnung innerhalb 30 Tagen).
|
||||
|
|
|
|||
|
|
@ -1,103 +1,74 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\SinglePurchaseStatus;
|
||||
use App\Enums\UserPaymentOptionStatus;
|
||||
use App\Models\Plan;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Volt\Component;
|
||||
|
||||
/**
|
||||
* Buchungen & Add-ons (Phase 9F): Tarif-Raster mit Stripe-Checkout,
|
||||
* Einzel-PM als separater No-Abo-Block, Bestandstarife (MAN-Kreis) und
|
||||
* echte Buchungsdaten. Launch-Credits (Extra-PM, Boost, Nachweis-PDF)
|
||||
* folgen mit Phase 9I, das Credit-Wallet mit Phase 2.
|
||||
*/
|
||||
new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class extends Component
|
||||
{
|
||||
public function formatEuro(int $cents): string
|
||||
{
|
||||
return number_format($cents / 100, $cents % 100 === 0 ? 0 : 2, ',', '.').' €';
|
||||
}
|
||||
|
||||
public function with(): array
|
||||
{
|
||||
$user = auth()->user();
|
||||
$subscription = $user->subscription();
|
||||
$currentPlan = $user->currentPlan();
|
||||
|
||||
$currentInterval = null;
|
||||
if ($currentPlan && $subscription) {
|
||||
$currentInterval = $subscription->stripe_price === $currentPlan->stripe_price_id_yearly
|
||||
? 'yearly'
|
||||
: 'monthly';
|
||||
}
|
||||
|
||||
return [
|
||||
// Rückkehr aus dem Stripe-Checkout (?checkout=erfolg|abbruch)
|
||||
// bzw. Hinweis aus den Checkout-Guards (Session-Flash).
|
||||
'checkoutResult' => request()->query('checkout'),
|
||||
'checkoutNotice' => session('checkout-notice'),
|
||||
'creditSummary' => [
|
||||
'total' => 17,
|
||||
'bonus' => 12,
|
||||
'paid' => 5,
|
||||
'auto_refill' => __('ab 10 Credits empfohlen'),
|
||||
'validity' => __('Bonus-Credits verfallen monatlich, gekaufte Credits bleiben 24 Monate gültig.'),
|
||||
],
|
||||
'currentPlan' => [
|
||||
'name' => 'Starter',
|
||||
'price' => '19 €/Mo.',
|
||||
'press_releases' => '3 PMs/Monat',
|
||||
'bonus_credits' => 12,
|
||||
],
|
||||
'creditPackages' => [
|
||||
['name' => 'Test', 'credits' => 10, 'price' => '10 €', 'rate' => '1,00 €', 'saving' => null],
|
||||
['name' => 'Standard', 'credits' => 50, 'price' => '45 €', 'rate' => '0,90 €', 'saving' => '10 %'],
|
||||
['name' => 'Plus', 'credits' => 150, 'price' => '120 €', 'rate' => '0,80 €', 'saving' => '20 %'],
|
||||
['name' => 'Pro', 'credits' => 500, 'price' => '375 €', 'rate' => '0,75 €', 'saving' => '25 %'],
|
||||
['name' => 'Business', 'credits' => 1500, 'price' => '1.050 €', 'rate' => '0,70 €', 'saving' => '30 %'],
|
||||
],
|
||||
'serviceGroups' => [
|
||||
[
|
||||
'title' => __('Veröffentlichung'),
|
||||
'description' => __('Basisleistungen rund um Veröffentlichung, Korrektur und Aktualisierung.'),
|
||||
'services' => [
|
||||
['name' => __('Standard-PM (Pay-as-you-go)'), 'credits' => '19', 'meta' => __('1 Veröffentlichung')],
|
||||
['name' => __('PM-Korrektur'), 'credits' => '8', 'meta' => __('Pfad C')],
|
||||
['name' => __('PM-Update'), 'credits' => '4', 'meta' => __('im ersten Jahr ggf. kostenlos')],
|
||||
['name' => __('Depublizierung'), 'credits' => '19–25', 'meta' => __('abhängig vom Aufwand')],
|
||||
],
|
||||
],
|
||||
[
|
||||
'title' => __('Bilder'),
|
||||
'description' => __('Stock- und KI-Bilder für mehr Sichtbarkeit in Listen und Detailseiten.'),
|
||||
'services' => [
|
||||
['name' => __('Free-Stock'), 'credits' => '0', 'meta' => __('Unsplash, Pexels')],
|
||||
['name' => __('Premium-Stock'), 'credits' => '8', 'meta' => __('Adobe, Shutterstock')],
|
||||
['name' => __('KI-Bild generieren'), 'credits' => '4', 'meta' => __('neues Motiv')],
|
||||
['name' => __('KI-Bild Re-Generation'), 'credits' => '2', 'meta' => __('Variante erzeugen')],
|
||||
],
|
||||
],
|
||||
[
|
||||
'title' => __('KI-Textservices'),
|
||||
'description' => __('Qualität verbessern, Score-Stufe erreichen und bessere Headlines testen.'),
|
||||
'services' => [
|
||||
['name' => __('Quality-Check'), 'credits' => '3', 'meta' => __('Stil und Pressestil')],
|
||||
['name' => __('Lektorat'), 'credits' => '8', 'meta' => __('sprachliche Prüfung')],
|
||||
['name' => __('Pressetext-Optimierung'), 'credits' => '15', 'meta' => __('Headlines und SEO')],
|
||||
['name' => __('Headline-Booster'), 'credits' => '5', 'meta' => __('nur Headlines')],
|
||||
['name' => __('PM aus Stichworten generieren'), 'credits' => '25', 'meta' => __('Entwurf aus Briefing')],
|
||||
['name' => __('Übersetzung DE/EN'), 'credits' => '12', 'meta' => __('pro Sprachrichtung')],
|
||||
],
|
||||
],
|
||||
[
|
||||
'title' => __('Distribution'),
|
||||
'description' => __('Zusätzliche Formate und externe Reichweite für passende Meldungen.'),
|
||||
'services' => [
|
||||
['name' => __('PDF-Export mit Branding'), 'credits' => '2', 'meta' => __('für Weitergabe')],
|
||||
['name' => __('Social-Snippet-Generierung'), 'credits' => '3', 'meta' => __('Kurztexte')],
|
||||
['name' => __('Verteiler-Versand klein'), 'credits' => '39', 'meta' => __('branchenspezifisch')],
|
||||
['name' => __('Verteiler-Versand mittel'), 'credits' => '99', 'meta' => __('mehr Empfänger')],
|
||||
['name' => __('Verteiler-Versand groß'), 'credits' => '199', 'meta' => __('branchenübergreifend')],
|
||||
],
|
||||
],
|
||||
[
|
||||
'title' => __('Account & Profil'),
|
||||
'description' => __('Vertrauen, Wiedererkennung und zusätzliche Profilfunktionen.'),
|
||||
'services' => [
|
||||
['name' => __('Verifiziertes Firmenprofil'), 'credits' => '79', 'meta' => __('einmalig')],
|
||||
['name' => __('Custom Subdomain'), 'credits' => '49', 'meta' => __('pro Jahr')],
|
||||
['name' => __('Erweiterte Statistiken'), 'credits' => '15', 'meta' => __('pro Monat')],
|
||||
],
|
||||
],
|
||||
],
|
||||
'placements' => [
|
||||
['name' => __('Highlight Kategorie'), 'credits' => '15', 'duration' => __('3 Tage'), 'tier' => __('Standard'), 'score' => '30+'],
|
||||
['name' => __('Highlight Kategorie'), 'credits' => '30', 'duration' => __('7 Tage'), 'tier' => __('Standard'), 'score' => '30+'],
|
||||
['name' => __('Startseite-Highlight'), 'credits' => '39', 'duration' => __('24 h'), 'tier' => __('Geprüft'), 'score' => '60+'],
|
||||
['name' => __('Startseite-Highlight'), 'credits' => '89', 'duration' => __('3 Tage'), 'tier' => __('Geprüft'), 'score' => '60+'],
|
||||
['name' => __('Top-Slot Startseite'), 'credits' => '119', 'duration' => __('24 h'), 'tier' => __('Hochwertig'), 'score' => '80+'],
|
||||
['name' => __('Newsletter-Erwähnung'), 'credits' => '59', 'duration' => __('nächster Versand'), 'tier' => __('Geprüft'), 'score' => '60+'],
|
||||
['name' => __('Social-Share'), 'credits' => '25', 'duration' => __('offizieller Kanal'), 'tier' => __('Geprüft'), 'score' => '60+'],
|
||||
],
|
||||
'activeBookings' => [],
|
||||
'bookingHistory' => [],
|
||||
|
||||
'plans' => Plan::query()->active()->get(),
|
||||
'currentPlan' => $currentPlan,
|
||||
'currentInterval' => $currentInterval,
|
||||
'subscription' => $subscription,
|
||||
|
||||
// Bestandstarife: laufende Legacy-Vereinbarungen (MAN-Kreis,
|
||||
// unbegrenzte PMs — Entscheidung 12.06.2026).
|
||||
'legacyOptions' => $user->userPaymentOptions()
|
||||
->whereIn('status', [
|
||||
UserPaymentOptionStatus::Active->value,
|
||||
UserPaymentOptionStatus::Grandfathered->value,
|
||||
])
|
||||
->orderBy('current_period_end')
|
||||
->get(),
|
||||
|
||||
'openPurchases' => $user->singlePurchases()
|
||||
->grantingSubmission()
|
||||
->orderBy('paid_at')
|
||||
->get(),
|
||||
'consumedPurchases' => $user->singlePurchases()
|
||||
->where('status', SinglePurchaseStatus::Consumed->value)
|
||||
->with('pressRelease')
|
||||
->latest('consumed_at')
|
||||
->limit(10)
|
||||
->get(),
|
||||
|
||||
'quotaRemaining' => $user->pressReleaseQuotaRemaining(),
|
||||
'quotaTotal' => $user->pressReleaseQuotaTotal(),
|
||||
'singlePmPrice' => $this->formatEuro((int) config('billing.single_pm_price_cents')),
|
||||
'singlePmAvailable' => (bool) config('billing.single_pm_stripe_price_id'),
|
||||
];
|
||||
}
|
||||
}; ?>
|
||||
|
|
@ -137,13 +108,12 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte
|
|||
<div class="flex items-center gap-3 mb-3 flex-wrap">
|
||||
<span class="badge hub dot">{{ __('User Backend') }}</span>
|
||||
<span class="eyebrow muted">{{ __('Mein Bereich · Finanzen') }}</span>
|
||||
<span class="badge hub">{{ __('Konzeptstand Mai 2026') }}</span>
|
||||
</div>
|
||||
<h1 class="text-[30px] font-bold tracking-[-0.6px] leading-[1.15] m-0 text-[color:var(--color-ink)]">
|
||||
{{ __('Buchungen & Add-ons') }}
|
||||
</h1>
|
||||
<p class="text-[13px] leading-[1.55] mt-2 m-0 max-w-[640px] text-[color:var(--color-ink-2)]">
|
||||
{{ __('Der Marktplatz für Credit-Pakete, KI-Services, Platzierungen und Firmen-Add-ons. Die Preise folgen dem neuen Credit-Modell: 1 Credit entspricht dem Listenwert von 1 €.') }}
|
||||
{{ __('Tarif wählen oder einzelne Pressemitteilung buchen. Alle Preise sind Nettopreise zzgl. USt.; die Abrechnung erfolgt sicher über Stripe.') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -151,221 +121,234 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte
|
|||
<flux:button size="sm" variant="filled" icon="document-text" href="{{ route('me.invoices.index') }}" wire:navigate>
|
||||
{{ __('Rechnungen') }}
|
||||
</flux:button>
|
||||
<flux:button size="sm" variant="primary" icon="plus" disabled>
|
||||
{{ __('Credits kaufen') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{{-- ============== CREDIT-ÜBERSICHT ============== --}}
|
||||
<section class="grid gap-4 lg:grid-cols-[1.2fr_0.8fr]">
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Credit-Stand') }}</span>
|
||||
<span class="badge ok dot">{{ __('Auto-Refill vorbereitet') }}</span>
|
||||
</div>
|
||||
<div class="p-5 grid gap-5 md:grid-cols-[0.8fr_1.2fr]">
|
||||
<div>
|
||||
<div class="text-[42px] font-bold tracking-[-1.2px] leading-none text-[color:var(--color-ink)]">
|
||||
{{ $creditSummary['total'] }}
|
||||
</div>
|
||||
<p class="text-[12.5px] text-[color:var(--color-ink-3)] mt-2 mb-0">
|
||||
{{ __('verfügbare Credits') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div class="rounded-[6px] border border-[color:var(--color-bg-rule)] p-4 bg-[color:var(--color-bg-subtle)]">
|
||||
<div class="text-[12px] text-[color:var(--color-ink-3)]">{{ __('Bonus-Credits') }}</div>
|
||||
<div class="text-[22px] font-semibold text-[color:var(--color-ink)]">{{ $creditSummary['bonus'] }}</div>
|
||||
<div class="text-[11.5px] text-[color:var(--color-ink-3)]">{{ __('monatlich verfallend') }}</div>
|
||||
</div>
|
||||
<div class="rounded-[6px] border border-[color:var(--color-bg-rule)] p-4 bg-[color:var(--color-bg-subtle)]">
|
||||
<div class="text-[12px] text-[color:var(--color-ink-3)]">{{ __('Gekaufte Credits') }}</div>
|
||||
<div class="text-[22px] font-semibold text-[color:var(--color-ink)]">{{ $creditSummary['paid'] }}</div>
|
||||
<div class="text-[11.5px] text-[color:var(--color-ink-3)]">{{ __('24 Monate gültig') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2 px-4 py-3 rounded-[5px] border text-[12.5px] flex items-start gap-3
|
||||
bg-[color:var(--color-hub-soft)] border-[color:var(--color-hub-soft-2)] text-[color:var(--color-ink-2)]">
|
||||
<flux:icon.information-circle class="size-[16px] flex-shrink-0 mt-0.5 text-[color:var(--color-hub)]" />
|
||||
<div class="flex-1">
|
||||
{{ $creditSummary['validity'] }}
|
||||
{{ __('Für spätere Checkouts ist Auto-Refill :threshold vorgesehen.', ['threshold' => $creditSummary['auto_refill']]) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Aktueller Tarif') }}</span>
|
||||
<span class="badge hub">{{ $currentPlan['name'] }}</span>
|
||||
</div>
|
||||
<div class="p-5 space-y-4">
|
||||
<div>
|
||||
<div class="text-[28px] font-bold tracking-[-0.7px] text-[color:var(--color-ink)]">
|
||||
{{ $currentPlan['price'] }}
|
||||
</div>
|
||||
<p class="text-[12.5px] text-[color:var(--color-ink-3)] mt-1 mb-0">
|
||||
{{ __('inkl. :credits Bonus-Credits und :pms', [
|
||||
'credits' => $currentPlan['bonus_credits'],
|
||||
'pms' => $currentPlan['press_releases'],
|
||||
]) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-[6px] border border-[color:var(--color-bg-rule)] p-4">
|
||||
<div class="text-[12px] font-semibold text-[color:var(--color-ink)] mb-1">
|
||||
{{ __('Nächster sinnvoller Schritt') }}
|
||||
</div>
|
||||
<p class="text-[12.5px] text-[color:var(--color-ink-3)] m-0">
|
||||
{{ __('Bei mehreren PMs mit KI-Optimierung oder Platzierungen ergänzt das Standard-Paket die monatlichen Bonus-Credits am saubersten.') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
{{-- ============== CREDIT-PAKETE ============== --}}
|
||||
<article class="panel overflow-hidden">
|
||||
{{-- ============== AKTUELLER TARIF ============== --}}
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Credit-Pakete') }}</span>
|
||||
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">{{ __('Volumenrabatt nach Paketgröße') }}</span>
|
||||
<span class="section-eyebrow">{{ __('Aktueller Tarif') }}</span>
|
||||
@if ($currentPlan)
|
||||
<span class="badge hub">{{ $currentPlan->name }}</span>
|
||||
@elseif ($legacyOptions->isNotEmpty())
|
||||
<span class="badge ok dot">{{ __('Bestandstarif') }}</span>
|
||||
@else
|
||||
<span class="badge">{{ __('Kein Abo') }}</span>
|
||||
@endif
|
||||
</div>
|
||||
<flux:table>
|
||||
<flux:table.columns>
|
||||
<flux:table.column>{{ __('Paket') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Credits') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Preis') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Effektiv/Credit') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Ersparnis') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Aktion') }}</flux:table.column>
|
||||
</flux:table.columns>
|
||||
|
||||
@foreach ($creditPackages as $package)
|
||||
<flux:table.row wire:key="credit-package-{{ $package['name'] }}">
|
||||
<flux:table.cell>
|
||||
<span class="text-[13px] font-semibold text-[color:var(--color-ink)]">{{ $package['name'] }}</span>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>{{ number_format($package['credits'], 0, ',', '.') }}</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<span class="font-semibold text-[color:var(--color-ink)]">{{ $package['price'] }}</span>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>{{ $package['rate'] }}</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
@if ($package['saving'])
|
||||
<span class="badge ok">{{ $package['saving'] }}</span>
|
||||
@else
|
||||
<span class="text-[12px] text-[color:var(--color-ink-3)]">–</span>
|
||||
<div class="p-5 grid gap-5 md:grid-cols-[1.2fr_0.8fr]">
|
||||
<div class="space-y-2">
|
||||
@if ($currentPlan)
|
||||
<div class="text-[28px] font-bold tracking-[-0.7px] text-[color:var(--color-ink)]">
|
||||
{{ $currentInterval === 'yearly'
|
||||
? $this->formatEuro($currentPlan->yearly_price_cents).' / '.__('Jahr')
|
||||
: $this->formatEuro($currentPlan->monthly_price_cents).' / '.__('Monat') }}
|
||||
<span class="text-[13px] font-normal text-[color:var(--color-ink-3)]">{{ __('netto') }}</span>
|
||||
</div>
|
||||
<p class="text-[12.5px] text-[color:var(--color-ink-2)] m-0">
|
||||
{{ __(':quota Pressemitteilungen pro Monat', ['quota' => $currentPlan->press_release_quota]) }}
|
||||
@if ($currentPlan->daily_limit)
|
||||
· {{ __('max. :limit Veröffentlichungen pro Tag', ['limit' => $currentPlan->daily_limit]) }}
|
||||
@endif
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<flux:button size="sm" variant="filled" disabled>
|
||||
{{ __('Kaufen') }}
|
||||
</flux:button>
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
@endforeach
|
||||
</flux:table>
|
||||
</p>
|
||||
@if ($subscription?->onGracePeriod())
|
||||
<p class="text-[12px] text-[color:var(--color-warn,#b45309)] m-0">
|
||||
{{ __('Gekündigt — läuft am :date aus.', ['date' => $subscription->ends_at?->format('d.m.Y')]) }}
|
||||
</p>
|
||||
@endif
|
||||
@elseif ($legacyOptions->isNotEmpty())
|
||||
@foreach ($legacyOptions as $option)
|
||||
<div>
|
||||
<div class="text-[15px] font-semibold text-[color:var(--color-ink)]">
|
||||
{{ data_get($option->legacy_conditions, 'name') ?? $option->paymentOption?->article_number ?? __('Bestehende Vereinbarung') }}
|
||||
</div>
|
||||
<p class="text-[12.5px] text-[color:var(--color-ink-2)] mt-1 mb-0">
|
||||
{{ __('Unbegrenzte Pressemitteilungen (Bestandsschutz).') }}
|
||||
@if ($option->current_period_end)
|
||||
{{ __('Nächste Rechnung am :date.', ['date' => $option->current_period_end->format('d.m.Y')]) }}
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
@endforeach
|
||||
<p class="text-[11.5px] text-[color:var(--color-ink-3)] m-0">
|
||||
{{ __('Ihre bisherigen Konditionen gelten unverändert weiter; die Abrechnung erfolgt wie gewohnt per Rechnung.') }}
|
||||
</p>
|
||||
@elseif ($openPurchases->isNotEmpty())
|
||||
<div class="text-[15px] font-semibold text-[color:var(--color-ink)]">
|
||||
{{ trans_choice(':count Einzel-Pressemitteilung verfügbar|:count Einzel-Pressemitteilungen verfügbar', $openPurchases->count(), ['count' => $openPurchases->count()]) }}
|
||||
</div>
|
||||
<p class="text-[12.5px] text-[color:var(--color-ink-2)] m-0">
|
||||
{{ __('Jeder Kauf berechtigt zu genau einer Veröffentlichung — eingelöst wird er erst, wenn die Pressemitteilung live geht.') }}
|
||||
</p>
|
||||
@else
|
||||
<div class="text-[15px] font-semibold text-[color:var(--color-ink)]">
|
||||
{{ __('Noch kein aktiver Tarif') }}
|
||||
</div>
|
||||
<p class="text-[12.5px] text-[color:var(--color-ink-2)] m-0">
|
||||
{{ __('Wählen Sie unten einen Tarif oder buchen Sie eine einzelne Pressemitteilung ohne Abo.') }}
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="rounded-[6px] border border-[color:var(--color-bg-rule)] p-4 bg-[color:var(--color-bg-subtle)]">
|
||||
<div class="text-[12px] text-[color:var(--color-ink-3)]">{{ __('PM-Kontingent diesen Monat') }}</div>
|
||||
<div class="text-[22px] font-semibold text-[color:var(--color-ink)]">
|
||||
@if (is_null($quotaRemaining))
|
||||
{{ __('Unbegrenzt') }}
|
||||
@else
|
||||
{{ $quotaRemaining }} / {{ $quotaTotal }}
|
||||
@endif
|
||||
</div>
|
||||
<div class="text-[11.5px] text-[color:var(--color-ink-3)]">
|
||||
{{ __('Wird erst bei Veröffentlichung verbraucht.') }}
|
||||
</div>
|
||||
</div>
|
||||
@if ($subscription)
|
||||
<flux:button size="sm" variant="filled" icon="cog-6-tooth" class="w-full"
|
||||
href="{{ route('me.checkout.billing-portal') }}">
|
||||
{{ __('Abo verwalten') }}
|
||||
</flux:button>
|
||||
<p class="text-[11px] text-[color:var(--color-ink-3)] m-0">
|
||||
{{ __('Zahlungsmethode, Rechnungen und Kündigung — sicher über das Stripe-Kundenportal.') }}
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{{-- ============== PLATZIERUNGEN ============== --}}
|
||||
<section class="space-y-4">
|
||||
<div>
|
||||
<span class="section-eyebrow">{{ __('Boost & Platzierungen') }}</span>
|
||||
<h2 class="text-[22px] font-bold tracking-[-0.4px] mt-1 mb-1 text-[color:var(--color-ink)]">
|
||||
{{ __('Sichtbarkeit buchen, wenn die Score-Stufe passt') }}
|
||||
</h2>
|
||||
<p class="text-[12.5px] text-[color:var(--color-ink-3)] max-w-[760px] m-0">
|
||||
{{ __('Platzierungen bleiben an Qualitätsstufen gekoppelt: Standard reicht für Kategorie-Highlights, Geprüft für Startseite/Newsletter/Social und Hochwertig für den Top-Slot.') }}
|
||||
</p>
|
||||
{{-- ============== TARIF-RASTER ============== --}}
|
||||
<section class="space-y-4" x-data="{ interval: '{{ $currentInterval ?? 'monthly' }}' }">
|
||||
<div class="flex items-end justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<span class="section-eyebrow">{{ __('Tarife') }}</span>
|
||||
<h2 class="text-[22px] font-bold tracking-[-0.4px] mt-1 mb-1 text-[color:var(--color-ink)]">
|
||||
{{ __('Den passenden Tarif wählen') }}
|
||||
</h2>
|
||||
<p class="text-[12.5px] text-[color:var(--color-ink-3)] max-w-[640px] m-0">
|
||||
{{ __('Monatlich kündbar. Im Jahrestarif sind 2 Monate gratis — Sie zahlen 10 von 12 Monaten.') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1 rounded-[6px] border border-[color:var(--color-bg-rule)] p-1 bg-[color:var(--color-bg-subtle)]">
|
||||
<button type="button" @click="interval = 'monthly'"
|
||||
class="px-3 py-1.5 rounded-[4px] text-[12.5px] font-semibold transition-colors"
|
||||
:class="interval === 'monthly' ? 'bg-[color:var(--color-bg-elev)] text-[color:var(--color-ink)] shadow-sm' : 'text-[color:var(--color-ink-3)]'">
|
||||
{{ __('Monatlich') }}
|
||||
</button>
|
||||
<button type="button" @click="interval = 'yearly'"
|
||||
class="px-3 py-1.5 rounded-[4px] text-[12.5px] font-semibold transition-colors"
|
||||
:class="interval === 'yearly' ? 'bg-[color:var(--color-bg-elev)] text-[color:var(--color-ink)] shadow-sm' : 'text-[color:var(--color-ink-3)]'">
|
||||
{{ __('Jährlich') }} <span class="badge ok ms-1">{{ __('2 Monate gratis') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
@foreach ($placements as $placement)
|
||||
<article class="panel">
|
||||
<div class="p-5 space-y-4">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-10 h-10 rounded-[6px] flex items-center justify-center bg-[color:var(--color-hub-soft)] text-[color:var(--color-hub)]">
|
||||
<flux:icon.megaphone class="size-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-[14px] font-semibold text-[color:var(--color-ink)] m-0">
|
||||
{{ $placement['name'] }}
|
||||
</h3>
|
||||
<p class="text-[12px] text-[color:var(--color-ink-3)] mt-1 mb-0">
|
||||
{{ $placement['duration'] }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-[22px] font-bold text-[color:var(--color-ink)]">{{ $placement['credits'] }}</div>
|
||||
<div class="text-[11px] text-[color:var(--color-ink-3)]">{{ __('Credits') }}</div>
|
||||
</div>
|
||||
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
@foreach ($plans as $plan)
|
||||
@php($isCurrent = $currentPlan && $plan->is($currentPlan))
|
||||
<article @class(['panel', 'ring-2 ring-[color:var(--color-hub)]' => $isCurrent]) wire:key="plan-{{ $plan->slug }}">
|
||||
<div class="p-5 space-y-4 flex flex-col h-full">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<h3 class="text-[15px] font-bold text-[color:var(--color-ink)] m-0">{{ $plan->name }}</h3>
|
||||
@if ($isCurrent)
|
||||
<span class="badge hub dot">{{ __('Aktuell') }}</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-3 rounded-[6px] border border-[color:var(--color-bg-rule)] p-3">
|
||||
<div>
|
||||
<div class="text-[11.5px] text-[color:var(--color-ink-3)]">{{ __('Mindeststufe') }}</div>
|
||||
<div class="text-[13px] font-semibold text-[color:var(--color-ink)]">{{ $placement['tier'] }}</div>
|
||||
<div>
|
||||
<div x-show="interval === 'monthly'">
|
||||
<span class="text-[26px] font-bold tracking-[-0.6px] text-[color:var(--color-ink)]">{{ $this->formatEuro($plan->monthly_price_cents) }}</span>
|
||||
<span class="text-[12px] text-[color:var(--color-ink-3)]">/ {{ __('Monat') }}</span>
|
||||
</div>
|
||||
<flux:tooltip content="{{ __('Interner Score-Schwellenwert: :score', ['score' => $placement['score']]) }}">
|
||||
<span class="badge hub">{{ __('Score :score', ['score' => $placement['score']]) }}</span>
|
||||
</flux:tooltip>
|
||||
<div x-show="interval === 'yearly'" x-cloak>
|
||||
<span class="text-[26px] font-bold tracking-[-0.6px] text-[color:var(--color-ink)]">{{ $this->formatEuro($plan->yearly_price_cents) }}</span>
|
||||
<span class="text-[12px] text-[color:var(--color-ink-3)]">/ {{ __('Jahr') }}</span>
|
||||
</div>
|
||||
<div class="text-[11px] text-[color:var(--color-ink-3)] mt-0.5">{{ __('netto zzgl. USt.') }}</div>
|
||||
</div>
|
||||
|
||||
<flux:button size="sm" variant="primary" class="w-full" disabled>
|
||||
{{ __('Buchung vorbereiten') }}
|
||||
</flux:button>
|
||||
<ul class="m-0 p-0 list-none space-y-2 text-[12.5px] text-[color:var(--color-ink-2)] flex-1">
|
||||
<li class="flex items-start gap-2">
|
||||
<flux:icon.check class="size-4 flex-shrink-0 mt-0.5 text-[color:var(--color-hub)]" />
|
||||
{{ __(':quota Pressemitteilungen pro Monat', ['quota' => $plan->press_release_quota]) }}
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<flux:icon.check class="size-4 flex-shrink-0 mt-0.5 text-[color:var(--color-hub)]" />
|
||||
@if ($plan->daily_limit)
|
||||
{{ __('max. :limit Veröffentlichungen pro Tag', ['limit' => $plan->daily_limit]) }}
|
||||
@else
|
||||
{{ __('Ohne Tageslimit') }}
|
||||
@endif
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<flux:icon.check class="size-4 flex-shrink-0 mt-0.5 text-[color:var(--color-hub)]" />
|
||||
{{ __('KI-Prüfung & Veröffentlichung inklusive') }}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@if ($subscription)
|
||||
<flux:button size="sm" variant="filled" class="w-full" disabled>
|
||||
{{ $isCurrent ? __('Ihr aktueller Tarif') : __('Wechsel über „Abo verwalten"') }}
|
||||
</flux:button>
|
||||
@else
|
||||
<div x-show="interval === 'monthly'">
|
||||
<flux:button size="sm" variant="primary" class="w-full"
|
||||
href="{{ route('me.checkout.subscription', ['planSlug' => $plan->slug, 'interval' => 'monthly']) }}">
|
||||
{{ __('Monatlich buchen') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
<div x-show="interval === 'yearly'" x-cloak>
|
||||
<flux:button size="sm" variant="primary" class="w-full"
|
||||
href="{{ route('me.checkout.subscription', ['planSlug' => $plan->slug, 'interval' => 'yearly']) }}">
|
||||
{{ __('Jährlich buchen') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</article>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<p class="text-[12px] text-[color:var(--color-ink-3)] m-0">
|
||||
{{ __('Mehr als 60 Pressemitteilungen pro Monat, mehrere Teams oder Sonderkonditionen? Enterprise-Konditionen erhalten Sie auf Anfrage über den Support.') }}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{{-- ============== SERVICE-MARKTPLATZ ============== --}}
|
||||
<section class="space-y-4">
|
||||
<div>
|
||||
<span class="section-eyebrow">{{ __('Add-on-Marktplatz') }}</span>
|
||||
<h2 class="text-[22px] font-bold tracking-[-0.4px] mt-1 mb-1 text-[color:var(--color-ink)]">
|
||||
{{ __('Buchbare Services nach Kategorie') }}
|
||||
</h2>
|
||||
{{-- ============== EINZEL-PM (OHNE ABO) ============== --}}
|
||||
<article class="panel">
|
||||
<div class="p-5 grid gap-5 md:grid-cols-[1fr_auto] md:items-center">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="w-10 h-10 rounded-[6px] flex items-center justify-center flex-shrink-0 bg-[color:var(--color-hub-soft)] text-[color:var(--color-hub)]">
|
||||
<flux:icon.document-plus class="size-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-[15px] font-bold text-[color:var(--color-ink)] m-0">
|
||||
{{ __('Einzel-Pressemitteilung — ohne Abo') }}
|
||||
</h3>
|
||||
<p class="text-[12.5px] text-[color:var(--color-ink-2)] mt-1 mb-0 max-w-[560px]">
|
||||
{{ __('Genau eine Veröffentlichung inklusive KI-Prüfung. Eingelöst wird der Kauf erst, wenn die Pressemitteilung live geht — Ablehnungen kosten nichts.') }}
|
||||
@if ($openPurchases->isNotEmpty())
|
||||
<span class="font-semibold text-[color:var(--color-ink)]">
|
||||
{{ trans_choice('Aktuell :count offener Kauf.|Aktuell :count offene Käufe.', $openPurchases->count(), ['count' => $openPurchases->count()]) }}
|
||||
</span>
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-4 md:justify-end">
|
||||
<div class="text-right">
|
||||
<div class="text-[22px] font-bold text-[color:var(--color-ink)]">{{ $singlePmPrice }}</div>
|
||||
<div class="text-[11px] text-[color:var(--color-ink-3)]">{{ __('netto zzgl. USt.') }}</div>
|
||||
</div>
|
||||
<flux:button size="sm" variant="primary"
|
||||
href="{{ route('me.checkout.single-pm') }}"
|
||||
:disabled="! $singlePmAvailable">
|
||||
{{ __('Jetzt buchen') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 xl:grid-cols-2">
|
||||
@foreach ($serviceGroups as $group)
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<div class="flex items-center gap-2">
|
||||
<flux:icon.sparkles class="size-4 text-[color:var(--color-hub)]" />
|
||||
<span class="section-eyebrow">{{ $group['title'] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<p class="text-[12.5px] text-[color:var(--color-ink-3)] mt-0 mb-4">
|
||||
{{ $group['description'] }}
|
||||
</p>
|
||||
<div class="divide-y divide-[color:var(--color-bg-rule)]">
|
||||
@foreach ($group['services'] as $service)
|
||||
<div class="py-3 first:pt-0 last:pb-0 flex items-center justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<div class="text-[13px] font-semibold text-[color:var(--color-ink)]">{{ $service['name'] }}</div>
|
||||
<div class="text-[11.5px] text-[color:var(--color-ink-3)]">{{ $service['meta'] }}</div>
|
||||
</div>
|
||||
<div class="text-right flex-shrink-0">
|
||||
<div class="text-[15px] font-bold text-[color:var(--color-ink)]">{{ $service['credits'] }}</div>
|
||||
<div class="text-[10.5px] uppercase tracking-[0.08em] text-[color:var(--color-ink-3)]">{{ __('Credits') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
@endforeach
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
|
||||
{{-- ============== AKTIVE BUCHUNGEN / VERLAUF ============== --}}
|
||||
<section class="grid gap-4 lg:grid-cols-2">
|
||||
|
|
@ -375,9 +358,54 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte
|
|||
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">{{ __('läuft aktuell') }}</span>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
@forelse ($activeBookings as $booking)
|
||||
<div>{{ $booking }}</div>
|
||||
@empty
|
||||
@if ($subscription || $legacyOptions->isNotEmpty() || $openPurchases->isNotEmpty())
|
||||
<div class="divide-y divide-[color:var(--color-bg-rule)]">
|
||||
@if ($subscription && $currentPlan)
|
||||
<div class="py-3 first:pt-0 last:pb-0 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div class="text-[13px] font-semibold text-[color:var(--color-ink)]">
|
||||
{{ __('Abo: :plan', ['plan' => $currentPlan->name]) }}
|
||||
</div>
|
||||
<div class="text-[11.5px] text-[color:var(--color-ink-3)]">
|
||||
{{ $currentInterval === 'yearly' ? __('jährliche Abrechnung') : __('monatliche Abrechnung') }} · Stripe
|
||||
</div>
|
||||
</div>
|
||||
<span class="badge ok dot">{{ __('aktiv') }}</span>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@foreach ($legacyOptions as $option)
|
||||
<div class="py-3 first:pt-0 last:pb-0 flex items-center justify-between gap-4" wire:key="legacy-option-{{ $option->id }}">
|
||||
<div>
|
||||
<div class="text-[13px] font-semibold text-[color:var(--color-ink)]">
|
||||
{{ data_get($option->legacy_conditions, 'name') ?? $option->paymentOption?->article_number ?? __('Bestehende Vereinbarung') }}
|
||||
</div>
|
||||
<div class="text-[11.5px] text-[color:var(--color-ink-3)]">
|
||||
{{ __('Bestandstarif · Abrechnung per Rechnung') }}
|
||||
@if ($option->current_period_end)
|
||||
· {{ __('nächste Rechnung :date', ['date' => $option->current_period_end->format('d.m.Y')]) }}
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<span class="badge ok dot">{{ __('aktiv') }}</span>
|
||||
</div>
|
||||
@endforeach
|
||||
|
||||
@foreach ($openPurchases as $purchase)
|
||||
<div class="py-3 first:pt-0 last:pb-0 flex items-center justify-between gap-4" wire:key="open-purchase-{{ $purchase->id }}">
|
||||
<div>
|
||||
<div class="text-[13px] font-semibold text-[color:var(--color-ink)]">
|
||||
{{ $purchase->type->label() }}
|
||||
</div>
|
||||
<div class="text-[11.5px] text-[color:var(--color-ink-3)]">
|
||||
{{ __('gekauft am :date', ['date' => $purchase->paid_at?->format('d.m.Y')]) }} · {{ $this->formatEuro($purchase->price_cents) }} {{ __('netto') }}
|
||||
</div>
|
||||
</div>
|
||||
<span class="badge hub">{{ __('einlösbar') }}</span>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<div class="flex flex-col items-center justify-center px-4 py-10 text-center">
|
||||
<div class="w-14 h-14 rounded-[6px] flex items-center justify-center mb-3
|
||||
bg-[color:var(--color-hub-soft)] border border-[color:var(--color-hub-soft-2)] text-[color:var(--color-hub)]">
|
||||
|
|
@ -387,22 +415,36 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte
|
|||
{{ __('Noch keine aktiven Buchungen') }}
|
||||
</div>
|
||||
<p class="text-[12px] text-[color:var(--color-ink-3)] max-w-md m-0">
|
||||
{{ __('Gebuchte Highlights, Newsletter-Platzierungen oder Add-ons erscheinen hier mit Laufzeit und zugehöriger Firma.') }}
|
||||
{{ __('Ihr Abo, Bestandstarife und offene Einzelkäufe erscheinen hier mit Laufzeit und Abrechnungsart.') }}
|
||||
</p>
|
||||
</div>
|
||||
@endforelse
|
||||
@endif
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Verlauf') }}</span>
|
||||
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">{{ __('verbrauchte Credits') }}</span>
|
||||
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">{{ __('eingelöste Käufe') }}</span>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
@forelse ($bookingHistory as $booking)
|
||||
<div>{{ $booking }}</div>
|
||||
@empty
|
||||
@if ($consumedPurchases->isNotEmpty())
|
||||
<div class="divide-y divide-[color:var(--color-bg-rule)]">
|
||||
@foreach ($consumedPurchases as $purchase)
|
||||
<div class="py-3 first:pt-0 last:pb-0 flex items-center justify-between gap-4" wire:key="consumed-purchase-{{ $purchase->id }}">
|
||||
<div class="min-w-0">
|
||||
<div class="text-[13px] font-semibold text-[color:var(--color-ink)] truncate">
|
||||
{{ $purchase->pressRelease?->title ?? $purchase->type->label() }}
|
||||
</div>
|
||||
<div class="text-[11.5px] text-[color:var(--color-ink-3)]">
|
||||
{{ __('eingelöst am :date', ['date' => $purchase->consumed_at?->format('d.m.Y')]) }}
|
||||
</div>
|
||||
</div>
|
||||
<span class="badge">{{ __('eingelöst') }}</span>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<div class="flex flex-col items-center justify-center px-4 py-10 text-center">
|
||||
<div class="w-14 h-14 rounded-[6px] flex items-center justify-center mb-3
|
||||
bg-[color:var(--color-bg-subtle)] border border-[color:var(--color-bg-rule)] text-[color:var(--color-ink-3)]">
|
||||
|
|
@ -412,10 +454,10 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte
|
|||
{{ __('Noch kein Buchungsverlauf') }}
|
||||
</div>
|
||||
<p class="text-[12px] text-[color:var(--color-ink-3)] max-w-md m-0">
|
||||
{{ __('Nach dem ersten Checkout werden Verbrauch, Rechnungsbezug und betroffene Pressemitteilung hier nachvollziehbar.') }}
|
||||
{{ __('Eingelöste Einzelkäufe erscheinen hier mit der zugehörigen Pressemitteilung.') }}
|
||||
</p>
|
||||
</div>
|
||||
@endforelse
|
||||
@endif
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ Route::middleware(['auth', 'verified', EnsureUserIsCustomer::class, LogSlowAdmin
|
|||
->name('checkout.subscription');
|
||||
Route::get('checkout/einzel-pm', [CheckoutController::class, 'singlePm'])
|
||||
->name('checkout.single-pm');
|
||||
Route::get('checkout/abo-verwalten', [CheckoutController::class, 'billingPortal'])
|
||||
->name('checkout.billing-portal');
|
||||
Volt::route('invoices', 'customer.invoices')->name('invoices.index');
|
||||
Route::get('legacy-invoices/{legacyInvoice}/pdf', LegacyInvoicePdfController::class)->name('invoices.pdf');
|
||||
Volt::route('tokens', 'customer.tokens')->name('tokens.index');
|
||||
|
|
|
|||
156
tests/Feature/Billing/BookingsPageTest.php
Normal file
156
tests/Feature/Billing/BookingsPageTest.php
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\UserPaymentOptionStatus;
|
||||
use App\Models\Plan;
|
||||
use App\Models\SinglePurchase;
|
||||
use App\Models\User;
|
||||
use App\Models\UserPaymentOption;
|
||||
use App\Services\Billing\StripeCheckoutService;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Livewire\Volt\Volt as LivewireVolt;
|
||||
use Tests\TestCase;
|
||||
|
||||
beforeEach(function (): void {
|
||||
/** @var TestCase $this */
|
||||
$this->seed(RolesAndPermissionsSeeder::class);
|
||||
});
|
||||
|
||||
function bookingsTestCustomer(): User
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$user->assignRole('customer');
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
test('the bookings page renders the active plans with checkout links', function () {
|
||||
/** @var TestCase $this */
|
||||
Plan::factory()->create([
|
||||
'name' => 'Business',
|
||||
'slug' => 'business',
|
||||
'monthly_price_cents' => 4900,
|
||||
'yearly_price_cents' => 49000,
|
||||
'press_release_quota' => 10,
|
||||
'daily_limit' => 2,
|
||||
]);
|
||||
Plan::factory()->inactive()->create(['name' => 'Versteckt']);
|
||||
|
||||
$this->actingAs(bookingsTestCustomer());
|
||||
|
||||
LivewireVolt::test('customer.bookings')
|
||||
->assertSee('Business')
|
||||
->assertSee('49 €')
|
||||
->assertSee('490 €')
|
||||
->assertSee('10 Pressemitteilungen pro Monat')
|
||||
->assertSee('max. 2 Veröffentlichungen pro Tag')
|
||||
->assertSee('2 Monate gratis')
|
||||
->assertSee(route('me.checkout.subscription', ['planSlug' => 'business', 'interval' => 'monthly']), false)
|
||||
->assertSee(route('me.checkout.subscription', ['planSlug' => 'business', 'interval' => 'yearly']), false)
|
||||
->assertDontSee('Versteckt');
|
||||
});
|
||||
|
||||
test('the single pm block links to its checkout', function () {
|
||||
/** @var TestCase $this */
|
||||
config()->set('billing.single_pm_stripe_price_id', 'price_test_single_pm');
|
||||
|
||||
$this->actingAs(bookingsTestCustomer());
|
||||
|
||||
LivewireVolt::test('customer.bookings')
|
||||
->assertSee('Einzel-Pressemitteilung — ohne Abo')
|
||||
->assertSee('19 €')
|
||||
->assertSee(route('me.checkout.single-pm'), false);
|
||||
});
|
||||
|
||||
test('without any booking the page shows the empty state', function () {
|
||||
/** @var TestCase $this */
|
||||
$this->actingAs(bookingsTestCustomer());
|
||||
|
||||
LivewireVolt::test('customer.bookings')
|
||||
->assertSee('Noch kein aktiver Tarif')
|
||||
->assertSee('Noch keine aktiven Buchungen');
|
||||
});
|
||||
|
||||
test('a subscriber sees the current plan and the manage button', function () {
|
||||
/** @var TestCase $this */
|
||||
$user = bookingsTestCustomer();
|
||||
$plan = Plan::factory()->create([
|
||||
'name' => 'Pro',
|
||||
'press_release_quota' => 25,
|
||||
'stripe_price_id_monthly' => 'price_test_m_pro',
|
||||
]);
|
||||
subscribeUserToPlan($user, $plan);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
LivewireVolt::test('customer.bookings')
|
||||
->assertSee('Ihr aktueller Tarif')
|
||||
->assertSee('Abo verwalten')
|
||||
->assertSee('Abo: Pro')
|
||||
->assertSee(route('me.checkout.billing-portal'), false);
|
||||
});
|
||||
|
||||
test('a grandfathered legacy user sees the bestandstarif with unlimited quota', function () {
|
||||
/** @var TestCase $this */
|
||||
$user = bookingsTestCustomer();
|
||||
UserPaymentOption::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'status' => UserPaymentOptionStatus::Grandfathered->value,
|
||||
'current_period_end' => now()->addMonths(3),
|
||||
'legacy_conditions' => ['name' => 'Presseverteiler Premium'],
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
LivewireVolt::test('customer.bookings')
|
||||
->assertSee('Bestandstarif')
|
||||
->assertSee('Presseverteiler Premium')
|
||||
->assertSee('Unbegrenzte Pressemitteilungen (Bestandsschutz).')
|
||||
->assertSee('Unbegrenzt');
|
||||
});
|
||||
|
||||
test('open and consumed single purchases appear in bookings and history', function () {
|
||||
/** @var TestCase $this */
|
||||
$user = bookingsTestCustomer();
|
||||
SinglePurchase::factory()->paid()->create(['user_id' => $user->id]);
|
||||
SinglePurchase::factory()->consumed()->create(['user_id' => $user->id]);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
LivewireVolt::test('customer.bookings')
|
||||
->assertSee('einlösbar')
|
||||
->assertSee('eingelöst am');
|
||||
});
|
||||
|
||||
test('the billing portal redirects without an active subscription', function () {
|
||||
/** @var TestCase $this */
|
||||
$this->actingAs(bookingsTestCustomer())
|
||||
->get(route('me.checkout.billing-portal'))
|
||||
->assertRedirect(route('me.bookings.index'))
|
||||
->assertSessionHas('checkout-notice');
|
||||
});
|
||||
|
||||
test('the billing portal forwards a subscriber to stripe', function () {
|
||||
/** @var TestCase $this */
|
||||
$user = bookingsTestCustomer();
|
||||
$user->forceFill(['stripe_id' => 'cus_test_portal'])->save();
|
||||
$plan = Plan::factory()->create(['stripe_price_id_monthly' => 'price_test_m_portal']);
|
||||
subscribeUserToPlan($user, $plan);
|
||||
|
||||
$this->mock(StripeCheckoutService::class, function ($mock) {
|
||||
$mock->shouldReceive('billingPortalUrl')
|
||||
->once()
|
||||
->andReturn('https://billing.stripe.com/p/session/test');
|
||||
});
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('me.checkout.billing-portal'))
|
||||
->assertRedirect('https://billing.stripe.com/p/session/test');
|
||||
});
|
||||
|
||||
test('the checkout success banner is shown after returning from stripe', function () {
|
||||
/** @var TestCase $this */
|
||||
$this->actingAs(bookingsTestCustomer())
|
||||
->get(route('me.bookings.index', ['checkout' => 'erfolg']))
|
||||
->assertOk()
|
||||
->assertSee('Vielen Dank für Ihre Buchung!');
|
||||
});
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Plan;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Tests\TestCase;
|
||||
|
|
@ -58,21 +59,27 @@ test('customer can access me dashboard but not admin dashboard', function () {
|
|||
$this->get(route('dashboard'))->assertForbidden();
|
||||
});
|
||||
|
||||
test('customer bookings page shows credit packages and add ons from pricing concept', function () {
|
||||
test('customer bookings page shows the tariff grid and single pm block', function () {
|
||||
// Seit Phase 9F zeigt die Seite das echte Tarif-Raster mit
|
||||
// Stripe-Checkout statt des Credit-Konzept-Mocks (Credits → Phase 9I/2).
|
||||
/** @var TestCase $this */
|
||||
$customer = User::factory()->create(['is_active' => true]);
|
||||
$customer->assignRole('customer');
|
||||
|
||||
Plan::factory()->create([
|
||||
'name' => 'Starter',
|
||||
'monthly_price_cents' => 2900,
|
||||
'press_release_quota' => 3,
|
||||
]);
|
||||
|
||||
$this->actingAs($customer)
|
||||
->get(route('me.bookings.index'))
|
||||
->assertSuccessful()
|
||||
->assertSee('Credit-Pakete')
|
||||
->assertSee('Standard')
|
||||
->assertSee('50')
|
||||
->assertSee('45 €')
|
||||
->assertSee('Pressetext-Optimierung')
|
||||
->assertSee('Top-Slot Startseite')
|
||||
->assertSee('Score 80+')
|
||||
->assertSee('Den passenden Tarif wählen')
|
||||
->assertSee('Starter')
|
||||
->assertSee('29 €')
|
||||
->assertSee('2 Monate gratis')
|
||||
->assertSee('Einzel-Pressemitteilung — ohne Abo')
|
||||
->assertSee('Noch keine aktiven Buchungen');
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue