From 3341c929eb7296ba737dca80a6fb3fa9a3113070 Mon Sep 17 00:00:00 2001 From: Kevin Date: Tue, 22 Sep 2026 11:11:33 +0000 Subject: [PATCH] Incentive (E1): "Nur fuer VIP sichtbar" und Ranglisten-Seite mit Teilnahme-Block oben Haken vip_only am Incentive: aktives Incentive erscheint dann nur fuer VIP-Konten in Sidenav, Dashboard, Teaser, Rangliste und Details, andere Berater bekommen 404; das Tracking laeuft fuer alle weiter. Seeder setzt den Haken fuer die Abstimmung der Recruiting Challenge. Ranglisten-Seite: solange die Teilnahme nicht bestaetigt ist, steht der Teilnahme-Block mit Praemien-Chips, Checkbox, Button und aufklappbaren Bedingungen ganz oben, darunter Trennlinie, Titel, Untertitel, Bild, Beschreibung und die Info-Kacheln. Ueberschriften vergroessert, Abo-Bloecke nur wenn Abos zaehlen. Co-Authored-By: Claude Fable 5.1 --- .../Controllers/Admin/IncentiveController.php | 10 +- app/Http/Controllers/HomeController.php | 2 +- .../Controllers/User/IncentiveController.php | 11 +- app/Models/Incentive.php | 27 +- ...00000_add_vip_only_to_incentives_table.php | 33 ++ .../seeders/RecruitingChallenge2026Seeder.php | 4 +- ...2026-09-18-recruiting-challenge-inhalte.md | 4 +- ...twicklungsplan-protokoll-september-2026.md | 8 +- resources/lang/de/incentive.php | 13 + resources/lang/en/incentive.php | 13 + resources/lang/es/incentive.php | 13 + resources/lang/fr/incentive.php | 13 + .../views/admin/incentive/_form.blade.php | 10 + .../views/admin/incentive/show.blade.php | 3 + .../layouts/includes/layout-sidenav.blade.php | 2 +- resources/views/user/incentive/show.blade.php | 548 ++++++++++++------ .../Incentive/IncentiveVipOnlyTest.php | 187 ++++++ .../RecruitingChallengeSeederTest.php | 1 + 18 files changed, 717 insertions(+), 185 deletions(-) create mode 100644 database/migrations/2026_09_22_100000_add_vip_only_to_incentives_table.php create mode 100644 tests/Feature/Incentive/IncentiveVipOnlyTest.php diff --git a/app/Http/Controllers/Admin/IncentiveController.php b/app/Http/Controllers/Admin/IncentiveController.php index 7744b020..3423d16a 100644 --- a/app/Http/Controllers/Admin/IncentiveController.php +++ b/app/Http/Controllers/Admin/IncentiveController.php @@ -59,6 +59,7 @@ class IncentiveController extends Controller 'bonus_rank_2' => 'nullable|numeric|min:0|max:999999', 'bonus_rank_3' => 'nullable|numeric|min:0|max:999999', 'include_sales_points' => 'nullable|boolean', + 'vip_only' => 'nullable|boolean', 'status' => 'required|integer|in:0,1,2', ]); $data = $this->normalizePayoutFields($data); @@ -122,6 +123,7 @@ class IncentiveController extends Controller $data[$field] = isset($data[$field]) && $data[$field] !== '' ? round((float) $data[$field], 2) : null; } $data['include_sales_points'] = filter_var($data['include_sales_points'] ?? false, FILTER_VALIDATE_BOOLEAN); + $data['vip_only'] = filter_var($data['vip_only'] ?? false, FILTER_VALIDATE_BOOLEAN); return $data; } @@ -157,6 +159,7 @@ class IncentiveController extends Controller 'bonus_rank_2' => 'nullable|numeric|min:0|max:999999', 'bonus_rank_3' => 'nullable|numeric|min:0|max:999999', 'include_sales_points' => 'nullable|boolean', + 'vip_only' => 'nullable|boolean', 'status' => 'required|integer|in:0,1,2', ]); $data = $this->normalizePayoutFields($data); @@ -459,7 +462,12 @@ class IncentiveController extends Controller '; }) ->addColumn('status_label', function (Incentive $incentive) { - return ''.$incentive->getStatusType().''; + $badge = ''.$incentive->getStatusType().''; + if ($incentive->vip_only) { + $badge .= ' '.__('incentive.vip_only_badge').''; + } + + return $badge; }) ->addColumn('period', function (Incentive $incentive) { return $incentive->qualification_start->format('d.m.Y').' - '.$incentive->qualification_end->format('d.m.Y'); diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php index 33ccf126..e59cb794 100644 --- a/app/Http/Controllers/HomeController.php +++ b/app/Http/Controllers/HomeController.php @@ -62,7 +62,7 @@ class HomeController extends Controller $incentiveParticipants = collect(); if ($user->isActiveAccount()) { - $activeIncentives = Incentive::activeForNavigation(); + $activeIncentives = Incentive::activeForNavigation($user); if ($activeIncentives->isNotEmpty()) { $incentiveParticipants = IncentiveParticipant::query() ->whereIn('incentive_id', $activeIncentives->pluck('id')) diff --git a/app/Http/Controllers/User/IncentiveController.php b/app/Http/Controllers/User/IncentiveController.php index 7f743942..78da6303 100644 --- a/app/Http/Controllers/User/IncentiveController.php +++ b/app/Http/Controllers/User/IncentiveController.php @@ -34,6 +34,8 @@ class IncentiveController extends Controller ->firstOrFail(); $user = Auth::user(); + abort_unless($incentive->isVisibleFor($user), 404); + $participant = IncentiveParticipant::where('incentive_id', $incentive->id) ->where('user_id', $user->id) ->first(); @@ -54,6 +56,8 @@ class IncentiveController extends Controller ->firstOrFail(); $user = Auth::user(); + abort_unless($incentive->isVisibleFor($user), 404); + $participant = IncentiveParticipant::where('incentive_id', $incentive->id) ->where('user_id', $user->id) ->first(); @@ -97,14 +101,15 @@ class IncentiveController extends Controller ->active() ->firstOrFail(); + $user = Auth::user(); + abort_unless($incentive->isVisibleFor($user), 404); + if (! Request::has('accept_terms')) { \Session()->flash('alert-error', __('incentive.terms_required')); return redirect(route('user_incentive_show', [$slug])); } - $user = Auth::user(); - $participant = IncentiveParticipant::firstOrCreate( [ 'incentive_id' => $incentive->id, @@ -142,6 +147,8 @@ class IncentiveController extends Controller ->firstOrFail(); $user = Auth::user(); + abort_unless($incentive->isVisibleFor($user), 404); + $participant = IncentiveParticipant::with('incentive', 'user', 'user.account') ->where('incentive_id', $incentive->id) ->where('user_id', $user->id) diff --git a/app/Models/Incentive.php b/app/Models/Incentive.php index f0197a9f..38981fcb 100644 --- a/app/Models/Incentive.php +++ b/app/Models/Incentive.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\User; use Carbon\Carbon; use Cviebrock\EloquentSluggable\Sluggable; use Illuminate\Database\Eloquent\Collection; @@ -37,6 +38,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; * @property float|null $bonus_rank_3 * @property bool $include_sales_points * @property int $status + * @property bool $vip_only * @property Carbon|null $created_at * @property Carbon|null $updated_at * @property Carbon|null $deleted_at @@ -70,6 +72,7 @@ class Incentive extends Model 'bonus_rank_3' => 'float', 'include_sales_points' => 'bool', 'status' => 'int', + 'vip_only' => 'bool', 'qualification_start' => 'date', 'qualification_end' => 'date', 'calculation_end' => 'date', @@ -99,6 +102,7 @@ class Incentive extends Model 'bonus_rank_3', 'include_sales_points', 'status', + 'vip_only', ]; /** @@ -141,6 +145,18 @@ class Incentive extends Model return $query->where('status', 1); } + /** + * Incentives, die dieser Berater sehen darf: VIP sieht alles, andere nur ohne VIP-Sperre. + */ + public function scopeVisibleFor($query, ?User $user) + { + if ($user?->isVIP()) { + return $query; + } + + return $query->where('vip_only', false); + } + /** * Incentives, bei denen Umsatzpunkte der Neupartner/Neuabos mitzaehlen. */ @@ -183,14 +199,23 @@ class Incentive extends Model * * @return Collection */ - public static function activeForNavigation(): Collection + public static function activeForNavigation(?User $user = null): Collection { return static::active() + ->visibleFor($user) ->orderBy('qualification_start') ->orderBy('id') ->get(); } + /** + * Darf dieser Berater das Incentive sehen (Teaser, Rangliste, Details, Teilnahme)? + */ + public function isVisibleFor(?User $user): bool + { + return ! $this->vip_only || (bool) $user?->isVIP(); + } + public function isActive(): bool { return $this->status === 1; diff --git a/database/migrations/2026_09_22_100000_add_vip_only_to_incentives_table.php b/database/migrations/2026_09_22_100000_add_vip_only_to_incentives_table.php new file mode 100644 index 00000000..4be87732 --- /dev/null +++ b/database/migrations/2026_09_22_100000_add_vip_only_to_incentives_table.php @@ -0,0 +1,33 @@ += 1) gezeigt werden. Sidenav, Dashboard, Teaser, Rangliste und + * Details bleiben fuer alle anderen Berater verborgen, das Tracking laeuft trotzdem. + * Haken im Admin, jederzeit an- und abschaltbar. + */ +return new class extends Migration +{ + public function up(): void + { + Schema::table('incentives', function (Blueprint $table) { + if (! Schema::hasColumn('incentives', 'vip_only')) { + $table->boolean('vip_only')->default(false)->after('status') + ->comment('1 = nur fuer VIP-Konten sichtbar (Abstimmung)'); + } + }); + } + + public function down(): void + { + Schema::table('incentives', function (Blueprint $table) { + if (Schema::hasColumn('incentives', 'vip_only')) { + $table->dropColumn('vip_only'); + } + }); + } +}; diff --git a/database/seeders/RecruitingChallenge2026Seeder.php b/database/seeders/RecruitingChallenge2026Seeder.php index f017bb8f..f64e3ed2 100644 --- a/database/seeders/RecruitingChallenge2026Seeder.php +++ b/database/seeders/RecruitingChallenge2026Seeder.php @@ -14,7 +14,8 @@ use Illuminate\Database\Seeder; * * php artisan db:seed --class=RecruitingChallenge2026Seeder * - * Danach im Admin pruefen, Bild eintragen und auf "Aktiv" stellen. + * Danach im Admin pruefen, Bild eintragen und auf "Aktiv" stellen. Zur Abstimmung ist + * "Nur fuer VIP sichtbar" gesetzt; nach Freigabe den Haken entfernen. */ class RecruitingChallenge2026Seeder extends Seeder { @@ -44,6 +45,7 @@ class RecruitingChallenge2026Seeder extends Seeder 'bonus_rank_3' => 200, 'include_sales_points' => false, 'status' => 0, + 'vip_only' => true, 'image' => null, ]; diff --git a/dev/_tickets/2026-09-18-recruiting-challenge-inhalte.md b/dev/_tickets/2026-09-18-recruiting-challenge-inhalte.md index 2b565911..c7089b9c 100644 --- a/dev/_tickets/2026-09-18-recruiting-challenge-inhalte.md +++ b/dev/_tickets/2026-09-18-recruiting-challenge-inhalte.md @@ -147,7 +147,7 @@ php artisan db:seed --class=RecruitingChallenge2026Seeder php artisan view:clear && php artisan route:clear ``` -Danach im Admin: Bild eintragen, Texte prüfen, Status auf "Aktiv". Lokal ist die Challenge bereits angelegt und aktiv (ID 3), Teaser und Rangliste lassen sich dort ansehen. +Danach im Admin: Bild eintragen, Texte prüfen, Status auf "Aktiv". Der Seeder setzt den Haken "Nur für VIP sichtbar": Sidenav, Dashboard, Teaser und Rangliste sehen dann nur VIP-Konten, so kann der Kunde die Challenge im Backoffice prüfen, ohne dass Berater sie sehen. Punkte laufen trotzdem für alle mit. Nach Freigabe den Haken im Admin entfernen. Lokal ist die Challenge angelegt, aktiv und auf VIP gestellt (ID 3). ## 12. Checkliste bis zum Start @@ -158,4 +158,4 @@ Danach im Admin: Bild eintragen, Texte prüfen, Status auf "Aktiv". Lokal ist di - [ ] Deploy E1 und Seeder auf Produktion - [ ] Challenge im Admin geprüft, Teaser und Rangliste angesehen - [ ] Ankündigungsmail raus -- [ ] Status "Aktiv" +- [ ] Status "Aktiv" und Haken "Nur für VIP sichtbar" entfernen diff --git a/dev/_tickets/entwicklungsplan-protokoll-september-2026.md b/dev/_tickets/entwicklungsplan-protokoll-september-2026.md index 1754f62d..3f87cc43 100644 --- a/dev/_tickets/entwicklungsplan-protokoll-september-2026.md +++ b/dev/_tickets/entwicklungsplan-protokoll-september-2026.md @@ -451,7 +451,8 @@ Das Incentive-Modul trägt jetzt optional eine Geldprämie; Montenegro bleibt un - **Auszahlungs-Export:** Admin-Detailseite → Button „Auszahlungs-Export (XLSX)“ (`/admin/incentives/{id}/payout-export`): Rang, Berater-ID, Name, E-Mail, Teilnahme bestätigt, Neupartner, Betrag je Neupartner, Prämie Neupartner, Rang-Bonus, Prämie gesamt, Punkte, qualifiziert. Nur Zeilen mit Prämie > 0. Dazu Summenblock (Empfänger, Summen) und Prämienspalte in der Admin-Rangliste. **Gutschrift über `Credit` ist nicht gebaut** – die Februar-Auszahlung läuft über den Export in die Buchhaltung; Gutschriften ließen sich später aus derselben Liste erzeugen. - **Mehrere aktive Incentives:** Sidenav listet alle aktiven Incentives (bei mehreren mit Namen), Dashboard zeigt je Incentive eine Karte mit eigenen Modal-IDs; `HomeController` liefert `activeIncentives`/`incentiveParticipants` (die alten Einzelvariablen bleiben befüllt). Montenegro kann so bis zum Abschluss parallel stehen bleiben. - **Berater-Ansichten:** Teaser und Rangliste zeigen bei Geldprämie ohne Mindestqualifikation eine Prämien-Kachel statt „Dein Ticket“, blenden die Abo-Blöcke aus, wenn Abos keine Rolle spielen, und ergänzen die Rangliste um die Spalte „Prämie“; Header/Details zeigen „Deine Prämie bisher“. Der Punkte-Boost-Text erscheint nur, wenn Umsatzpunkte zählen. -- **Tests:** [`IncentivePayoutTest`](../../tests/Feature/Incentive/IncentivePayoutTest.php), [`IncentiveSalesPointsToggleTest`](../../tests/Feature/Incentive/IncentiveSalesPointsToggleTest.php), [`IncentiveAdminPayoutTest`](../../tests/Feature/Incentive/IncentiveAdminPayoutTest.php). Incentive-Suite: 48 grün, 1 vorbestehender Fehlschlag (`IncentiveTeaserPageTest`, Hero-Bild in der Galerie – unabhängig von E1). +- **Nur für VIP sichtbar (22.09.):** Haken am Incentive (`incentives.vip_only`, Migration `2026_09_22_100000`). Aktives Incentive erscheint dann nur für VIP-Konten (Sidenav, Dashboard, Teaser, Rangliste, Details; andere bekommen 404), Tracking läuft für alle. Zur Abstimmung mit dem Kunden; danach Haken entfernen. Ranglisten-Seite umgebaut: Teilnahme-Block mit Prämien-Chips und Bedingungen steht oben, solange die Teilnahme nicht bestätigt ist, darunter Trennlinie und die Inhalte; Überschriften vergrößert. Inhalte/Texte: [2026-09-18-recruiting-challenge-inhalte.md](2026-09-18-recruiting-challenge-inhalte.md), Seeder `RecruitingChallenge2026Seeder`. +- **Tests:** [`IncentiveVipOnlyTest`](../../tests/Feature/Incentive/IncentiveVipOnlyTest.php), [`RecruitingChallengeSeederTest`](../../tests/Feature/Incentive/RecruitingChallengeSeederTest.php), [`IncentivePayoutTest`](../../tests/Feature/Incentive/IncentivePayoutTest.php), [`IncentiveSalesPointsToggleTest`](../../tests/Feature/Incentive/IncentiveSalesPointsToggleTest.php), [`IncentiveAdminPayoutTest`](../../tests/Feature/Incentive/IncentiveAdminPayoutTest.php). Incentive-Suite: 48 grün, 1 vorbestehender Fehlschlag (`IncentiveTeaserPageTest`, Hero-Bild in der Galerie – unabhängig von E1). **Konfiguration der Challenge (Admin → Incentives → Neu):** @@ -481,11 +482,12 @@ Das Incentive-Modul trägt jetzt optional eine Geldprämie; Montenegro bleibt un **Deploy** ```bash -php artisan migrate # incentives: amount_per_partner, bonus_rank_1..3, include_sales_points +php artisan migrate # incentives: amount_per_partner, bonus_rank_1..3, include_sales_points, vip_only +php artisan db:seed --class=RecruitingChallenge2026Seeder # Challenge als Entwurf, nur VIP, mit Texten php artisan view:clear && php artisan route:clear ``` -Danach Challenge im Admin anlegen (Tabelle oben). Kein Neuaufbau, keine Datenänderung an Montenegro. +Danach im Admin: Bild, Status „Aktiv“, zur Abstimmung bleibt „Nur für VIP sichtbar“ gesetzt. Kein Neuaufbau, keine Datenänderung an Montenegro. --- diff --git a/resources/lang/de/incentive.php b/resources/lang/de/incentive.php index 0ea6b172..c8354a3a 100644 --- a/resources/lang/de/incentive.php +++ b/resources/lang/de/incentive.php @@ -209,4 +209,17 @@ return [ 'new_partners_short' => 'Neupartner', 'badge_challenge' => 'Challenge', 'export_user_id' => 'Berater-ID', + + // Sichtbarkeit VIP und Teilnahme-Block (E1, 22.09.) + 'vip_only' => 'Nur für VIP sichtbar', + 'vip_only_help' => 'Zur Abstimmung: Sidenav, Dashboard, Teaser und Rangliste sehen nur VIP-Konten. Punkte laufen für alle Berater trotzdem mit. Haken entfernen, sobald die Challenge freigegeben ist.', + 'vip_only_badge' => 'nur VIP', + 'join_title' => 'Jetzt teilnehmen', + 'join_confirm_title' => 'Teilnahme bestätigen', + 'join_terms_toggle' => 'Teilnahmebedingungen anzeigen', + 'join_highlight_partner' => ':amount je neuem Partner', + 'join_highlight_rank' => ':amount für Platz :n', + 'join_highlight_points' => ':points Punkte je neuem Partner', + 'join_highlight_abo' => ':points Punkte je Kundenabo', + 'section_info' => 'Alles zur Challenge', ]; diff --git a/resources/lang/en/incentive.php b/resources/lang/en/incentive.php index 62c88c4b..51ed64b3 100644 --- a/resources/lang/en/incentive.php +++ b/resources/lang/en/incentive.php @@ -209,4 +209,17 @@ return [ 'new_partners_short' => 'New partners', 'badge_challenge' => 'Challenge', 'export_user_id' => 'Consultant ID', + + // Sichtbarkeit VIP und Teilnahme-Block (E1, 22.09.) + 'vip_only' => 'Visible to VIP only', + 'vip_only_help' => 'For review: only VIP accounts see it in the sidenav, dashboard, teaser and ranking. Points are still tracked for all consultants. Remove the tick once the challenge is approved.', + 'vip_only_badge' => 'VIP only', + 'join_title' => 'Join now', + 'join_confirm_title' => 'Confirm participation', + 'join_terms_toggle' => 'Show terms of participation', + 'join_highlight_partner' => ':amount per new partner', + 'join_highlight_rank' => ':amount for rank :n', + 'join_highlight_points' => ':points points per new partner', + 'join_highlight_abo' => ':points points per customer subscription', + 'section_info' => 'All about the challenge', ]; diff --git a/resources/lang/es/incentive.php b/resources/lang/es/incentive.php index 482b6363..17e49506 100644 --- a/resources/lang/es/incentive.php +++ b/resources/lang/es/incentive.php @@ -209,4 +209,17 @@ return [ 'new_partners_short' => 'Nuevos socios', 'badge_challenge' => 'Reto', 'export_user_id' => 'ID del asesor', + + // Sichtbarkeit VIP und Teilnahme-Block (E1, 22.09.) + 'vip_only' => 'Visible solo para VIP', + 'vip_only_help' => 'Para la revisión: solo las cuentas VIP lo ven en el menú, el panel, el teaser y el ranking. Los puntos se registran igualmente para todos los asesores. Quita la marca cuando el reto esté aprobado.', + 'vip_only_badge' => 'solo VIP', + 'join_title' => 'Participa ahora', + 'join_confirm_title' => 'Confirmar participación', + 'join_terms_toggle' => 'Mostrar condiciones de participación', + 'join_highlight_partner' => ':amount por nuevo socio', + 'join_highlight_rank' => ':amount para el puesto :n', + 'join_highlight_points' => ':points puntos por nuevo socio', + 'join_highlight_abo' => ':points puntos por suscripción de cliente', + 'section_info' => 'Todo sobre el reto', ]; diff --git a/resources/lang/fr/incentive.php b/resources/lang/fr/incentive.php index 65e722d1..9ce466d8 100644 --- a/resources/lang/fr/incentive.php +++ b/resources/lang/fr/incentive.php @@ -184,4 +184,17 @@ return [ 'new_partners_short' => 'Nouveaux partenaires', 'badge_challenge' => 'Challenge', 'export_user_id' => 'ID du conseiller', + + // Sichtbarkeit VIP und Teilnahme-Block (E1, 22.09.) + 'vip_only' => 'Visible uniquement pour les VIP', + 'vip_only_help' => 'Pour la validation : seuls les comptes VIP le voient dans le menu, le tableau de bord, le teaser et le classement. Les points sont quand même comptés pour tous les conseillers. Retirer la coche dès que le challenge est validé.', + 'vip_only_badge' => 'VIP uniquement', + 'join_title' => 'Participer maintenant', + 'join_confirm_title' => 'Confirmer la participation', + 'join_terms_toggle' => 'Afficher les conditions de participation', + 'join_highlight_partner' => ':amount par nouveau partenaire', + 'join_highlight_rank' => ':amount pour la place :n', + 'join_highlight_points' => ':points points par nouveau partenaire', + 'join_highlight_abo' => ':points points par abonnement client', + 'section_info' => 'Tout sur le challenge', ]; diff --git a/resources/views/admin/incentive/_form.blade.php b/resources/views/admin/incentive/_form.blade.php index 17414ba2..8d4b35e5 100644 --- a/resources/views/admin/incentive/_form.blade.php +++ b/resources/views/admin/incentive/_form.blade.php @@ -35,6 +35,16 @@ +
+
+ + vip_only : 0)) checked @endif> + +
+ {{ __('incentive.vip_only_help') }} +
+
diff --git a/resources/views/admin/incentive/show.blade.php b/resources/views/admin/incentive/show.blade.php index 88a0fd4c..9aab055a 100644 --- a/resources/views/admin/incentive/show.blade.php +++ b/resources/views/admin/incentive/show.blade.php @@ -4,6 +4,9 @@

{{ $incentive->name }} {{ $incentive->getStatusType() }} + @if ($incentive->vip_only) + {{ __('incentive.vip_only_badge') }} + @endif
{{ __('incentive.edit') }} diff --git a/resources/views/layouts/includes/layout-sidenav.blade.php b/resources/views/layouts/includes/layout-sidenav.blade.php index 1229ab8b..0e4f9bd8 100755 --- a/resources/views/layouts/includes/layout-sidenav.blade.php +++ b/resources/views/layouts/includes/layout-sidenav.blade.php @@ -290,7 +290,7 @@
{{ __('navigation.downloadcenter') }}
- @php $sidenavIncentives = \App\Models\Incentive::activeForNavigation(); @endphp + @php $sidenavIncentives = \App\Models\Incentive::activeForNavigation(Auth::user()); @endphp @foreach ($sidenavIncentives as $sidenavIncentive)
  • h6 { + font-size: 1.15rem; + } + + /* Teilnahme-Block ganz oben */ + .inc-join { + background: linear-gradient(135deg, #6b7758 0%, #4a5340 100%); + color: #fff; + border-radius: .75rem; + padding: 1.75rem 2rem; + position: relative; + overflow: hidden; + } + + .inc-join::before { + content: ''; + position: absolute; + top: -50%; + right: -10%; + width: 320px; + height: 320px; + background: rgba(215, 215, 0, 0.08); + border-radius: 50%; + } + + .inc-join > .row, + .inc-join > .collapse { + position: relative; + z-index: 1; + } + + .inc-join-title { + font-size: 1.8rem; + font-weight: 800; + margin-bottom: .5rem; + color: #fff; + } + + .inc-join-title i { + color: #d7d700; + } + + .inc-join-intro { + font-size: 1rem; + opacity: .9; + margin-bottom: 1rem; + } + + .inc-join-highlights { + display: flex; + flex-wrap: wrap; + gap: .5rem; + margin-bottom: .5rem; + } + + .inc-chip { + display: inline-block; + background: rgba(255, 255, 255, 0.16); + border: 1px solid rgba(255, 255, 255, 0.35); + color: #fff; + border-radius: 50px; + padding: .4rem 1rem; + font-weight: 600; + font-size: .95rem; + } + + .inc-chip-strong { + background: #d7d700; + border-color: #d7d700; + color: #333; + font-weight: 800; + font-size: 1.05rem; + } + + .inc-chip-muted { + background: transparent; + font-weight: 500; + font-size: .85rem; + opacity: .85; + } + + .inc-join-stats { + display: flex; + flex-wrap: wrap; + gap: 1rem; + margin-top: 1rem; + } + + .inc-join-stats .stat-box { + text-align: center; + padding: .5rem .9rem; + background: rgba(255, 255, 255, 0.12); + border-radius: .5rem; + } + + .inc-join-stats .stat-label { + font-size: .68rem; + text-transform: uppercase; + letter-spacing: .05em; + opacity: .8; + } + + .inc-join-stats .stat-value { + font-size: 1.4rem; + font-weight: 800; + line-height: 1.1; + } + + .inc-join-form { + background: rgba(255, 255, 255, 0.12); + border: 1px solid rgba(255, 255, 255, 0.25); + border-radius: .75rem; + padding: 1.25rem; + } + + .inc-join-form .custom-control-label { + font-size: .95rem; + } + + .inc-join-form .btn-participate { + font-size: 1.05rem; + font-weight: 800; + } + + .inc-join-hint { + background: rgba(255, 255, 255, 0.12); + border-radius: .5rem; + padding: .6rem .8rem; + font-size: .85rem; + } + + .inc-join-terms-link { + display: inline-block; + margin-top: .9rem; + color: #fff; + text-decoration: underline; + font-size: .9rem; + } + + .inc-join-terms-link:hover { + color: #d7d700; + } + + .inc-join-terms { + background: #fff; + color: #333; + border-radius: .75rem; + padding: 1.25rem; + max-height: 360px; + overflow-y: auto; + } + + .inc-divider { + border: 0; + border-top: 3px solid #e3e5dc; + margin: 1.75rem 0; } .inc-header .stat-box { @@ -294,7 +474,7 @@ .inc-ranking-card .ranking-title { font-weight: 700; - font-size: 1.1rem; + font-size: 1.35rem; color: #333; } @@ -453,6 +633,18 @@ .inc-participate { padding: 1.5rem; } + + .inc-join { + padding: 1.25rem; + } + + .inc-join-title { + font-size: 1.45rem; + } + + .inc-page-title { + font-size: 1.5rem; + } } @endsection @@ -695,45 +887,113 @@ @else {{-- ============================================================ ANSICHT: NOCH KEINE ZUSTIMMUNG (Punkte laufen) ODER KEIN TEILNEHMER + Teilnahme-Block zuerst, damit er auch mobil sofort sichtbar ist. ============================================================ --}} - @if ($participant) -
    -
    -
    - -
    {{ $incentive->name }}
    +
    +
    +
    +

    + + {{ $participant ? __('incentive.join_confirm_title') : __('incentive.join_title') }} +

    +

    + {{ $participant ? __('incentive.pending_confirmation_banner') : __('incentive.participate_intro') }} +

    + + {{-- Hervorhebungen: worum es geht --}} +
    + @if ((float) $incentive->amount_per_partner > 0) + {{ __('incentive.join_highlight_partner', ['amount' => \App\Services\Incentive\IncentivePayoutService::formatAmount((float) $incentive->amount_per_partner)]) }} + @elseif ($incentive->points_partner_onetime > 0) + {{ __('incentive.join_highlight_points', ['points' => number_format($incentive->points_partner_onetime, 0, ',', '.')]) }} + @endif + @foreach ($incentive->rankBonuses() as $bonusRank => $bonusAmount) + {{ __('incentive.join_highlight_rank', ['amount' => \App\Services\Incentive\IncentivePayoutService::formatAmount($bonusAmount), 'n' => $bonusRank]) }} + @endforeach + @if ($incentive->usesAbos() && $incentive->points_abo_onetime > 0) + {{ __('incentive.join_highlight_abo', ['points' => number_format($incentive->points_abo_onetime, 0, ',', '.')]) }} + @endif + + + {{ $incentive->qualification_start->format('d.m.Y') }} bis {{ $incentive->qualification_end->format('d.m.Y') }} +
    -
    -
    - - {{ __('incentive.pending_confirmation_banner') }} -
    + + @if ($incentive->getLang('terms')) +
    +
    +
    {!! $incentive->getLang('terms') !!}
    +
    +
    + @endif +
    + +
    + + {{-- Danach alles, was auch nach der Bestätigung angezeigt wird --}} +

    + {{ $incentive->name }} +

    + @if ($incentive->getLang('subtitle')) +

    {{ $incentive->getLang('subtitle') }}

    @endif @if ($incentive->image) @@ -744,19 +1004,15 @@
    @endif -

    - {{ $incentive->name }} -

    - @if ($incentive->getLang('description'))
    {!! $incentive->getLang('description') !!}
    @endif +

    {{ __('incentive.section_info') }}

    -
    - +
    {{-- Qualifikationszeitraum --}}
    @@ -770,160 +1026,106 @@
  • {{ __('incentive.qualification_period') }}: {{ $incentive->qualification_start->format('d.m.Y') }} - – {{ $incentive->qualification_end->format('d.m.Y') }} -
  • -
  • - {{ __('incentive.calculation_period') }}: - {{ __('incentive.calculation_period_hint', ['date' => $incentive->calculation_end->format('d.m.Y')]) }} + bis {{ $incentive->qualification_end->format('d.m.Y') }}
  • + @if (!$incentive->calculation_end->isSameDay($incentive->qualification_end)) +
  • + {{ __('incentive.calculation_period') }}: + {{ __('incentive.calculation_period_hint', ['date' => $incentive->calculation_end->format('d.m.Y')]) }} +
  • + @endif

    - + +
    @if ($incentive->hasCashPayout() && !$incentive->hasMinimumRequirements()) @include('user.incentive._payout_tile', ['incentive' => $incentive]) @else - {{-- Mindestqualifikation --}} -
    -
    -
    -
    - -
    -
    -
    {{ __('incentive.section_min_qual') }}
    -

    {{ __('incentive.min_qual_intro') }}

    -
      -
    • - {{ $incentive->min_direct_partners }} - {{ __('incentive.min_partners_label') }} -
    • -
    • - {{ $incentive->min_customer_abos }} - {{ __('incentive.min_abos_label') }} -
    • -
    -

    - - {{ __('incentive.min_qual_ranking_hint') }} -

    -
    -
    -
    + {{-- Mindestqualifikation --}} +
    +
    +
    +
    +
    - @endif - - {{-- Punkte-System --}} -
    -
    - - {{ __('incentive.section_points') }} -
    -
    -
    -
    -
    - - {{ __('incentive.points_partners_title') }} -
    -
    - {{ number_format($incentive->points_partner_onetime, 0, ',', '.') }} - {{ __('incentive.points_short') }} +
    +
    {{ __('incentive.section_min_qual') }}
    +

    {{ __('incentive.min_qual_intro') }}

    +
      +
    • + {{ $incentive->min_direct_partners }} + {{ __('incentive.min_partners_label') }} +
    • +
    • + {{ $incentive->min_customer_abos }} + {{ __('incentive.min_abos_label') }} +
    • +
    +

    + + {{ __('incentive.min_qual_ranking_hint') }} +

    -
      -
    • {{ __('incentive.points_onetime_label') }}
    • -
    • {{ __('incentive.points_starter_package_label') }}
    • - @if ($incentive->includesSalesPoints()) -
    • {{ __('incentive.points_partner_boost') }}
    • - @endif - @include('user.incentive._payout_bullets', ['incentive' => $incentive]) -
    -
    -
    - @if ($incentive->usesAbos()) -
    -
    -
    - - {{ __('incentive.points_abos_title') }} -
    -
    - {{ number_format($incentive->points_abo_onetime, 0, ',', '.') }} - {{ __('incentive.points_short') }} -
    -
      -
    • {{ __('incentive.points_onetime_label') }}
    • - @if ($incentive->includesSalesPoints()) -
    • {{ __('incentive.points_abo_boost') }}
    • - @endif -
    -
    -
    - @endif -
    -
    - -
    - -
    + {{-- Punkte-System --}} +
    +
    + + {{ __('incentive.section_points') }} +
    +
    +
    +
    +
    + + {{ __('incentive.points_partners_title') }} +
    +
    + {{ number_format($incentive->points_partner_onetime, 0, ',', '.') }} + {{ __('incentive.points_short') }} +
    +
      +
    • {{ __('incentive.points_onetime_label') }}
    • +
    • {{ __('incentive.points_starter_package_label') }}
    • + @if ($incentive->includesSalesPoints()) +
    • {{ __('incentive.points_partner_boost') }}
    • + @endif + @include('user.incentive._payout_bullets', ['incentive' => $incentive]) +
    +
    +
    + @if ($incentive->usesAbos()) +
    +
    +
    + + {{ __('incentive.points_abos_title') }} +
    +
    + {{ number_format($incentive->points_abo_onetime, 0, ',', '.') }} + {{ __('incentive.points_short') }} +
    +
      +
    • {{ __('incentive.points_onetime_label') }}
    • +
    • {{ __('incentive.points_abo_direct') }}
    • + @if ($incentive->includesSalesPoints()) +
    • {{ __('incentive.points_abo_boost') }}
    • + @endif +
    +
    +
    + @endif +
    +
    @endif {{-- ============================================================ diff --git a/tests/Feature/Incentive/IncentiveVipOnlyTest.php b/tests/Feature/Incentive/IncentiveVipOnlyTest.php new file mode 100644 index 00000000..7eb9ab6c --- /dev/null +++ b/tests/Feature/Incentive/IncentiveVipOnlyTest.php @@ -0,0 +1,187 @@ +previousHttpHost = $_SERVER['HTTP_HOST'] ?? null; + $_SERVER['HTTP_HOST'] = config('domains.domains.crm.host'); + $this->refreshApplication(); + $this->artisan('migrate'); +}); + +afterEach(function () { + if ($this->previousHttpHost === null) { + unset($_SERVER['HTTP_HOST']); + } else { + $_SERVER['HTTP_HOST'] = $this->previousHttpHost; + } +}); + +function vipTestUser(int $admin = 0): User +{ + $account = UserAccount::create(['first_name' => 'Vera', 'last_name' => 'Berater']); + + return User::forceCreate([ + 'email' => 'vip-'.uniqid('', true).'@example.com', + 'password' => bcrypt('secret'), + 'lang' => 'de', + 'admin' => $admin, + 'account_id' => $account->id, + 'payment_account' => now()->addYear(), + 'wizard' => 100, + ]); +} + +function vipOnlyChallenge(array $overrides = []): Incentive +{ + return Incentive::factory()->create(array_merge([ + 'name' => 'Recruiting Challenge', + 'slug' => 'recruiting-challenge-vip-'.uniqid(), + 'qualification_start' => '2026-10-01', + 'qualification_end' => '2027-01-31', + 'calculation_end' => '2027-01-31', + 'points_partner_onetime' => 1, + 'points_abo_onetime' => 0, + 'min_direct_partners' => 0, + 'min_customer_abos' => 0, + 'max_winners' => 3, + 'amount_per_partner' => 50, + 'bonus_rank_1' => 500, + 'include_sales_points' => false, + 'vip_only' => true, + ], $overrides)); +} + +it('zeigt ein VIP-Incentive in der Navigation nur VIP-Konten', function () { + $public = Incentive::factory()->create(['qualification_start' => '2026-04-01']); + $vipOnly = vipOnlyChallenge(); + + expect(Incentive::activeForNavigation(vipTestUser())->pluck('id')->all())->toBe([$public->id]) + ->and(Incentive::activeForNavigation(vipTestUser(admin: 1))->pluck('id')->all())->toBe([$public->id, $vipOnly->id]) + ->and(Incentive::activeForNavigation(null)->pluck('id')->all())->toBe([$public->id]) + ->and($vipOnly->isVisibleFor(vipTestUser()))->toBeFalse() + ->and($vipOnly->isVisibleFor(vipTestUser(admin: 1)))->toBeTrue() + ->and($public->isVisibleFor(vipTestUser()))->toBeTrue(); +}); + +it('liefert normalen Beratern 404 fuer Teaser, Rangliste, Details und Teilnahme', function () { + $incentive = vipOnlyChallenge(); + + $this->actingAs(vipTestUser()); + $this->get(route('user_incentive_teaser', [$incentive->slug]))->assertNotFound(); + $this->get(route('user_incentive_show', [$incentive->slug]))->assertNotFound(); + $this->get(route('user_incentive_details', [$incentive->slug]))->assertNotFound(); + $this->post(route('user_incentive_participate', [$incentive->slug]), ['accept_terms' => 1])->assertNotFound(); +}); + +it('zeigt VIP-Konten Teaser und Rangliste eines VIP-Incentives', function () { + $incentive = vipOnlyChallenge(); + + $this->actingAs(vipTestUser(admin: 1)); + $teaser = $this->get(route('user_incentive_teaser', [$incentive->slug])); + $teaser->assertOk(); + $this->get(route('user_incentive_show', [$incentive->slug]))->assertOk(); +}); + +it('zeigt allen Beratern ein Incentive ohne VIP-Sperre', function () { + $incentive = vipOnlyChallenge(['vip_only' => false]); + + $this->actingAs(vipTestUser()); + $this->get(route('user_incentive_show', [$incentive->slug]))->assertOk(); +}); + +it('speichert den VIP-Haken im Admin und zeigt ihn auf der Detailseite', function () { + $this->actingAs(vipTestUser(admin: 2)); + + $this->post(route('admin_incentive_store'), [ + 'name' => 'VIP Test Challenge', + 'qualification_start' => '01.10.2026', + 'qualification_end' => '31.01.2027', + 'calculation_end' => '31.01.2027', + 'points_partner_onetime' => 1, + 'points_abo_onetime' => 0, + 'min_direct_partners' => 0, + 'min_customer_abos' => 0, + 'max_winners' => 3, + 'include_sales_points' => '0', + 'vip_only' => '1', + 'status' => 1, + ])->assertRedirect(route('admin_incentives')); + + $incentive = Incentive::where('name', 'VIP Test Challenge')->firstOrFail(); + expect($incentive->vip_only)->toBeTrue(); + + $this->get(route('admin_incentive_show', [$incentive->id])) + ->assertOk() + ->assertSee('id="incentive-vip-only-badge"', false); +}); + +describe('Ranglisten-Seite: Teilnahme-Block oben', function () { + it('zeigt ohne Bestaetigung den Teilnahme-Block mit Praemie zuerst, dann Trennlinie und Inhalt', function () { + $incentive = vipOnlyChallenge(['vip_only' => false, 'terms' => '

    Bedingungen Text

    ']); + $this->actingAs(vipTestUser()); + + $response = $this->get(route('user_incentive_show', [$incentive->slug]))->assertOk(); + $html = $response->getContent(); + + $join = strpos($html, 'id="incentive-join"'); + $divider = strpos($html, 'class="inc-divider"'); + $title = strpos($html, 'class="inc-page-title"'); + $ranking = strpos($html, 'class="inc-ranking-card mt-2'); + + expect($join)->not->toBeFalse() + ->and($divider)->toBeGreaterThan($join) + ->and($title)->toBeGreaterThan($divider) + ->and($ranking)->toBeGreaterThan($title); + + $response->assertSee(__('incentive.join_title')) + ->assertSee('50,00 €') + ->assertSee(__('incentive.join_highlight_rank', ['amount' => '500,00 €', 'n' => 1])) + ->assertSee(__('incentive.join_terms_toggle')) + ->assertSee('Bedingungen Text', false) + ->assertSee('id="incentive-payout-tile"', false); + }); + + it('nennt den Block "Teilnahme bestaetigen", wenn Punkte schon laufen', function () { + $incentive = vipOnlyChallenge(['vip_only' => false]); + $user = vipTestUser(); + IncentiveParticipant::factory()->unconfirmed()->create([ + 'incentive_id' => $incentive->id, 'user_id' => $user->id, + 'qualified_partners' => 2, 'total_points' => 2, 'is_qualified' => true, 'rank' => 1, + ]); + $this->actingAs($user); + + $this->get(route('user_incentive_show', [$incentive->slug])) + ->assertOk() + ->assertSee(__('incentive.join_confirm_title')) + ->assertSee(__('incentive.payout_so_far')) + ->assertSee('600,00 €'); + }); + + it('zeigt bestaetigten Teilnehmern keinen Teilnahme-Block', function () { + $incentive = vipOnlyChallenge(['vip_only' => false]); + $user = vipTestUser(); + IncentiveParticipant::factory()->create([ + 'incentive_id' => $incentive->id, 'user_id' => $user->id, + 'qualified_partners' => 1, 'total_points' => 1, 'is_qualified' => true, 'rank' => 1, + ]); + $this->actingAs($user); + + $this->get(route('user_incentive_show', [$incentive->slug])) + ->assertOk() + ->assertDontSee('id="incentive-join"', false) + ->assertSee(__('incentive.my_details')); + }); +}); diff --git a/tests/Feature/Incentive/RecruitingChallengeSeederTest.php b/tests/Feature/Incentive/RecruitingChallengeSeederTest.php index 175f7187..27011532 100644 --- a/tests/Feature/Incentive/RecruitingChallengeSeederTest.php +++ b/tests/Feature/Incentive/RecruitingChallengeSeederTest.php @@ -16,6 +16,7 @@ it('legt die Challenge als Entwurf mit Praemie und Wertung nach Neupartnern an', $incentive = Incentive::where('slug', RecruitingChallenge2026Seeder::SLUG)->firstOrFail(); expect($incentive->isDraft())->toBeTrue() + ->and($incentive->vip_only)->toBeTrue() ->and($incentive->name)->toBe('Recruiting Challenge 2026/27') ->and($incentive->qualification_start->toDateString())->toBe('2026-10-01') ->and($incentive->qualification_end->toDateString())->toBe('2027-01-31')