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 <noreply@anthropic.com>
This commit is contained in:
Kevin 2026-09-22 11:11:33 +00:00
parent 4e4b58e90e
commit 3341c929eb
18 changed files with 717 additions and 185 deletions

View file

@ -59,6 +59,7 @@ class IncentiveController extends Controller
'bonus_rank_2' => 'nullable|numeric|min:0|max:999999', 'bonus_rank_2' => 'nullable|numeric|min:0|max:999999',
'bonus_rank_3' => 'nullable|numeric|min:0|max:999999', 'bonus_rank_3' => 'nullable|numeric|min:0|max:999999',
'include_sales_points' => 'nullable|boolean', 'include_sales_points' => 'nullable|boolean',
'vip_only' => 'nullable|boolean',
'status' => 'required|integer|in:0,1,2', 'status' => 'required|integer|in:0,1,2',
]); ]);
$data = $this->normalizePayoutFields($data); $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[$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['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; return $data;
} }
@ -157,6 +159,7 @@ class IncentiveController extends Controller
'bonus_rank_2' => 'nullable|numeric|min:0|max:999999', 'bonus_rank_2' => 'nullable|numeric|min:0|max:999999',
'bonus_rank_3' => 'nullable|numeric|min:0|max:999999', 'bonus_rank_3' => 'nullable|numeric|min:0|max:999999',
'include_sales_points' => 'nullable|boolean', 'include_sales_points' => 'nullable|boolean',
'vip_only' => 'nullable|boolean',
'status' => 'required|integer|in:0,1,2', 'status' => 'required|integer|in:0,1,2',
]); ]);
$data = $this->normalizePayoutFields($data); $data = $this->normalizePayoutFields($data);
@ -459,7 +462,12 @@ class IncentiveController extends Controller
<a href="'.route('admin_incentive_edit', [$incentive->id]).'" class="btn icon-btn btn-sm btn-warning"><span class="fa fa-edit"></span></a>'; <a href="'.route('admin_incentive_edit', [$incentive->id]).'" class="btn icon-btn btn-sm btn-warning"><span class="fa fa-edit"></span></a>';
}) })
->addColumn('status_label', function (Incentive $incentive) { ->addColumn('status_label', function (Incentive $incentive) {
return '<span class="badge badge-'.$incentive->getStatusColor().'">'.$incentive->getStatusType().'</span>'; $badge = '<span class="badge badge-'.$incentive->getStatusColor().'">'.$incentive->getStatusType().'</span>';
if ($incentive->vip_only) {
$badge .= ' <span class="badge badge-dark">'.__('incentive.vip_only_badge').'</span>';
}
return $badge;
}) })
->addColumn('period', function (Incentive $incentive) { ->addColumn('period', function (Incentive $incentive) {
return $incentive->qualification_start->format('d.m.Y').' - '.$incentive->qualification_end->format('d.m.Y'); return $incentive->qualification_start->format('d.m.Y').' - '.$incentive->qualification_end->format('d.m.Y');

View file

@ -62,7 +62,7 @@ class HomeController extends Controller
$incentiveParticipants = collect(); $incentiveParticipants = collect();
if ($user->isActiveAccount()) { if ($user->isActiveAccount()) {
$activeIncentives = Incentive::activeForNavigation(); $activeIncentives = Incentive::activeForNavigation($user);
if ($activeIncentives->isNotEmpty()) { if ($activeIncentives->isNotEmpty()) {
$incentiveParticipants = IncentiveParticipant::query() $incentiveParticipants = IncentiveParticipant::query()
->whereIn('incentive_id', $activeIncentives->pluck('id')) ->whereIn('incentive_id', $activeIncentives->pluck('id'))

View file

@ -34,6 +34,8 @@ class IncentiveController extends Controller
->firstOrFail(); ->firstOrFail();
$user = Auth::user(); $user = Auth::user();
abort_unless($incentive->isVisibleFor($user), 404);
$participant = IncentiveParticipant::where('incentive_id', $incentive->id) $participant = IncentiveParticipant::where('incentive_id', $incentive->id)
->where('user_id', $user->id) ->where('user_id', $user->id)
->first(); ->first();
@ -54,6 +56,8 @@ class IncentiveController extends Controller
->firstOrFail(); ->firstOrFail();
$user = Auth::user(); $user = Auth::user();
abort_unless($incentive->isVisibleFor($user), 404);
$participant = IncentiveParticipant::where('incentive_id', $incentive->id) $participant = IncentiveParticipant::where('incentive_id', $incentive->id)
->where('user_id', $user->id) ->where('user_id', $user->id)
->first(); ->first();
@ -97,14 +101,15 @@ class IncentiveController extends Controller
->active() ->active()
->firstOrFail(); ->firstOrFail();
$user = Auth::user();
abort_unless($incentive->isVisibleFor($user), 404);
if (! Request::has('accept_terms')) { if (! Request::has('accept_terms')) {
\Session()->flash('alert-error', __('incentive.terms_required')); \Session()->flash('alert-error', __('incentive.terms_required'));
return redirect(route('user_incentive_show', [$slug])); return redirect(route('user_incentive_show', [$slug]));
} }
$user = Auth::user();
$participant = IncentiveParticipant::firstOrCreate( $participant = IncentiveParticipant::firstOrCreate(
[ [
'incentive_id' => $incentive->id, 'incentive_id' => $incentive->id,
@ -142,6 +147,8 @@ class IncentiveController extends Controller
->firstOrFail(); ->firstOrFail();
$user = Auth::user(); $user = Auth::user();
abort_unless($incentive->isVisibleFor($user), 404);
$participant = IncentiveParticipant::with('incentive', 'user', 'user.account') $participant = IncentiveParticipant::with('incentive', 'user', 'user.account')
->where('incentive_id', $incentive->id) ->where('incentive_id', $incentive->id)
->where('user_id', $user->id) ->where('user_id', $user->id)

View file

@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use App\User;
use Carbon\Carbon; use Carbon\Carbon;
use Cviebrock\EloquentSluggable\Sluggable; use Cviebrock\EloquentSluggable\Sluggable;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
@ -37,6 +38,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
* @property float|null $bonus_rank_3 * @property float|null $bonus_rank_3
* @property bool $include_sales_points * @property bool $include_sales_points
* @property int $status * @property int $status
* @property bool $vip_only
* @property Carbon|null $created_at * @property Carbon|null $created_at
* @property Carbon|null $updated_at * @property Carbon|null $updated_at
* @property Carbon|null $deleted_at * @property Carbon|null $deleted_at
@ -70,6 +72,7 @@ class Incentive extends Model
'bonus_rank_3' => 'float', 'bonus_rank_3' => 'float',
'include_sales_points' => 'bool', 'include_sales_points' => 'bool',
'status' => 'int', 'status' => 'int',
'vip_only' => 'bool',
'qualification_start' => 'date', 'qualification_start' => 'date',
'qualification_end' => 'date', 'qualification_end' => 'date',
'calculation_end' => 'date', 'calculation_end' => 'date',
@ -99,6 +102,7 @@ class Incentive extends Model
'bonus_rank_3', 'bonus_rank_3',
'include_sales_points', 'include_sales_points',
'status', 'status',
'vip_only',
]; ];
/** /**
@ -141,6 +145,18 @@ class Incentive extends Model
return $query->where('status', 1); 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. * Incentives, bei denen Umsatzpunkte der Neupartner/Neuabos mitzaehlen.
*/ */
@ -183,14 +199,23 @@ class Incentive extends Model
* *
* @return Collection<int, Incentive> * @return Collection<int, Incentive>
*/ */
public static function activeForNavigation(): Collection public static function activeForNavigation(?User $user = null): Collection
{ {
return static::active() return static::active()
->visibleFor($user)
->orderBy('qualification_start') ->orderBy('qualification_start')
->orderBy('id') ->orderBy('id')
->get(); ->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 public function isActive(): bool
{ {
return $this->status === 1; return $this->status === 1;

View file

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Sichtbarkeit "nur fuer VIP": Ein aktives Incentive kann zur Abstimmung erst den
* VIP-Konten (admin >= 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');
}
});
}
};

View file

@ -14,7 +14,8 @@ use Illuminate\Database\Seeder;
* *
* php artisan db:seed --class=RecruitingChallenge2026Seeder * 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 class RecruitingChallenge2026Seeder extends Seeder
{ {
@ -44,6 +45,7 @@ class RecruitingChallenge2026Seeder extends Seeder
'bonus_rank_3' => 200, 'bonus_rank_3' => 200,
'include_sales_points' => false, 'include_sales_points' => false,
'status' => 0, 'status' => 0,
'vip_only' => true,
'image' => null, 'image' => null,
]; ];

View file

@ -147,7 +147,7 @@ php artisan db:seed --class=RecruitingChallenge2026Seeder
php artisan view:clear && php artisan route:clear 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 ## 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 - [ ] Deploy E1 und Seeder auf Produktion
- [ ] Challenge im Admin geprüft, Teaser und Rangliste angesehen - [ ] Challenge im Admin geprüft, Teaser und Rangliste angesehen
- [ ] Ankündigungsmail raus - [ ] Ankündigungsmail raus
- [ ] Status "Aktiv" - [ ] Status "Aktiv" und Haken "Nur für VIP sichtbar" entfernen

View file

@ -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. - **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. - **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. - **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):** **Konfiguration der Challenge (Admin → Incentives → Neu):**
@ -481,11 +482,12 @@ Das Incentive-Modul trägt jetzt optional eine Geldprämie; Montenegro bleibt un
**Deploy** **Deploy**
```bash ```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 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.
--- ---

View file

@ -209,4 +209,17 @@ return [
'new_partners_short' => 'Neupartner', 'new_partners_short' => 'Neupartner',
'badge_challenge' => 'Challenge', 'badge_challenge' => 'Challenge',
'export_user_id' => 'Berater-ID', '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',
]; ];

View file

@ -209,4 +209,17 @@ return [
'new_partners_short' => 'New partners', 'new_partners_short' => 'New partners',
'badge_challenge' => 'Challenge', 'badge_challenge' => 'Challenge',
'export_user_id' => 'Consultant ID', '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',
]; ];

View file

@ -209,4 +209,17 @@ return [
'new_partners_short' => 'Nuevos socios', 'new_partners_short' => 'Nuevos socios',
'badge_challenge' => 'Reto', 'badge_challenge' => 'Reto',
'export_user_id' => 'ID del asesor', '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',
]; ];

View file

@ -184,4 +184,17 @@ return [
'new_partners_short' => 'Nouveaux partenaires', 'new_partners_short' => 'Nouveaux partenaires',
'badge_challenge' => 'Challenge', 'badge_challenge' => 'Challenge',
'export_user_id' => 'ID du conseiller', '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',
]; ];

View file

@ -35,6 +35,16 @@
</div> </div>
</div> </div>
<div class="form-group">
<div class="custom-control custom-checkbox">
<input type="hidden" name="vip_only" value="0">
<input type="checkbox" class="custom-control-input" id="vip_only" name="vip_only" value="1"
@if (old('vip_only', isset($incentive) ? (int) $incentive->vip_only : 0)) checked @endif>
<label class="custom-control-label" for="vip_only">{{ __('incentive.vip_only') }}</label>
</div>
<small class="form-text text-muted">{{ __('incentive.vip_only_help') }}</small>
</div>
<div class="form-row"> <div class="form-row">
<div class="form-group col-md-4"> <div class="form-group col-md-4">
<label for="qualification_start">{{ __('incentive.qualification_start') }} *</label> <label for="qualification_start">{{ __('incentive.qualification_start') }} *</label>

View file

@ -4,6 +4,9 @@
<h4 class="font-weight-bold py-2 mb-2"> <h4 class="font-weight-bold py-2 mb-2">
{{ $incentive->name }} {{ $incentive->name }}
<span class="badge badge-{{ $incentive->getStatusColor() }}">{{ $incentive->getStatusType() }}</span> <span class="badge badge-{{ $incentive->getStatusColor() }}">{{ $incentive->getStatusType() }}</span>
@if ($incentive->vip_only)
<span class="badge badge-dark" id="incentive-vip-only-badge" title="{{ __('incentive.vip_only_help') }}">{{ __('incentive.vip_only_badge') }}</span>
@endif
<div class="float-right"> <div class="float-right">
<a href="{{ route('admin_incentive_edit', [$incentive->id]) }}" class="btn btn-sm btn-warning"> <a href="{{ route('admin_incentive_edit', [$incentive->id]) }}" class="btn btn-sm btn-warning">
<span class="fa fa-edit"></span> {{ __('incentive.edit') }} <span class="fa fa-edit"></span> {{ __('incentive.edit') }}

View file

@ -290,7 +290,7 @@
<div>{{ __('navigation.downloadcenter') }}</div> <div>{{ __('navigation.downloadcenter') }}</div>
</a> </a>
</li> </li>
@php $sidenavIncentives = \App\Models\Incentive::activeForNavigation(); @endphp @php $sidenavIncentives = \App\Models\Incentive::activeForNavigation(Auth::user()); @endphp
@foreach ($sidenavIncentives as $sidenavIncentive) @foreach ($sidenavIncentives as $sidenavIncentive)
<li class="sidenav-item{{ Request::is('incentive/' . $sidenavIncentive->slug . '*') ? ' active' : '' }}"> <li class="sidenav-item{{ Request::is('incentive/' . $sidenavIncentive->slug . '*') ? ' active' : '' }}">
<a href="{{ route('user_incentive_teaser', [$sidenavIncentive->slug]) }}" <a href="{{ route('user_incentive_teaser', [$sidenavIncentive->slug]) }}"

View file

@ -25,6 +25,186 @@
.inc-header h5 { .inc-header h5 {
margin: 0; margin: 0;
font-weight: 700; font-weight: 700;
font-size: 1.35rem;
}
/* Groessere Ueberschriften fuer den Lesefluss */
.inc-page-title {
font-size: 1.9rem;
font-weight: 800;
margin-bottom: .25rem;
}
.inc-page-subtitle {
font-size: 1.1rem;
color: #6b7758;
font-weight: 600;
margin-bottom: 1.25rem;
}
.inc-section-heading {
font-size: 1.35rem;
font-weight: 700;
margin: .5rem 0 1rem;
}
.inc-info-tile h6,
.inc-points-section > 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 { .inc-header .stat-box {
@ -294,7 +474,7 @@
.inc-ranking-card .ranking-title { .inc-ranking-card .ranking-title {
font-weight: 700; font-weight: 700;
font-size: 1.1rem; font-size: 1.35rem;
color: #333; color: #333;
} }
@ -453,6 +633,18 @@
.inc-participate { .inc-participate {
padding: 1.5rem; padding: 1.5rem;
} }
.inc-join {
padding: 1.25rem;
}
.inc-join-title {
font-size: 1.45rem;
}
.inc-page-title {
font-size: 1.5rem;
}
} }
</style> </style>
@endsection @endsection
@ -695,45 +887,113 @@
@else @else
{{-- ============================================================ {{-- ============================================================
ANSICHT: NOCH KEINE ZUSTIMMUNG (Punkte laufen) ODER KEIN TEILNEHMER ANSICHT: NOCH KEINE ZUSTIMMUNG (Punkte laufen) ODER KEIN TEILNEHMER
Teilnahme-Block zuerst, damit er auch mobil sofort sichtbar ist.
============================================================ --}} ============================================================ --}}
@if ($participant) <div class="inc-join mb-4" id="incentive-join">
<div class="inc-header mb-3"> <div class="row align-items-start">
<div class="d-flex align-items-center justify-content-between flex-wrap"> <div class="col-lg-7 mb-3 mb-lg-0">
<div class="d-flex align-items-center mr-3 mb-2 mb-md-0"> <h2 class="inc-join-title">
<i class="ion ion-md-trophy mr-2" style="font-size: 1.5rem; color: #d7d700;"></i> <i class="ion ion-md-trophy mr-2"></i>
<h5>{{ $incentive->name }}</h5> {{ $participant ? __('incentive.join_confirm_title') : __('incentive.join_title') }}
</h2>
<p class="inc-join-intro">
{{ $participant ? __('incentive.pending_confirmation_banner') : __('incentive.participate_intro') }}
</p>
{{-- Hervorhebungen: worum es geht --}}
<div class="inc-join-highlights">
@if ((float) $incentive->amount_per_partner > 0)
<span class="inc-chip inc-chip-strong">{{ __('incentive.join_highlight_partner', ['amount' => \App\Services\Incentive\IncentivePayoutService::formatAmount((float) $incentive->amount_per_partner)]) }}</span>
@elseif ($incentive->points_partner_onetime > 0)
<span class="inc-chip inc-chip-strong">{{ __('incentive.join_highlight_points', ['points' => number_format($incentive->points_partner_onetime, 0, ',', '.')]) }}</span>
@endif
@foreach ($incentive->rankBonuses() as $bonusRank => $bonusAmount)
<span class="inc-chip">{{ __('incentive.join_highlight_rank', ['amount' => \App\Services\Incentive\IncentivePayoutService::formatAmount($bonusAmount), 'n' => $bonusRank]) }}</span>
@endforeach
@if ($incentive->usesAbos() && $incentive->points_abo_onetime > 0)
<span class="inc-chip">{{ __('incentive.join_highlight_abo', ['points' => number_format($incentive->points_abo_onetime, 0, ',', '.')]) }}</span>
@endif
<span class="inc-chip inc-chip-muted">
<i class="ion ion-md-calendar mr-1"></i>
{{ $incentive->qualification_start->format('d.m.Y') }} bis {{ $incentive->qualification_end->format('d.m.Y') }}
</span>
</div> </div>
<div class="d-flex align-items-center flex-wrap">
@if ($participant->rank) @if ($participant)
<div class="inc-join-stats">
@if ($participant->rank)
<div class="stat-box">
<div class="stat-label">{{ __('incentive.your_rank') }}</div>
<div class="stat-value">{{ $participant->rank }}</div>
</div>
@endif
<div class="stat-box"> <div class="stat-box">
<div class="stat-label">{{ __('incentive.your_rank') }}</div> <div class="stat-label">{{ $incentive->ranksByPartnerCount() ? __('incentive.new_partners_short') : __('incentive.total_points') }}</div>
<div class="stat-value">{{ $participant->rank }}</div> <div class="stat-value">{{ number_format($participant->total_points, 0, ',', '.') }}</div>
</div> </div>
@endif @if ($participantPayout)
<div class="stat-box"> <div class="stat-box">
<div class="stat-label">{{ $incentive->ranksByPartnerCount() ? __('incentive.new_partners_short') : __('incentive.total_points') }}</div> <div class="stat-label">{{ __('incentive.payout_so_far') }}</div>
<div class="stat-value">{{ number_format($participant->total_points, 0, ',', '.') }}</div> <div class="stat-value">{{ \App\Services\Incentive\IncentivePayoutService::formatAmount($participantPayout['total']) }}</div>
</div> </div>
@if ($participantPayout)
<div class="stat-box">
<div class="stat-label">{{ __('incentive.payout_so_far') }}</div>
<div class="stat-value">{{ \App\Services\Incentive\IncentivePayoutService::formatAmount($participantPayout['total']) }}</div>
</div>
@endif
<div>
@if ($participant->is_qualified)
<span class="badge-qual">{{ __('incentive.qualified') }}</span>
@else
<span class="badge-open">{{ __('incentive.not_yet_qualified') }}</span>
@endif @endif
</div> </div>
@endif
</div>
<div class="col-lg-5">
<div class="inc-join-form">
@if ($incentive->isActive())
@if (!empty($participateHasTrackableAbos))
<div class="inc-join-hint mb-3">
<i class="ion ion-md-information-circle mr-1"></i>
{{ __('incentive.participate_abo_hint') }}
</div>
@endif
<form action="{{ route('user_incentive_participate', [$incentive->slug]) }}" method="POST">
@csrf
<div class="custom-control custom-checkbox mb-3">
<input type="checkbox" class="custom-control-input" id="accept_terms"
name="accept_terms" value="1" required>
<label class="custom-control-label" for="accept_terms">
{{ __('incentive.accept_terms') }}
</label>
</div>
<button type="submit" class="btn btn-participate btn-block btn-lg">
<i class="ion ion-md-checkmark mr-1"></i>
{{ __('incentive.participate_now') }}
</button>
</form>
@else
<p class="mb-0">{{ __('incentive.not_active') }}</p>
@endif
@if ($incentive->getLang('terms'))
<a href="#terms" data-toggle="collapse" class="inc-join-terms-link" aria-expanded="false" aria-controls="terms">
<i class="ion ion-md-document mr-1"></i>{{ __('incentive.join_terms_toggle') }}
<i class="ion ion-md-chevron-down ml-1"></i>
</a>
@endif
</div> </div>
</div> </div>
</div> </div>
<div class="pending-banner mb-4">
<i class="ion ion-md-information-circle mr-1"></i> @if ($incentive->getLang('terms'))
{{ __('incentive.pending_confirmation_banner') }} <div id="terms" class="collapse">
</div> <div class="inc-join-terms mt-3">
<div class="small">{!! $incentive->getLang('terms') !!}</div>
</div>
</div>
@endif
</div>
<hr class="inc-divider">
{{-- Danach alles, was auch nach der Bestätigung angezeigt wird --}}
<h2 class="inc-page-title">
<i class="ion ion-md-trophy mr-2" style="color: #d7d700;"></i>{{ $incentive->name }}
</h2>
@if ($incentive->getLang('subtitle'))
<p class="inc-page-subtitle">{{ $incentive->getLang('subtitle') }}</p>
@endif @endif
@if ($incentive->image) @if ($incentive->image)
@ -744,19 +1004,15 @@
</div> </div>
@endif @endif
<h3 class="font-weight-bold mb-3">
<i class="ion ion-md-trophy mr-2" style="color: #d7d700;"></i>{{ $incentive->name }}
</h3>
@if ($incentive->getLang('description')) @if ($incentive->getLang('description'))
<div class="inc-intro mb-4"> <div class="inc-intro mb-4">
{!! $incentive->getLang('description') !!} {!! $incentive->getLang('description') !!}
</div> </div>
@endif @endif
<h3 class="inc-section-heading">{{ __('incentive.section_info') }}</h3>
<div class="row"> <div class="row">
<div class="col-lg-8"> <div class="col-lg-6">
{{-- Qualifikationszeitraum --}} {{-- Qualifikationszeitraum --}}
<div class="card inc-info-tile shadow-sm mb-4"> <div class="card inc-info-tile shadow-sm mb-4">
<div class="card-body"> <div class="card-body">
@ -770,160 +1026,106 @@
<li class="mb-1"> <li class="mb-1">
<strong>{{ __('incentive.qualification_period') }}:</strong> <strong>{{ __('incentive.qualification_period') }}:</strong>
{{ $incentive->qualification_start->format('d.m.Y') }} {{ $incentive->qualification_start->format('d.m.Y') }}
– {{ $incentive->qualification_end->format('d.m.Y') }} bis {{ $incentive->qualification_end->format('d.m.Y') }}
</li>
<li class="text-muted">
<strong>{{ __('incentive.calculation_period') }}:</strong>
{{ __('incentive.calculation_period_hint', ['date' => $incentive->calculation_end->format('d.m.Y')]) }}
</li> </li>
@if (!$incentive->calculation_end->isSameDay($incentive->qualification_end))
<li class="text-muted">
<strong>{{ __('incentive.calculation_period') }}:</strong>
{{ __('incentive.calculation_period_hint', ['date' => $incentive->calculation_end->format('d.m.Y')]) }}
</li>
@endif
</ul> </ul>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div>
<div class="col-lg-6">
@if ($incentive->hasCashPayout() && !$incentive->hasMinimumRequirements()) @if ($incentive->hasCashPayout() && !$incentive->hasMinimumRequirements())
@include('user.incentive._payout_tile', ['incentive' => $incentive]) @include('user.incentive._payout_tile', ['incentive' => $incentive])
@else @else
{{-- Mindestqualifikation --}} {{-- Mindestqualifikation --}}
<div class="card inc-info-tile shadow-sm mb-4"> <div class="card inc-info-tile shadow-sm mb-4">
<div class="card-body"> <div class="card-body">
<div class="d-flex align-items-start"> <div class="d-flex align-items-start">
<div class="inc-tile-icon inc-tile-icon-plane mr-3"> <div class="inc-tile-icon inc-tile-icon-plane mr-3">
<i class="ion ion-md-airplane"></i> <i class="ion ion-md-airplane"></i>
</div>
<div>
<h6 class="font-weight-bold mb-2">{{ __('incentive.section_min_qual') }}</h6>
<p class="text-muted small mb-2">{{ __('incentive.min_qual_intro') }}</p>
<ul class="list-unstyled mb-2">
<li class="mb-1">
<strong style="color: #6b7758;">{{ $incentive->min_direct_partners }}</strong>
{{ __('incentive.min_partners_label') }}
</li>
<li>
<strong style="color: #6b7758;">{{ $incentive->min_customer_abos }}</strong>
{{ __('incentive.min_abos_label') }}
</li>
</ul>
<p class="text-muted small mb-0">
<i class="ion ion-md-information-circle mr-1"></i>
{{ __('incentive.min_qual_ranking_hint') }}
</p>
</div>
</div>
</div>
</div> </div>
@endif <div>
<h6 class="font-weight-bold mb-2">{{ __('incentive.section_min_qual') }}</h6>
{{-- Punkte-System --}} <p class="text-muted small mb-2">{{ __('incentive.min_qual_intro') }}</p>
<div class="inc-points-section mb-4"> <ul class="list-unstyled mb-2">
<h6 class="font-weight-bold mb-3"> <li class="mb-1">
<i class="ion ion-md-star mr-1" style="color: #d7d700;"></i> <strong style="color: #6b7758;">{{ $incentive->min_direct_partners }}</strong>
{{ __('incentive.section_points') }} {{ __('incentive.min_partners_label') }}
</h6> </li>
<div class="row"> <li>
<div class="{{ $incentive->usesAbos() ? 'col-md-6 mb-3 mb-md-0' : 'col-md-12' }}"> <strong style="color: #6b7758;">{{ $incentive->min_customer_abos }}</strong>
<div class="inc-point-card inc-point-card-partner"> {{ __('incentive.min_abos_label') }}
<h6 class="font-weight-bold mb-1" style="font-size: .9rem;"> </li>
<i class="ion ion-md-person-add mr-1"></i> </ul>
{{ __('incentive.points_partners_title') }} <p class="text-muted small mb-0">
</h6> <i class="ion ion-md-information-circle mr-1"></i>
<div class="inc-point-val mb-2"> {{ __('incentive.min_qual_ranking_hint') }}
{{ number_format($incentive->points_partner_onetime, 0, ',', '.') }} </p>
<span class="small font-weight-normal">{{ __('incentive.points_short') }}</span>
</div> </div>
<ul class="pl-3 mb-0">
<li>{{ __('incentive.points_onetime_label') }}</li>
<li>{{ __('incentive.points_starter_package_label') }}</li>
@if ($incentive->includesSalesPoints())
<li>{{ __('incentive.points_partner_boost') }}</li>
@endif
@include('user.incentive._payout_bullets', ['incentive' => $incentive])
</ul>
</div>
</div>
@if ($incentive->usesAbos())
<div class="col-md-6">
<div class="inc-point-card inc-point-card-abo">
<h6 class="font-weight-bold mb-1" style="font-size: .9rem;">
<i class="ion ion-md-ribbon mr-1"></i>
{{ __('incentive.points_abos_title') }}
</h6>
<div class="inc-point-val mb-2">
{{ number_format($incentive->points_abo_onetime, 0, ',', '.') }}
<span class="small font-weight-normal">{{ __('incentive.points_short') }}</span>
</div>
<ul class="pl-3 mb-0">
<li>{{ __('incentive.points_onetime_label') }}</li>
@if ($incentive->includesSalesPoints())
<li>{{ __('incentive.points_abo_boost') }}</li>
@endif
</ul>
</div>
</div>
@endif
</div>
</div>
</div>
<div class="col-lg-4">
<div class="inc-participate mb-4">
<h5>
<i class="ion ion-md-log-in mr-1"></i>
{{ __('incentive.participate_title') }}
</h5>
@if ($incentive->isActive())
<p class="small mb-3" style="opacity: .85;">{{ __('incentive.participate_intro') }}</p>
@if (!empty($participateHasTrackableAbos))
<div class="mb-3 p-2 small" style="background: rgba(255,255,255,0.12); border-radius: .5rem;">
<i class="ion ion-md-information-circle mr-1"></i>
{{ __('incentive.participate_abo_hint') }}
</div>
@endif
<form action="{{ route('user_incentive_participate', [$incentive->slug]) }}" method="POST">
@csrf
<div class="custom-control custom-checkbox mb-3">
<input type="checkbox" class="custom-control-input" id="accept_terms"
name="accept_terms" value="1" required>
<label class="custom-control-label" for="accept_terms">
{{ __('incentive.accept_terms') }}
@if ($incentive->getLang('terms'))
(<a href="#terms" data-toggle="collapse">{{ __('incentive.show_terms') }}</a>)
@endif
</label>
</div>
<button type="submit" class="btn btn-participate btn-block">
<i class="ion ion-md-checkmark mr-1"></i>
{{ __('incentive.participate_now') }}
</button>
</form>
@else
<p class="mb-0" style="opacity: .8;">{{ __('incentive.not_active') }}</p>
@endif
</div>
@if ($incentive->getLang('terms'))
<div class="card inc-terms-toggle mb-4">
<div class="card-header">
<a href="#terms" data-toggle="collapse" class="d-flex align-items-center">
<i class="ion ion-md-document mr-2"></i>
<strong>{{ __('incentive.terms') }}</strong>
<i class="ion ion-md-chevron-down ml-auto"></i>
</a>
</div>
<div id="terms" class="collapse">
<div class="card-body">
<div class="small">{!! $incentive->getLang('terms') !!}</div>
</div> </div>
</div> </div>
</div> </div>
@endif @endif
</div> </div>
</div> </div>
{{-- Punkte-System --}}
<div class="inc-points-section mb-4">
<h6 class="font-weight-bold mb-3">
<i class="ion ion-md-star mr-1" style="color: #d7d700;"></i>
{{ __('incentive.section_points') }}
</h6>
<div class="row">
<div class="{{ $incentive->usesAbos() ? 'col-md-6 mb-3 mb-md-0' : 'col-md-12' }}">
<div class="inc-point-card inc-point-card-partner">
<h6 class="font-weight-bold mb-1" style="font-size: .9rem;">
<i class="ion ion-md-person-add mr-1"></i>
{{ __('incentive.points_partners_title') }}
</h6>
<div class="inc-point-val mb-2">
{{ number_format($incentive->points_partner_onetime, 0, ',', '.') }}
<span class="small font-weight-normal">{{ __('incentive.points_short') }}</span>
</div>
<ul class="pl-3 mb-0">
<li>{{ __('incentive.points_onetime_label') }}</li>
<li>{{ __('incentive.points_starter_package_label') }}</li>
@if ($incentive->includesSalesPoints())
<li>{{ __('incentive.points_partner_boost') }}</li>
@endif
@include('user.incentive._payout_bullets', ['incentive' => $incentive])
</ul>
</div>
</div>
@if ($incentive->usesAbos())
<div class="col-md-6">
<div class="inc-point-card inc-point-card-abo">
<h6 class="font-weight-bold mb-1" style="font-size: .9rem;">
<i class="ion ion-md-ribbon mr-1"></i>
{{ __('incentive.points_abos_title') }}
</h6>
<div class="inc-point-val mb-2">
{{ number_format($incentive->points_abo_onetime, 0, ',', '.') }}
<span class="small font-weight-normal">{{ __('incentive.points_short') }}</span>
</div>
<ul class="pl-3 mb-0">
<li>{{ __('incentive.points_onetime_label') }}</li>
<li>{{ __('incentive.points_abo_direct') }}</li>
@if ($incentive->includesSalesPoints())
<li>{{ __('incentive.points_abo_boost') }}</li>
@endif
</ul>
</div>
</div>
@endif
</div>
</div>
@endif @endif
{{-- ============================================================ {{-- ============================================================

View file

@ -0,0 +1,187 @@
<?php
use App\Models\Incentive;
use App\Models\IncentiveParticipant;
use App\Models\UserAccount;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
/**
* E1: "Nur fuer VIP sichtbar" blendet ein aktives Incentive fuer normale Berater aus
* (Navigation, Dashboard, Teaser, Rangliste, Details), VIP-Konten sehen es zur Abstimmung.
* Dazu der Umbau der Ranglisten-Seite: Teilnahme-Block oben, solange nicht bestaetigt.
* Berater-Routen sind host-abhaengig (CRM-Host).
*/
beforeEach(function () {
$this->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' => '<p>Bedingungen Text</p>']);
$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'));
});
});

View file

@ -16,6 +16,7 @@ it('legt die Challenge als Entwurf mit Praemie und Wertung nach Neupartnern an',
$incentive = Incentive::where('slug', RecruitingChallenge2026Seeder::SLUG)->firstOrFail(); $incentive = Incentive::where('slug', RecruitingChallenge2026Seeder::SLUG)->firstOrFail();
expect($incentive->isDraft())->toBeTrue() expect($incentive->isDraft())->toBeTrue()
->and($incentive->vip_only)->toBeTrue()
->and($incentive->name)->toBe('Recruiting Challenge 2026/27') ->and($incentive->name)->toBe('Recruiting Challenge 2026/27')
->and($incentive->qualification_start->toDateString())->toBe('2026-10-01') ->and($incentive->qualification_start->toDateString())->toBe('2026-10-01')
->and($incentive->qualification_end->toDateString())->toBe('2027-01-31') ->and($incentive->qualification_end->toDateString())->toBe('2027-01-31')