Compare commits
6 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6780565586 | |||
| 7bc551ca1f | |||
| 9ad69a3218 | |||
| ae59679150 | |||
| 4c944aa15d | |||
| fe1a9478f4 |
111 changed files with 5448 additions and 456 deletions
|
|
@ -111,3 +111,8 @@ STRIPE_KEY=
|
|||
STRIPE_WEBHOOK_SECRET=
|
||||
# test | live — muss zum Key-Paar passen
|
||||
STRIPE_MODE=test
|
||||
|
||||
# Cloudflare Turnstile (Captcha Kontaktformular, kostenlos): https://dash.cloudflare.com → Turnstile
|
||||
# Lokal/Test: Cloudflare-Testschlüssel, die immer bestehen
|
||||
TURNSTILE_SITE_KEY=1x00000000000000000000AA
|
||||
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
|
||||
|
|
|
|||
72
app/Console/Commands/RecognitionsFreeze.php
Normal file
72
app/Console/Commands/RecognitionsFreeze.php
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Team\RecognitionService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class RecognitionsFreeze extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'recognitions:freeze
|
||||
{--month= : Nur diesen Monat einfrieren (mit --year)}
|
||||
{--year= : Nur dieses Jahr einfrieren (mit --month)}
|
||||
{--force : Bereits eingefrorene Monate neu berechnen}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Friert die Anerkennungen abgeschlossener Monate ein (ab dem 3. des Folgemonats)';
|
||||
|
||||
/**
|
||||
* Ohne Optionen werden alle noch offenen Monate seit dem ersten
|
||||
* Anerkennungsmonat nachgezogen — der tägliche Lauf holt damit auch einen
|
||||
* verpassten Stichtag nach. Ein neu gebauter Snapshot rechnet mit dem Stand
|
||||
* des Ausführungstags; `--force` deshalb nur bewusst einsetzen.
|
||||
*/
|
||||
public function handle(RecognitionService $recognitions): int
|
||||
{
|
||||
$months = $this->monthsToFreeze();
|
||||
$force = (bool) $this->option('force');
|
||||
$stored = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($months as ['month' => $month, 'year' => $year]) {
|
||||
if (! RecognitionService::isFreezable($month, $year)) {
|
||||
$this->line(sprintf('%02d/%d: noch nicht festschreibbar (ab %s)', $month, $year, RecognitionService::freezeDate($month, $year)->format('d.m.Y')));
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($recognitions->freeze($month, $year, $force)) {
|
||||
$this->info(sprintf('%02d/%d: eingefroren', $month, $year));
|
||||
$stored++;
|
||||
} else {
|
||||
$skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("Fertig. Eingefroren: {$stored}, uebersprungen: {$skipped}");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{month: int, year: int}>
|
||||
*/
|
||||
private function monthsToFreeze(): array
|
||||
{
|
||||
if ($this->option('month') && $this->option('year')) {
|
||||
return [['month' => max(1, min(12, (int) $this->option('month'))), 'year' => (int) $this->option('year')]];
|
||||
}
|
||||
|
||||
return RecognitionService::closedMonths();
|
||||
}
|
||||
}
|
||||
|
|
@ -98,6 +98,10 @@ class Kernel extends ConsoleKernel
|
|||
// Incentive: Punkteberechnung täglich nach business:store-optimized
|
||||
$schedule->command('incentive:calculate')->dailyAt('05:00');
|
||||
|
||||
// Anerkennungen: abgeschlossene Monate ab dem 3. des Folgemonats einfrieren
|
||||
// (idempotent; nach Provisionslauf, Repairs und Incentive)
|
||||
$schedule->command('recognitions:freeze')->dailyAt('06:15');
|
||||
|
||||
// Cleanup old log files weekly (keeps logs for 30 days)
|
||||
$schedule->command('logs:cleanup --days=30')->weekly()->sundays()->at('05:00');
|
||||
|
||||
|
|
|
|||
181
app/Http/Controllers/Admin/CustomerPriorityController.php
Normal file
181
app/Http/Controllers/Admin/CustomerPriorityController.php
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ShoppingUser;
|
||||
use App\Models\ShoppingUserEmailChange;
|
||||
use App\Services\CustomerEmailChangeService;
|
||||
use App\Services\CustomerPriority;
|
||||
use App\Services\CustomerPriorityOverviewService;
|
||||
use App\User;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
/**
|
||||
* Admin overview "Kundenhoheit": everything where the customer sovereignty still needs a
|
||||
* decision, so the work does not depend on info mails that get lost.
|
||||
*/
|
||||
class CustomerPriorityController extends Controller
|
||||
{
|
||||
public const TABS = ['reviews', 'conflicts', 'email-changes'];
|
||||
|
||||
public function __construct(private CustomerPriorityOverviewService $overview)
|
||||
{
|
||||
$this->middleware('admin');
|
||||
}
|
||||
|
||||
public function index(string $tab = 'reviews'): View
|
||||
{
|
||||
abort_unless(in_array($tab, self::TABS, true), 404);
|
||||
|
||||
return view('admin.customer_priority.index', [
|
||||
'tab' => $tab,
|
||||
'counts' => $this->overview->counts(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function reviewsDatatable(): JsonResponse
|
||||
{
|
||||
$back = route('admin_customer_priority', ['reviews']);
|
||||
|
||||
return \DataTables::eloquent($this->overview->reviewsQuery())
|
||||
->addColumn('created', fn (ShoppingUser $customer): string => $customer->created_at->format('d.m.Y H:i')
|
||||
.'<br><small class="text-muted">'.$customer->created_at->diffForHumans().'</small>')
|
||||
->addColumn('customer', fn (ShoppingUser $customer): string => $this->customerCell($customer))
|
||||
->addColumn('member', fn (ShoppingUser $customer): string => $this->memberCell($customer->member))
|
||||
->addColumn('shop', fn (ShoppingUser $customer): string => $this->memberCell($customer->shopping_order?->user_shop?->user))
|
||||
->addColumn('matches', fn (ShoppingUser $customer): string => (string) count($customer->getNotice('like') ?: []))
|
||||
->addColumn('action', function (ShoppingUser $customer) use ($back): string {
|
||||
return '<button type="button" class="btn btn-sm btn-primary mb-1" data-toggle="modal" data-target="#modals-load-content"'
|
||||
.' data-id="'.$customer->id.'" data-action="shopping-user-is-like-member" data-back="'.e($back).'"'
|
||||
.' data-modal="modal-xl" data-route="'.route('modal_load').'"><i class="fa fa-edit"></i> Prüfen & zuordnen</button> '
|
||||
.'<form method="POST" action="'.route('admin_customer_priority_keep', [$customer->id]).'" class="d-inline"'
|
||||
.' onsubmit="return confirm(\'Prüffall schließen? Berater und Kundennummer bleiben unverändert.\')">'
|
||||
.csrf_field()
|
||||
.'<button type="submit" class="btn btn-sm btn-outline-secondary mb-1">Zuordnung beibehalten</button></form>';
|
||||
})
|
||||
->filterColumn('customer', function ($query, $keyword): void {
|
||||
$query->where(function ($query) use ($keyword) {
|
||||
$query->where('billing_email', 'like', '%'.$keyword.'%')
|
||||
->orWhere('billing_lastname', 'like', '%'.$keyword.'%')
|
||||
->orWhere('billing_firstname', 'like', '%'.$keyword.'%')
|
||||
->orWhere('billing_zipcode', 'like', '%'.$keyword.'%');
|
||||
});
|
||||
})
|
||||
->orderColumn('created', 'shopping_users.created_at $1')
|
||||
->rawColumns(['created', 'customer', 'member', 'shop', 'action'])
|
||||
->make(true);
|
||||
}
|
||||
|
||||
public function conflictsDatatable(): JsonResponse
|
||||
{
|
||||
$back = route('admin_customer_priority', ['conflicts']);
|
||||
|
||||
return \DataTables::of($this->overview->conflicts())
|
||||
->addColumn('customer', fn (array $row): string => '<strong>'.e($row['name']).'</strong><br>'.e($row['email']))
|
||||
->addColumn('groups', function (array $row) use ($back): string {
|
||||
$html = '';
|
||||
foreach ($row['groups'] as $group) {
|
||||
$html .= '<div class="mb-2">'
|
||||
.($group['oldest'] ? '<span class="badge badge-success">älteste Zuordnung</span> ' : '<span class="badge badge-warning">weitere Zuordnung</span> ')
|
||||
.$this->memberCell($group['member'])
|
||||
.' <span class="text-muted">· Kunden-Nr. '.e($group['number']).' · '.$group['entries'].' Einträge · zuletzt '.e(date('d.m.Y', strtotime($group['last_at']))).'</span> '
|
||||
.'<a href="'.route('admin_customer_detail', [$group['latest_id']]).'" class="btn btn-xs btn-outline-primary"><i class="fa fa-eye"></i></a> '
|
||||
.'<button type="button" class="btn btn-xs btn-outline-secondary" data-toggle="modal" data-target="#modals-load-content"'
|
||||
.' data-id="'.$group['latest_id'].'" data-action="shopping-user-change-member" data-view="customer" data-back="'.e($back).'"'
|
||||
.' data-route="'.route('modal_load').'"><i class="fa fa-edit"></i> Berater zuweisen</button>'
|
||||
.'</div>';
|
||||
}
|
||||
|
||||
return $html;
|
||||
})
|
||||
->addColumn('last', fn (array $row): string => date('d.m.Y', strtotime($row['last_at'])))
|
||||
->filter(function ($instance): void {
|
||||
$keyword = mb_strtolower((string) request('search.value'));
|
||||
if ($keyword !== '') {
|
||||
$instance->collection = $instance->collection->filter(
|
||||
fn (array $row) => str_contains($row['email'], $keyword) || str_contains(mb_strtolower($row['name']), $keyword)
|
||||
);
|
||||
}
|
||||
})
|
||||
->rawColumns(['customer', 'groups'])
|
||||
->make(true);
|
||||
}
|
||||
|
||||
public function emailChangesDatatable(): JsonResponse
|
||||
{
|
||||
return \DataTables::eloquent($this->overview->emailChangesQuery())
|
||||
->addColumn('created', fn (ShoppingUserEmailChange $change): string => $change->created_at->format('d.m.Y H:i'))
|
||||
->addColumn('customer', fn (ShoppingUserEmailChange $change): string => $change->shopping_user
|
||||
? $this->customerCell($change->shopping_user)
|
||||
: '<span class="text-muted">Kunde #'.$change->shopping_user_id.' gelöscht</span>')
|
||||
->addColumn('change', fn (ShoppingUserEmailChange $change): string => e($change->old_email).' <i class="fa fa-arrow-right"></i> <strong>'.e($change->new_email).'</strong>')
|
||||
->addColumn('requested_by', fn (ShoppingUserEmailChange $change): string => $this->memberCell($change->requested_by))
|
||||
->addColumn('status', function (ShoppingUserEmailChange $change): string {
|
||||
if ($change->blocked_at) {
|
||||
return '<span class="badge badge-danger">abgelehnt (Kundenhoheit)</span><br><small class="text-muted">Adresse gehört einem anderen Berater oder Beraterkonto</small>';
|
||||
}
|
||||
|
||||
return '<span class="badge badge-info">wartet auf Bestätigung</span><br><small class="text-muted">Link gültig bis '.$change->expires_at->format('d.m.Y H:i').'</small>';
|
||||
})
|
||||
->addColumn('action', function (ShoppingUserEmailChange $change): string {
|
||||
if ($change->blocked_at) {
|
||||
return '<form method="POST" action="'.route('admin_customer_priority_email_resolve', [$change->id]).'" class="d-inline">'
|
||||
.csrf_field().'<button type="submit" class="btn btn-sm btn-outline-success"><i class="fa fa-check"></i> Erledigt</button></form>';
|
||||
}
|
||||
|
||||
return '<form method="POST" action="'.route('admin_customer_priority_email_discard', [$change->id]).'" class="d-inline"'
|
||||
.' onsubmit="return confirm(\'Anfrage verwerfen? Der Link in der Bestätigungsmail funktioniert dann nicht mehr.\')">'
|
||||
.csrf_field().'<button type="submit" class="btn btn-sm btn-outline-danger">Verwerfen</button></form>';
|
||||
})
|
||||
->orderColumn('created', 'shopping_user_email_changes.created_at $1')
|
||||
->rawColumns(['customer', 'change', 'requested_by', 'status', 'action'])
|
||||
->make(true);
|
||||
}
|
||||
|
||||
public function keepAssignment(int $id): RedirectResponse
|
||||
{
|
||||
$customer = ShoppingUser::where('is_like', true)->findOrFail($id);
|
||||
CustomerPriority::keepAssignment($customer);
|
||||
\Session()->flash('alert-success', 'Prüffall geschlossen, Zuordnung beibehalten.');
|
||||
|
||||
return redirect(route('admin_customer_priority', ['reviews']));
|
||||
}
|
||||
|
||||
public function resolveEmailChange(int $id, CustomerEmailChangeService $service): RedirectResponse
|
||||
{
|
||||
$service->resolveBlocked(ShoppingUserEmailChange::blockedOpen()->findOrFail($id), \Auth::id());
|
||||
\Session()->flash('alert-success', 'Als erledigt markiert.');
|
||||
|
||||
return redirect(route('admin_customer_priority', ['email-changes']));
|
||||
}
|
||||
|
||||
public function discardEmailChange(int $id, CustomerEmailChangeService $service): RedirectResponse
|
||||
{
|
||||
$service->discard(ShoppingUserEmailChange::pending()->findOrFail($id));
|
||||
\Session()->flash('alert-success', 'Anfrage verworfen.');
|
||||
|
||||
return redirect(route('admin_customer_priority', ['email-changes']));
|
||||
}
|
||||
|
||||
private function customerCell(ShoppingUser $customer): string
|
||||
{
|
||||
return '<a href="'.route('admin_customer_detail', [$customer->id]).'"><strong>'
|
||||
.e(trim($customer->billing_firstname.' '.$customer->billing_lastname)).'</strong></a>'
|
||||
.($customer->number ? ' <span class="text-muted">#'.e($customer->number).'</span>' : '')
|
||||
.'<br>'.e($customer->billing_email)
|
||||
.'<br><small class="text-muted">'.e($customer->billing_zipcode.' '.$customer->billing_city).'</small>';
|
||||
}
|
||||
|
||||
private function memberCell(?User $member): string
|
||||
{
|
||||
if (! $member) {
|
||||
return '<span class="text-muted">–</span>';
|
||||
}
|
||||
|
||||
return '<a href="'.route('admin_lead_edit', [$member->id]).'">'.e($member->getFullName(false) ?: $member->email).'</a>'
|
||||
.' <span class="text-muted">#'.e($member->number).'</span>';
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ use Illuminate\Foundation\Auth\RegistersUsers;
|
|||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
class RegisterController extends Controller
|
||||
{
|
||||
|
|
@ -58,7 +59,7 @@ class RegisterController extends Controller
|
|||
return Validator::make($data, [
|
||||
'name' => 'required|string|max:255',
|
||||
'email' => 'required|string|email:filter|max:255|unique:users',
|
||||
'password' => 'required|string|min:6|confirmed',
|
||||
'password' => ['required', 'string', 'confirmed', Password::defaults()],
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ namespace App\Http\Controllers;
|
|||
use App\Exceptions\GdprDeletionException;
|
||||
use App\Models\ShoppingUser;
|
||||
use App\Repositories\CustomerRepository;
|
||||
use App\Services\CustomerEmailChangeService;
|
||||
use App\Services\CustomerPriority;
|
||||
use App\Services\GdprDeletionService;
|
||||
use App\Services\HTMLHelper;
|
||||
|
|
@ -146,6 +147,12 @@ class CustomerController extends Controller
|
|||
CustomerPriority::newMemberForCustomer($shopping_user, $data['change_member_id'], $data['customer_set_member_for']);
|
||||
\Session()->flash('alert-save', true);
|
||||
|
||||
// Aus der Übersicht Kundenhoheit aufgerufen: dorthin zurück.
|
||||
$back = $data['back'] ?? null;
|
||||
if ($back && str_starts_with($back, route('admin_customer_priority'))) {
|
||||
return redirect($back);
|
||||
}
|
||||
|
||||
return redirect(route('admin_customer_detail', [$shopping_user->id]));
|
||||
}
|
||||
if ($data['action'] === 'shopping-user-store') {
|
||||
|
|
@ -194,6 +201,14 @@ class CustomerController extends Controller
|
|||
$data['same_as_billing'] = isset($data['same_as_billing']) ? true : false;
|
||||
$data['shipping_country_id'] = isset($data['shipping_country_id']) ? $data['shipping_country_id'] : $data['billing_country_id'];
|
||||
CustomerPriority::checkChangeOne($shopping_user, $data, true);
|
||||
|
||||
// Admin ändert direkt (Support); alle Einträge des Kunden und seine Abos ziehen mit.
|
||||
$emailChangeService = app(CustomerEmailChangeService::class);
|
||||
if ($emailChangeService->changesAddress($shopping_user, $data['billing_email'] ?? null)) {
|
||||
$emailChangeService->changeDirectly($shopping_user, $data['billing_email'], Auth::id());
|
||||
unset($data['billing_email']);
|
||||
}
|
||||
|
||||
$shopping_user->fill($data);
|
||||
$shopping_user->save();
|
||||
Shop::newUserOrder($shopping_user->number);
|
||||
|
|
|
|||
|
|
@ -48,9 +48,12 @@ class ModalController extends Controller
|
|||
$current = ShoppingUser::find($data['id']); // current user form order
|
||||
$possibles = [];
|
||||
if ($current->is_like) {
|
||||
$likes = $current->getNotice('like');
|
||||
// Treffer können inzwischen gelöscht sein (alte Prüffälle).
|
||||
$likes = $current->getNotice('like') ?: [];
|
||||
foreach ($likes as $like_id => $number) {
|
||||
$possibles[] = ShoppingUser::find($like_id);
|
||||
if ($possible = ShoppingUser::find($like_id)) {
|
||||
$possibles[] = $possible;
|
||||
}
|
||||
}
|
||||
}
|
||||
$ret = view('admin.modal.is_like_member', compact('current', 'possibles', 'data'))->render();
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
|
|||
use App\Models\ShoppingUser;
|
||||
use App\Repositories\CustomerRepository;
|
||||
use App\Services\AboHelper;
|
||||
use App\Services\CustomerEmailChangeService;
|
||||
use App\Services\CustomerPriority;
|
||||
use App\Services\HTMLHelper;
|
||||
use App\Services\Shop;
|
||||
|
|
@ -229,7 +230,24 @@ class CustomerController extends Controller
|
|||
if ($shopping_user->isGdprDeleted()) {
|
||||
return $this->redirectGdprLocked($shopping_user);
|
||||
}
|
||||
// Neue E-Mail wird erst nach Bestätigung durch den Kunden aktiv (Login + Abo) und nur,
|
||||
// wenn sie keinem Beraterkonto oder Kunden eines anderen Beraters gehört (Kundenhoheit).
|
||||
$emailChangeService = app(CustomerEmailChangeService::class);
|
||||
if ($emailChangeService->requiresConfirmation($shopping_user, $data['billing_email'] ?? null)) {
|
||||
$newEmail = trim($data['billing_email']);
|
||||
unset($data['billing_email']);
|
||||
if ($emailChangeService->ownershipConflict($shopping_user, $newEmail)) {
|
||||
$emailChangeService->reportConflict($shopping_user, $newEmail, \Auth::id());
|
||||
\Session()->flash('custom-error', __('customer.email_change_blocked', ['email' => $newEmail]));
|
||||
} else {
|
||||
$emailChangeService->request($shopping_user, $newEmail, \Auth::id());
|
||||
\Session()->flash('custom-error', __('customer.email_change_requested', ['email' => $newEmail]));
|
||||
}
|
||||
}
|
||||
|
||||
// Adresse (Nachname + PLZ) läuft weiter über die bestehende Kundenhoheit-Prüfung.
|
||||
CustomerPriority::checkChangeOne($shopping_user, $data, true);
|
||||
|
||||
$shopping_user->fill($data);
|
||||
$shopping_user->save();
|
||||
|
||||
|
|
|
|||
53
app/Http/Controllers/User/RecognitionController.php
Normal file
53
app/Http/Controllers/User/RecognitionController.php
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\User;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Recognition\RecognitionFilterRequest;
|
||||
use App\Services\Team\RecognitionService;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* Anerkennungen im Salescenter: oben die Ranglisten des gewählten
|
||||
* abgeschlossenen Monats (Standard: Vormonat), darunter der Live-Stand des
|
||||
* laufenden Monats. Sichtbar für alle Berater mit aktivem Konto.
|
||||
*/
|
||||
class RecognitionController extends Controller
|
||||
{
|
||||
public function __construct(private RecognitionService $recognitions)
|
||||
{
|
||||
$this->middleware('active.account');
|
||||
}
|
||||
|
||||
public function index(RecognitionFilterRequest $request): View
|
||||
{
|
||||
$closedMonths = RecognitionService::closedMonths();
|
||||
$selected = $this->selectedMonth((string) $request->validated('period'), $closedMonths);
|
||||
|
||||
return view('user.recognitions.index', [
|
||||
'periods' => $closedMonths,
|
||||
'selected' => $selected,
|
||||
'recognitions' => $selected
|
||||
? $this->recognitions->recognitions($selected['month'], $selected['year'])
|
||||
: null,
|
||||
'live' => $this->recognitions->liveRankings((int) now()->month, (int) now()->year),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nur abgeschlossene Monate sind wählbar; alles andere fällt auf den Vormonat zurück.
|
||||
*
|
||||
* @param array<int, array{month: int, year: int}> $closedMonths
|
||||
* @return array{month: int, year: int}|null
|
||||
*/
|
||||
private function selectedMonth(string $period, array $closedMonths): ?array
|
||||
{
|
||||
foreach ($closedMonths as $closedMonth) {
|
||||
if ($period === sprintf('%d-%02d', $closedMonth['year'], $closedMonth['month'])) {
|
||||
return $closedMonth;
|
||||
}
|
||||
}
|
||||
|
||||
return $closedMonths[0] ?? null;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,39 +1,38 @@
|
|||
<?php
|
||||
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Auth;
|
||||
use Illuminate\Contracts\View\Factory;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Routing\Redirector;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Validator;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Illuminate\View\View;
|
||||
use Request;
|
||||
|
||||
use Validator;
|
||||
|
||||
class UserUpdatePasswordController extends Controller
|
||||
{
|
||||
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('auth');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
|
||||
* @return Factory|View
|
||||
*/
|
||||
public function updatePassword()
|
||||
{
|
||||
return view('user.update_password');
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function updatePasswordStore()
|
||||
{
|
||||
$rules = array(
|
||||
'old_password' => 'required|old_password:' . Auth::user()->password,
|
||||
'password' => 'required|string|min:8|confirmed',
|
||||
);
|
||||
$rules = [
|
||||
'old_password' => 'required|old_password:'.Auth::user()->password,
|
||||
'password' => ['required', 'string', 'confirmed', Password::defaults()],
|
||||
];
|
||||
|
||||
Validator::extend('old_password', function ($attribute, $value, $parameters, $validator) {
|
||||
|
||||
|
|
@ -47,6 +46,7 @@ class UserUpdatePasswordController extends Controller
|
|||
|
||||
// get the error messages from the validator
|
||||
$messages = $validator->messages();
|
||||
|
||||
// redirect our user back to the form with the errors from the validator
|
||||
return view('user.update_password')->withErrors($validator);
|
||||
|
||||
|
|
@ -54,33 +54,32 @@ class UserUpdatePasswordController extends Controller
|
|||
$user = Auth::user();
|
||||
$data = Request::all();
|
||||
$user->fill([
|
||||
'password' => Hash::make($data['password'])
|
||||
'password' => Hash::make($data['password']),
|
||||
])->save();
|
||||
|
||||
|
||||
\Session()->flash('alert-save', '1');
|
||||
|
||||
return redirect(route('user_update_password'));
|
||||
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @return \Illuminate\Contracts\View\Factory|\Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector|\Illuminate\View\View
|
||||
* @return Factory|RedirectResponse|Redirector|View
|
||||
*/
|
||||
public function updatePasswordFirst(){
|
||||
if(!Auth::user()->isPasswort()){
|
||||
public function updatePasswordFirst()
|
||||
{
|
||||
if (! Auth::user()->isPasswort()) {
|
||||
return view('user.update_password_first');
|
||||
}
|
||||
|
||||
return redirect(route('user_update_password'));
|
||||
}
|
||||
|
||||
|
||||
public function updatePasswordFirstStore()
|
||||
{
|
||||
$rules = array(
|
||||
'password' => 'required|string|min:6|confirmed',
|
||||
);
|
||||
|
||||
$rules = [
|
||||
'password' => ['required', 'string', 'confirmed', Password::defaults()],
|
||||
];
|
||||
|
||||
$validator = Validator::make(Request::all(), $rules);
|
||||
|
||||
|
|
@ -88,6 +87,7 @@ class UserUpdatePasswordController extends Controller
|
|||
|
||||
// get the error messages from the validator
|
||||
$messages = $validator->messages();
|
||||
|
||||
// redirect our user back to the form with the errors from the validator
|
||||
return view('user.update_password_first')->withErrors($validator);
|
||||
|
||||
|
|
@ -95,12 +95,11 @@ class UserUpdatePasswordController extends Controller
|
|||
$user = Auth::user();
|
||||
$data = Request::all();
|
||||
$user->fill([
|
||||
'password' => Hash::make($data['password'])
|
||||
'password' => Hash::make($data['password']),
|
||||
])->save();
|
||||
|
||||
|
||||
|
||||
\Session()->flash('alert-save', '1');
|
||||
|
||||
return redirect('/home');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,126 +2,65 @@
|
|||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
|
||||
use Request;
|
||||
use Validator;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Contact\ContactStoreRequest;
|
||||
use App\Mail\MailContact;
|
||||
use App\Services\FormSpamGuard;
|
||||
use App\Services\Shop;
|
||||
use App\Services\Util;
|
||||
use GuzzleHttp\Client;
|
||||
use App\Mail\MailContact;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
|
||||
class ContactController extends Controller
|
||||
{
|
||||
private $GOOGLE_ReCAPTCHA_KEY = "6LeeZosUAAAAAG907fMMqO4BFgsiR4ANDodd8FlU";
|
||||
private $GOOGLE_ReCAPTCHA_SECRET = "6LeeZosUAAAAADIy2fyR4RG3EuM-Zdz7Pa2Qmb1J";
|
||||
public function __construct(private FormSpamGuard $spamGuard) {}
|
||||
|
||||
/**
|
||||
* Create a new controller instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function create()
|
||||
public function create(): View
|
||||
{
|
||||
$data = [
|
||||
'GOOGLE_ReCAPTCHA_KEY' => $this->GOOGLE_ReCAPTCHA_KEY,
|
||||
'turnstile_site_key' => config('services.turnstile.site_key'),
|
||||
'form_token' => old(FormSpamGuard::TIMESTAMP_FIELD, $this->spamGuard->makeFormToken()),
|
||||
'user_shop' => Util::getUserShop(),
|
||||
'mylangs' => Shop::getLangChange('webshop'),
|
||||
'yard_instance' => 'webshop',
|
||||
|
||||
];
|
||||
|
||||
return view('web.templates.kontakt', $data);
|
||||
}
|
||||
|
||||
public function store()
|
||||
/**
|
||||
* Die Anfrage geht nur an mivita und ggf. den Berater des Shops, nie an die eingegebene Adresse.
|
||||
*/
|
||||
public function store(ContactStoreRequest $request): View
|
||||
{
|
||||
$blockReason = $this->spamGuard->blockReason($request);
|
||||
|
||||
$user_shop = Util::getUserShop();
|
||||
|
||||
|
||||
$rules = array(
|
||||
'salutation' => 'required',
|
||||
'first_name' => 'required',
|
||||
'last_name' => 'required',
|
||||
'email' => 'required|email',
|
||||
'message' => 'required',
|
||||
'g-recaptcha-response' => 'required|recaptcha',
|
||||
'accepted_data_protection' => 'required',
|
||||
);
|
||||
if (!$user_shop || $user_shop->id === 22) {
|
||||
$rules['sales_partnership'] = 'required';
|
||||
if (Request::get('sales_partnership') === 'JA') {
|
||||
$rules['sales_partnership_message'] = 'required';
|
||||
}
|
||||
if ($blockReason !== null) {
|
||||
FormSpamGuard::log($request, 'contact', $blockReason);
|
||||
} else {
|
||||
$this->sendMail($request);
|
||||
}
|
||||
|
||||
|
||||
Validator::extend('recaptcha', function ($attribute, $value, $parameters, $validator) {
|
||||
return $this->reCaptcha_validate($attribute, $value, $parameters, $validator);
|
||||
});
|
||||
|
||||
$validator = Validator::make(Request::all(), $rules);
|
||||
if ($validator->fails()) {
|
||||
return back()->withErrors($validator)->withInput(Request::all());
|
||||
}
|
||||
|
||||
|
||||
$contact = [];
|
||||
$contact['salutation'] = Request::get('salutation');
|
||||
$contact['first_name'] = Request::get('first_name');
|
||||
$contact['last_name'] = Request::get('last_name');
|
||||
$contact['email'] = Request::get('email');
|
||||
$contact['phone'] = Request::get('phone');
|
||||
$contact['message'] = Request::get('message');
|
||||
if (!$user_shop){
|
||||
$contact['sales_partnership'] = Request::get('sales_partnership');
|
||||
$contact['sales_partnership_message'] = Request::get('sales_partnership_message');
|
||||
}
|
||||
|
||||
|
||||
$contact_mail = config('app.contact_mail');
|
||||
if($user_shop){
|
||||
Mail::to($contact['email'])->bcc([$user_shop->user->email, $contact_mail])->locale(\App::getLocale())->send(new MailContact($contact));
|
||||
}else{
|
||||
Mail::to($contact['email'])->bcc($contact_mail)->locale(\App::getLocale())->send(new MailContact($contact));
|
||||
}
|
||||
|
||||
$data = [
|
||||
'user_shop' => Util::getUserShop(),
|
||||
'mylangs' => Shop::getLangChange('webshop'),
|
||||
'yard_instance' => 'webshop',
|
||||
];
|
||||
return view('web.templates.contact-final', $data);
|
||||
|
||||
return view('web.templates.contact-final', [
|
||||
'user_shop' => Util::getUserShop(),
|
||||
'mylangs' => Shop::getLangChange('webshop'),
|
||||
'yard_instance' => 'webshop',
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
private function reCaptcha_validate($attribute, $value, $parameters, $validator)
|
||||
private function sendMail(ContactStoreRequest $request): void
|
||||
{
|
||||
$contact = $request->safe()->only(['salutation', 'first_name', 'last_name', 'email', 'phone', 'message']);
|
||||
if ($request->asksForSalesPartnership()) {
|
||||
$contact['sales_partnership'] = $request->input('sales_partnership');
|
||||
$contact['sales_partnership_message'] = $request->input('sales_partnership_message');
|
||||
}
|
||||
|
||||
$client = new Client();
|
||||
$mail = Mail::to(config('app.contact_mail'));
|
||||
if ($consultantEmail = Util::getUserShop()?->user?->email) {
|
||||
$mail->bcc($consultantEmail);
|
||||
}
|
||||
|
||||
$response = $client->post(
|
||||
'https://www.google.com/recaptcha/api/siteverify',
|
||||
['form_params' =>
|
||||
[
|
||||
'secret' => $this->GOOGLE_ReCAPTCHA_SECRET,
|
||||
'response' => $value
|
||||
]
|
||||
]
|
||||
);
|
||||
|
||||
$body = json_decode((string)$response->getBody());
|
||||
return $body->success;
|
||||
$mail->locale(app()->getLocale())->send(new MailContact($contact));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
|||
43
app/Http/Controllers/Web/CustomerEmailChangeController.php
Normal file
43
app/Http/Controllers/Web/CustomerEmailChangeController.php
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\CustomerEmailChangeService;
|
||||
use Illuminate\Contracts\View\View;
|
||||
|
||||
/**
|
||||
* Login-free confirmation of a new customer e-mail address via the tokenized link in the
|
||||
* confirmation mail. GET only shows the page; the change is applied on POST, so mail
|
||||
* scanners that open links cannot confirm on the customer's behalf.
|
||||
*/
|
||||
class CustomerEmailChangeController extends Controller
|
||||
{
|
||||
public function show(string $token, CustomerEmailChangeService $service): View
|
||||
{
|
||||
$change = $service->findConfirmable($token);
|
||||
if (! $change) {
|
||||
return view('portal.customer.email_change_done', ['status' => CustomerEmailChangeService::STATUS_INVALID]);
|
||||
}
|
||||
|
||||
if ($change->shopping_user && $service->ownershipConflict($change->shopping_user, $change->new_email)) {
|
||||
return view('portal.customer.email_change_done', ['status' => CustomerEmailChangeService::STATUS_CONFLICT]);
|
||||
}
|
||||
|
||||
return view('portal.customer.email_change_confirm', [
|
||||
'change' => $change,
|
||||
'token' => $token,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(string $token, CustomerEmailChangeService $service): View
|
||||
{
|
||||
$change = $service->findConfirmable($token);
|
||||
$status = $change ? $service->confirm($change) : CustomerEmailChangeService::STATUS_INVALID;
|
||||
|
||||
return view('portal.customer.email_change_done', [
|
||||
'status' => $status,
|
||||
'email' => $status === CustomerEmailChangeService::STATUS_CONFIRMED ? $change->new_email : null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,23 +3,19 @@
|
|||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Register\RegisterUserRequest;
|
||||
use App\Mail\MailVerifyAccount;
|
||||
use App\Models\UserLevel;
|
||||
use App\Repositories\UserRepository;
|
||||
use App\Services\FormSpamGuard;
|
||||
use App\Services\UserService;
|
||||
use App\Services\Util;
|
||||
use App\User;
|
||||
use GuzzleHttp\Client;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Request;
|
||||
use Validator;
|
||||
|
||||
class RegisterController extends Controller
|
||||
{
|
||||
private $GOOGLE_ReCAPTCHA_KEY = '6LeeZosUAAAAAG907fMMqO4BFgsiR4ANDodd8FlU';
|
||||
|
||||
private $GOOGLE_ReCAPTCHA_SECRET = '6LeeZosUAAAAADIy2fyR4RG3EuM-Zdz7Pa2Qmb1J';
|
||||
|
||||
protected $userRepo;
|
||||
|
||||
/**
|
||||
|
|
@ -27,7 +23,7 @@ class RegisterController extends Controller
|
|||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(UserRepository $userRepo)
|
||||
public function __construct(UserRepository $userRepo, private FormSpamGuard $spamGuard)
|
||||
{
|
||||
$this->userRepo = $userRepo;
|
||||
}
|
||||
|
|
@ -35,7 +31,7 @@ class RegisterController extends Controller
|
|||
public function index()
|
||||
{
|
||||
if (config('app.debug')) {
|
||||
\Log::channel('doamin')->debug('RegisterController: index - Session user_shop', [
|
||||
\Log::channel('domain')->debug('RegisterController: index - Session user_shop', [
|
||||
'session_user_shop_id' => \Session::get('user_shop')?->id,
|
||||
'session_user_shop_name' => \Session::get('user_shop')?->name,
|
||||
'session_user_shop_user_id' => \Session::get('user_shop')?->user_id,
|
||||
|
|
@ -47,7 +43,8 @@ class RegisterController extends Controller
|
|||
}
|
||||
|
||||
$data = [
|
||||
'GOOGLE_ReCAPTCHA_KEY' => $this->GOOGLE_ReCAPTCHA_KEY,
|
||||
'turnstile_site_key' => config('services.turnstile.site_key'),
|
||||
'form_token' => old(FormSpamGuard::TIMESTAMP_FIELD, $this->spamGuard->makeFormToken()),
|
||||
'user_shop' => Util::getUserShop(),
|
||||
'yard_instance' => 'webshop',
|
||||
];
|
||||
|
|
@ -60,13 +57,12 @@ class RegisterController extends Controller
|
|||
if (! $member_id) {
|
||||
return redirect('/registrierung');
|
||||
}
|
||||
$user_id = (int) str_replace('m', '', $member_id) - config('mivita.add_number_id');
|
||||
$user = User::find($user_id);
|
||||
if (! $user || ! $user->isActive() || ! $user->isActiveAccount()) {
|
||||
if (! $this->findActiveSponsor($member_id)) {
|
||||
return redirect('/registrierung');
|
||||
}
|
||||
$data = [
|
||||
'GOOGLE_ReCAPTCHA_KEY' => $this->GOOGLE_ReCAPTCHA_KEY,
|
||||
'turnstile_site_key' => config('services.turnstile.site_key'),
|
||||
'form_token' => old(FormSpamGuard::TIMESTAMP_FIELD, $this->spamGuard->makeFormToken()),
|
||||
'user_shop' => Util::getUserShop(),
|
||||
'from_member_id' => $member_id,
|
||||
'yard_instance' => 'webshop',
|
||||
|
|
@ -75,32 +71,17 @@ class RegisterController extends Controller
|
|||
return view('web.templates.registrierung', $data);
|
||||
}
|
||||
|
||||
public function register()
|
||||
public function register(RegisterUserRequest $request): RedirectResponse
|
||||
{
|
||||
if ($blockReason = $this->spamGuard->blockReason($request)) {
|
||||
FormSpamGuard::log($request, 'register', $blockReason);
|
||||
|
||||
$rules = [
|
||||
'salutation' => 'required',
|
||||
'first_name' => 'required',
|
||||
'last_name' => 'required',
|
||||
'email' => 'required|string|email:filter|max:255|unique:users',
|
||||
'email-confirm' => 'required|same:email',
|
||||
'password' => 'required|string|min:6|confirmed',
|
||||
'password_confirmation' => 'required|string|min:6',
|
||||
'g-recaptcha-response' => 'required|recaptcha',
|
||||
'accepted_data_protection' => 'required',
|
||||
];
|
||||
|
||||
Validator::extend('recaptcha', function ($attribute, $value, $parameters, $validator) {
|
||||
return $this->reCaptcha_validate($attribute, $value, $parameters, $validator);
|
||||
});
|
||||
|
||||
$validator = Validator::make(Request::all(), $rules);
|
||||
if ($validator->fails()) {
|
||||
return back()->withErrors($validator)->withInput(Request::all());
|
||||
return redirect('/registrierung/finish');
|
||||
}
|
||||
|
||||
$user_shop = Util::getUserShop();
|
||||
$data = Request::all();
|
||||
$data = $request->validated();
|
||||
$data['lang'] = ! empty(\App::getLocale()) ? \App::getLocale() : 'de';
|
||||
$user = $this->userRepo->create($data);
|
||||
|
||||
$confirmation_code = UserService::createConfirmationCode();
|
||||
|
|
@ -108,10 +89,9 @@ class RegisterController extends Controller
|
|||
if ($user_shop) {
|
||||
$m_sponsor_id = $user_shop->user->id;
|
||||
}
|
||||
if (isset($data['from_member_id'])) {
|
||||
$m_sponsor_id = (int) str_replace('m', '', $data['from_member_id']) - config('mivita.add_number_id');
|
||||
if (filled($data['from_member_id'] ?? null) && $sponsor = $this->findActiveSponsor($data['from_member_id'])) {
|
||||
$m_sponsor_id = $sponsor->id;
|
||||
}
|
||||
$user->lang = ! empty(\App::getLocale()) ? \App::getLocale() : 'de';
|
||||
$user->confirmation_code = $confirmation_code;
|
||||
$user->confirmation_code_to = date('Y-m-d H:i:s', strtotime('+1 week'));
|
||||
$user->confirmation_code_remider = 0;
|
||||
|
|
@ -144,23 +124,18 @@ class RegisterController extends Controller
|
|||
return view('web.templates.registrierung_finish', $data);
|
||||
}
|
||||
|
||||
private function reCaptcha_validate($attribute, $value, $parameters, $validator)
|
||||
/**
|
||||
* Löst eine Partner-Nummer (z. B. "m1234") zu einem aktiven Berater auf.
|
||||
*/
|
||||
private function findActiveSponsor(string $memberId): ?User
|
||||
{
|
||||
$userId = (int) str_replace('m', '', $memberId) - config('mivita.add_number_id');
|
||||
$user = User::find($userId);
|
||||
|
||||
$client = new Client;
|
||||
if (! $user || ! $user->isActive() || ! $user->isActiveAccount()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$response = $client->post(
|
||||
'https://www.google.com/recaptcha/api/siteverify',
|
||||
[
|
||||
'form_params' => [
|
||||
'secret' => $this->GOOGLE_ReCAPTCHA_SECRET,
|
||||
'response' => $value,
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
$body = json_decode((string) $response->getBody());
|
||||
|
||||
return $body->success;
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ use App\User;
|
|||
use Auth;
|
||||
use Hash;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Request;
|
||||
use Validator;
|
||||
use Yard;
|
||||
|
|
@ -489,7 +490,7 @@ class WizardController extends Controller
|
|||
return redirect(route('wizard_create', [12]));
|
||||
}
|
||||
$rules = [
|
||||
'password' => 'required|string|min:6|confirmed',
|
||||
'password' => ['required', 'string', 'confirmed', Password::defaults()],
|
||||
];
|
||||
$validator = Validator::make(Request::all(), $rules);
|
||||
if ($validator->fails()) {
|
||||
|
|
|
|||
84
app/Http/Requests/Contact/ContactStoreRequest.php
Normal file
84
app/Http/Requests/Contact/ContactStoreRequest.php
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\Contact;
|
||||
|
||||
use App\Rules\PersonName;
|
||||
use App\Rules\TurnstileToken;
|
||||
use App\Services\FormSpamGuard;
|
||||
use App\Services\Util;
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\Validator;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class ContactStoreRequest extends FormRequest
|
||||
{
|
||||
public const PHONE_PATTERN = '/^[0-9 +\-\/().]+$/';
|
||||
|
||||
public const MAX_LINKS = 2;
|
||||
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, mixed>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
$rules = [
|
||||
'salutation' => ['required', 'in:mr,ms'],
|
||||
'first_name' => ['required', 'string', new PersonName],
|
||||
'last_name' => ['required', 'string', new PersonName],
|
||||
'email' => ['required', 'email', 'max:255'],
|
||||
'phone' => ['nullable', 'string', 'max:30', 'regex:'.self::PHONE_PATTERN],
|
||||
'message' => ['required', 'string', 'max:3000', $this->maxLinksRule()],
|
||||
'accepted_data_protection' => ['accepted'],
|
||||
'cf-turnstile-response' => ['required', new TurnstileToken($this->ip())],
|
||||
];
|
||||
|
||||
if ($this->asksForSalesPartnership()) {
|
||||
$rules['sales_partnership'] = ['required', 'in:JA,NEIN'];
|
||||
$rules['sales_partnership_message'] = ['required_if:sales_partnership,JA', 'nullable', 'string', 'max:3000', $this->maxLinksRule()];
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'phone.regex' => __('contact.phone_invalid'),
|
||||
'cf-turnstile-response.required' => __('form_protection.captcha_failed'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Abfrage zur Vertriebspartnerschaft nur auf der Hauptseite und im Firmen-Shop.
|
||||
*/
|
||||
public function asksForSalesPartnership(): bool
|
||||
{
|
||||
$userShop = Util::getUserShop();
|
||||
|
||||
return ! $userShop || $userShop->id === 22;
|
||||
}
|
||||
|
||||
protected function failedValidation(Validator $validator): void
|
||||
{
|
||||
FormSpamGuard::log($this, 'contact', 'validation', ['fields' => array_keys($validator->failed())]);
|
||||
|
||||
parent::failedValidation($validator);
|
||||
}
|
||||
|
||||
private function maxLinksRule(): Closure
|
||||
{
|
||||
return function (string $attribute, mixed $value, Closure $fail): void {
|
||||
if (preg_match_all('~https?://|www\.~i', (string) $value) > self::MAX_LINKS) {
|
||||
$fail(__('contact.too_many_links'));
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
33
app/Http/Requests/Recognition/RecognitionFilterRequest.php
Normal file
33
app/Http/Requests/Recognition/RecognitionFilterRequest.php
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\Recognition;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class RecognitionFilterRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, mixed>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'period' => ['nullable', 'date_format:Y-m'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'period.date_format' => __('recognitions.period_invalid'),
|
||||
];
|
||||
}
|
||||
}
|
||||
54
app/Http/Requests/Register/RegisterUserRequest.php
Normal file
54
app/Http/Requests/Register/RegisterUserRequest.php
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\Register;
|
||||
|
||||
use App\Rules\PersonName;
|
||||
use App\Rules\TurnstileToken;
|
||||
use App\Services\FormSpamGuard;
|
||||
use Illuminate\Contracts\Validation\Validator;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
class RegisterUserRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, mixed>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'salutation' => ['required', 'in:mr,ms'],
|
||||
'first_name' => ['required', 'string', new PersonName],
|
||||
'last_name' => ['required', 'string', new PersonName],
|
||||
'email' => ['required', 'string', 'email:filter', 'max:255', 'unique:users'],
|
||||
'email-confirm' => ['required', 'same:email'],
|
||||
'password' => ['required', 'string', 'max:255', 'confirmed', Password::defaults()],
|
||||
'password_confirmation' => ['required', 'string'],
|
||||
'accepted_data_protection' => ['accepted'],
|
||||
'from_member_id' => ['nullable', 'string', 'max:20'],
|
||||
'cf-turnstile-response' => ['required', new TurnstileToken($this->ip())],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'cf-turnstile-response.required' => __('form_protection.captcha_failed'),
|
||||
];
|
||||
}
|
||||
|
||||
protected function failedValidation(Validator $validator): void
|
||||
{
|
||||
FormSpamGuard::log($this, 'register', 'validation', ['fields' => array_keys($validator->failed())]);
|
||||
|
||||
parent::failedValidation($validator);
|
||||
}
|
||||
}
|
||||
51
app/Mail/CustomerEmailChangeBlockedMail.php
Normal file
51
app/Mail/CustomerEmailChangeBlockedMail.php
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\ShoppingUser;
|
||||
use App\Services\Util;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* Info to the customer sovereignty inbox: a consultant tried to change a customer's e-mail
|
||||
* to an address that already belongs to a consultant account or another consultant's customer.
|
||||
*/
|
||||
class CustomerEmailChangeBlockedMail extends Mailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* @param list<string> $owners E-mail addresses of the consultants the address belongs to
|
||||
*/
|
||||
public function __construct(
|
||||
public ShoppingUser $shoppingUser,
|
||||
public string $newEmail,
|
||||
public ?string $requestedBy = null,
|
||||
public array $owners = [],
|
||||
) {}
|
||||
|
||||
public function build(): self
|
||||
{
|
||||
$customer = $this->shoppingUser;
|
||||
|
||||
$content = 'Kunde: '.$customer->billing_firstname.' '.$customer->billing_lastname.' (Kunden-Nr. '.$customer->number.")\n";
|
||||
$content .= 'Bisherige E-Mail: '.$customer->billing_email."\n";
|
||||
$content .= 'Gewünschte E-Mail: '.$this->newEmail."\n";
|
||||
$content .= 'Betreuender Berater: '.($customer->member?->email ?? '-')."\n";
|
||||
$content .= 'Änderung angefragt von: '.($this->requestedBy ?? '-')."\n";
|
||||
$content .= 'E-Mail bereits zugeordnet bei: '.($this->owners ? implode(', ', $this->owners) : 'Beraterkonto')."\n";
|
||||
|
||||
return $this->subject('E-Mail-Änderung abgelehnt - Kundenhoheit')
|
||||
->view('emails.info')
|
||||
->with([
|
||||
'url' => Util::getMyMivitaUrl().'/admin/customer-priority/email-changes',
|
||||
'title' => 'Eine E-Mail-Änderung wurde nicht übernommen, weil die Adresse bereits einem anderen Berater bzw. einem Beraterkonto zugeordnet ist.',
|
||||
'button' => 'zur Übersicht Kundenhoheit',
|
||||
'copy1line' => "Der Fall steht in der Übersicht Kundenhoheit, bis er als erledigt markiert ist:\n",
|
||||
'copy2line' => __('email.copy2line'),
|
||||
'content' => $content,
|
||||
]);
|
||||
}
|
||||
}
|
||||
36
app/Mail/CustomerEmailChangeMail.php
Normal file
36
app/Mail/CustomerEmailChangeMail.php
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\ShoppingUser;
|
||||
use App\Services\Util;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class CustomerEmailChangeMail extends Mailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public function __construct(public ShoppingUser $shoppingUser, public string $token) {}
|
||||
|
||||
public function build(): self
|
||||
{
|
||||
$firstname = $this->shoppingUser->billing_firstname;
|
||||
$salutation = $firstname ? __('email.hello').' '.$firstname : __('email.hello');
|
||||
|
||||
// Link zeigt immer auf das Kundenportal (in.mivita.care), unabhängig vom Versand-Kontext.
|
||||
$link = rtrim(Util::getMyMivitaPortalUrl(), '/').'/kunde/email-bestaetigen/'.$this->token;
|
||||
$subject = __('customer.email_change_mail_subject');
|
||||
|
||||
return $this->subject($subject)
|
||||
->view('emails.customer_email_change')
|
||||
->with([
|
||||
'salutation' => $salutation,
|
||||
'link' => $link,
|
||||
'mailTitle' => $subject,
|
||||
'greetings' => __('email.greetings'),
|
||||
'sender' => __('email.sender'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,59 +1,59 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\User;
|
||||
use App\Services\Util;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use App\Services\Util;
|
||||
|
||||
/**
|
||||
* Kontaktanfrage an mivita bzw. den Berater. Antworten gehen per Reply-To direkt an den Absender.
|
||||
*/
|
||||
class MailContact extends Mailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public $user_shop;
|
||||
|
||||
public $subject;
|
||||
|
||||
public $data;
|
||||
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function __construct($data)
|
||||
{
|
||||
$this->data = $data;
|
||||
$this->user_shop = Util::getUserShop();
|
||||
$this->subject = __('email.your_request_from').' mivita.care';
|
||||
if($this->user_shop){
|
||||
$this->subject = __('email.your_request_from')." ".$this->user_shop->slug.'.mivita.care';
|
||||
}
|
||||
$this->subject = __('contact.mail_subject', ['source' => $this->source()]);
|
||||
$this->replyTo($data['email'], trim(($data['first_name'] ?? '').' '.($data['last_name'] ?? '')));
|
||||
}
|
||||
|
||||
public function build()
|
||||
{
|
||||
$salutation = __('email.hello');
|
||||
if(isset($this->data['first_name'])){
|
||||
$salutation .= " ".$this->data['first_name'];
|
||||
}
|
||||
|
||||
if(isset($this->data['salutation'])){
|
||||
if($this->data['salutation'] === 'mr'){
|
||||
if (isset($this->data['salutation'])) {
|
||||
if ($this->data['salutation'] === 'mr') {
|
||||
$this->data['salutation'] = 'Herr';
|
||||
}
|
||||
if($this->data['salutation'] === 'ms'){
|
||||
if ($this->data['salutation'] === 'ms') {
|
||||
$this->data['salutation'] = 'Frau';
|
||||
}
|
||||
}
|
||||
$copy1line = __('email.your_request_from')." mivita.care";
|
||||
if($this->user_shop){
|
||||
$copy1line = __('email.your_request_from')." ".$this->user_shop->slug.'.mivita.care';
|
||||
}
|
||||
|
||||
return $this->view('emails.contact')->with([
|
||||
'salutation' => $salutation,
|
||||
'copy1line' => $copy1line,
|
||||
'salutation' => __('contact.mail_headline'),
|
||||
'copy1line' => __('contact.mail_intro', ['source' => $this->source()]),
|
||||
'data' => $this->data,
|
||||
'copy3line' => __('email.copy3line'),
|
||||
'copy3line' => __('contact.mail_reply_hint'),
|
||||
'greetings' => __('email.greetings'),
|
||||
'sender' => __('email.sender'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function source(): string
|
||||
{
|
||||
return $this->user_shop ? $this->user_shop->slug.'.mivita.care' : 'mivita.care';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -400,6 +400,23 @@ class Incentive extends Model
|
|||
return ! $this->includesSalesPoints() && ! $this->usesAbos() && $this->points_partner_onetime > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rangliste nach Umsatzpunkten der Neupartner: keine Einmal- und keine Abopunkte
|
||||
* (Recruiting-Challenge ab 08.10.2026, Berechnung wie Montenegro).
|
||||
*/
|
||||
public function ranksBySalesOfNewPartners(): bool
|
||||
{
|
||||
return $this->includesSalesPoints() && ! $this->usesAbos() && $this->points_partner_onetime <= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Darstellung als Challenge mit Geldpraemie (Praemie je Neupartner, Top-Bonus) statt Punktesystem.
|
||||
*/
|
||||
public function isCashChallenge(): bool
|
||||
{
|
||||
return $this->hasCashPayout() && ($this->ranksByPartnerCount() || $this->ranksBySalesOfNewPartners());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get specific translation for a field and locale.
|
||||
*/
|
||||
|
|
|
|||
32
app/Models/RecognitionSnapshot.php
Normal file
32
app/Models/RecognitionSnapshot.php
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Services\Team\RecognitionService;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* Eingefrorene Anerkennungen eines abgeschlossenen Monats
|
||||
* ({@see RecognitionService::freeze()}).
|
||||
*/
|
||||
class RecognitionSnapshot extends Model
|
||||
{
|
||||
protected $table = 'recognition_snapshots';
|
||||
|
||||
protected $fillable = [
|
||||
'year',
|
||||
'month',
|
||||
'payload',
|
||||
'frozen_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'year' => 'int',
|
||||
'month' => 'int',
|
||||
'payload' => 'array',
|
||||
'frozen_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
}
|
||||
108
app/Models/ShoppingUserEmailChange.php
Normal file
108
app/Models/ShoppingUserEmailChange.php
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\User;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* Requested change of a customer's e-mail address. A consultant's change stays pending
|
||||
* until the customer confirms it via the tokenized link sent to the new address; only
|
||||
* then the address is used (login, abo mails). The plaintext token lives only in the
|
||||
* e-mail link, the database stores its SHA-256 hash (see {@see AboCardUpdateLink}).
|
||||
*
|
||||
* @property int $shopping_user_id
|
||||
* @property int|null $number
|
||||
* @property string $old_email
|
||||
* @property string $new_email
|
||||
* @property string|null $token_hash
|
||||
* @property Carbon|null $expires_at
|
||||
* @property Carbon|null $confirmed_at
|
||||
* @property Carbon|null $blocked_at
|
||||
* @property Carbon|null $resolved_at
|
||||
* @property int|null $requested_by_user_id
|
||||
* @property int|null $resolved_by_user_id
|
||||
*/
|
||||
class ShoppingUserEmailChange extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'shopping_user_id',
|
||||
'number',
|
||||
'old_email',
|
||||
'new_email',
|
||||
'token_hash',
|
||||
'expires_at',
|
||||
'confirmed_at',
|
||||
'blocked_at',
|
||||
'resolved_at',
|
||||
'requested_by_user_id',
|
||||
'resolved_by_user_id',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'expires_at' => 'datetime',
|
||||
'confirmed_at' => 'datetime',
|
||||
'blocked_at' => 'datetime',
|
||||
'resolved_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function shopping_user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ShoppingUser::class, 'shopping_user_id');
|
||||
}
|
||||
|
||||
public function requested_by(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'requested_by_user_id');
|
||||
}
|
||||
|
||||
public static function hashToken(string $token): string
|
||||
{
|
||||
return hash('sha256', $token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open requests that can still be confirmed.
|
||||
*
|
||||
* @param Builder<ShoppingUserEmailChange> $query
|
||||
*/
|
||||
public function scopePending(Builder $query): void
|
||||
{
|
||||
$query->whereNull('confirmed_at')->whereNotNull('token_hash')->where('expires_at', '>', now());
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes rejected because of customer sovereignty that an admin has not handled yet.
|
||||
*
|
||||
* @param Builder<ShoppingUserEmailChange> $query
|
||||
*/
|
||||
public function scopeBlockedOpen(Builder $query): void
|
||||
{
|
||||
$query->whereNotNull('blocked_at')->whereNull('resolved_at');
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests belonging to the same customer (all entries share the customer number).
|
||||
*
|
||||
* @param Builder<ShoppingUserEmailChange> $query
|
||||
*/
|
||||
public function scopeForCustomer(Builder $query, ShoppingUser $shoppingUser): void
|
||||
{
|
||||
if ($shoppingUser->number > 0) {
|
||||
$query->where('number', $shoppingUser->number);
|
||||
} else {
|
||||
$query->where('shopping_user_id', $shoppingUser->id);
|
||||
}
|
||||
}
|
||||
|
||||
public function isConfirmable(): bool
|
||||
{
|
||||
return $this->confirmed_at === null && $this->token_hash !== null && $this->expires_at?->isFuture();
|
||||
}
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ use Illuminate\Support\Facades\Schema;
|
|||
use Illuminate\Support\Facades\URL;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
|
|
@ -29,6 +30,8 @@ class AppServiceProvider extends ServiceProvider
|
|||
{
|
||||
Schema::defaultStringLength(191);
|
||||
Validator::resolver(fn ($translator, $data, $rules, $messages, $attributes) => new SafeEmailValidator($translator, $data, $rules, $messages, $attributes));
|
||||
// Mindestanforderung für alle neu gesetzten Passwörter (Registrierung, Wizard, Ändern, Zurücksetzen)
|
||||
Password::defaults(fn () => Password::min(8));
|
||||
Paginator::defaultView('pagination::bootstrap-4');
|
||||
Paginator::defaultSimpleView('pagination::simple-bootstrap-4');
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ namespace App\Providers;
|
|||
// use App\Domain\DomainContext; // HOTFIX: Temporär deaktiviert
|
||||
use App\Domain\EarlyDomainParser;
|
||||
use App\Http\Controllers\HealthController;
|
||||
use App\Services\FormSpamGuard;
|
||||
use Closure;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
|
||||
use Illuminate\Http\Request;
|
||||
|
|
@ -131,5 +133,38 @@ class RouteServiceProvider extends ServiceProvider
|
|||
RateLimiter::for('api', function (Request $request) {
|
||||
return Limit::perMinute(60)->by(optional($request->user())->id ?: $request->ip());
|
||||
});
|
||||
|
||||
RateLimiter::for('contact-form', function (Request $request) {
|
||||
$tooManyRequests = $this->formTooManyRequestsResponse('contact');
|
||||
|
||||
return [
|
||||
Limit::perMinutes(10, 5)->by('contact-ip:'.$request->ip())->response($tooManyRequests),
|
||||
Limit::perDay(5)->by('contact-email:'.mb_strtolower((string) $request->input('email')))->response($tooManyRequests),
|
||||
];
|
||||
});
|
||||
|
||||
RateLimiter::for('register-form', function (Request $request) {
|
||||
$tooManyRequests = $this->formTooManyRequestsResponse('register');
|
||||
|
||||
return [
|
||||
Limit::perMinutes(10, 5)->by('register-ip:'.$request->ip())->response($tooManyRequests),
|
||||
Limit::perDay(20)->by('register-ip-day:'.$request->ip())->response($tooManyRequests),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Zurück zum Formular mit Hinweis statt nackter 429-Seite.
|
||||
*
|
||||
* @param 'contact'|'register' $form
|
||||
*/
|
||||
private function formTooManyRequestsResponse(string $form): Closure
|
||||
{
|
||||
return function (Request $request) use ($form) {
|
||||
FormSpamGuard::log($request, $form, 'rate_limit');
|
||||
|
||||
return back()->withInput($request->except('password', 'password_confirmation'))
|
||||
->withErrors(['email' => __('form_protection.too_many_requests')]);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,10 +51,12 @@ class UserRepository extends BaseRepository
|
|||
public function create($data)
|
||||
{
|
||||
|
||||
$this->model = User::create([
|
||||
$this->model = new User([
|
||||
'email' => $data['email'],
|
||||
'password' => Hash::make($data['password']),
|
||||
]);
|
||||
$this->model->lang = $data['lang'] ?? 'de';
|
||||
$this->model->save();
|
||||
|
||||
$account = UserAccount::create([
|
||||
'm_salutation' => $data['salutation'],
|
||||
|
|
|
|||
29
app/Rules/PersonName.php
Normal file
29
app/Rules/PersonName.php
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
namespace App\Rules;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Translation\PotentiallyTranslatedString;
|
||||
|
||||
/**
|
||||
* Vor- oder Nachname: nur lateinische Schrift (inkl. Umlaute/Akzente), Leerzeichen, Bindestrich,
|
||||
* Apostroph und Punkt, max. 50 Zeichen. Hält Werbetexte und Links aus Namensfeldern fern,
|
||||
* die sonst in Begrüßungen von Mails landen.
|
||||
*/
|
||||
class PersonName implements ValidationRule
|
||||
{
|
||||
public const PATTERN = "/^[\p{Latin}\p{M}][\p{Latin}\p{M} '.\-]*$/u";
|
||||
|
||||
public const MAX_LENGTH = 50;
|
||||
|
||||
/**
|
||||
* @param Closure(string, ?string=): PotentiallyTranslatedString $fail
|
||||
*/
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
if (! is_string($value) || mb_strlen($value) > self::MAX_LENGTH || ! preg_match(self::PATTERN, $value)) {
|
||||
$fail(__('form_protection.name_invalid'));
|
||||
}
|
||||
}
|
||||
}
|
||||
23
app/Rules/TurnstileToken.php
Normal file
23
app/Rules/TurnstileToken.php
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
|
||||
namespace App\Rules;
|
||||
|
||||
use App\Services\TurnstileVerifier;
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Translation\PotentiallyTranslatedString;
|
||||
|
||||
class TurnstileToken implements ValidationRule
|
||||
{
|
||||
public function __construct(private ?string $ip = null) {}
|
||||
|
||||
/**
|
||||
* @param Closure(string, ?string=): PotentiallyTranslatedString $fail
|
||||
*/
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
if (! is_string($value) || ! app(TurnstileVerifier::class)->verify($value, $this->ip)) {
|
||||
$fail(__('form_protection.captcha_failed'));
|
||||
}
|
||||
}
|
||||
}
|
||||
291
app/Services/CustomerEmailChangeService.php
Normal file
291
app/Services/CustomerEmailChangeService.php
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Mail\CustomerEmailChangeBlockedMail;
|
||||
use App\Mail\CustomerEmailChangeMail;
|
||||
use App\Models\ShoppingUser;
|
||||
use App\Models\ShoppingUserEmailChange;
|
||||
use App\Models\UserAbo;
|
||||
use App\User;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Changing a customer's e-mail address. The address is the customer's portal login and
|
||||
* the key the portal uses to find their abo, so a consultant may not switch it silently:
|
||||
* the new address only becomes active after the customer confirmed it via the link sent
|
||||
* to it. On activation all entries of the customer with the old address and their abos
|
||||
* move to the new address together.
|
||||
*/
|
||||
class CustomerEmailChangeService
|
||||
{
|
||||
public const TTL_HOURS = 168;
|
||||
|
||||
public const STATUS_CONFIRMED = 'confirmed';
|
||||
|
||||
public const STATUS_INVALID = 'invalid';
|
||||
|
||||
public const STATUS_CONFLICT = 'conflict';
|
||||
|
||||
public const CONFLICT_CONSULTANT_ACCOUNT = 'consultant_account';
|
||||
|
||||
public const CONFLICT_OTHER_CONSULTANT = 'other_consultant';
|
||||
|
||||
public function changesAddress(ShoppingUser $shoppingUser, ?string $newEmail): bool
|
||||
{
|
||||
return filled($newEmail) && ! $this->sameEmail($shoppingUser->billing_email, $newEmail);
|
||||
}
|
||||
|
||||
/**
|
||||
* A customer without a real address yet (placeholder) has no login to lose, so only
|
||||
* replacing a real address needs the customer's confirmation.
|
||||
*/
|
||||
public function requiresConfirmation(ShoppingUser $shoppingUser, ?string $newEmail): bool
|
||||
{
|
||||
return ! $this->hasPlaceholderEmail($shoppingUser) && $this->changesAddress($shoppingUser, $newEmail);
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer sovereignty: the e-mail is the main indicator who a customer belongs to. A
|
||||
* consultant may not take over an address that already belongs to an active consultant
|
||||
* account or to a customer of another consultant.
|
||||
*
|
||||
* @return self::CONFLICT_*|null
|
||||
*/
|
||||
public function ownershipConflict(ShoppingUser $shoppingUser, string $newEmail): ?string
|
||||
{
|
||||
$newEmail = trim($newEmail);
|
||||
|
||||
if (User::where('email', $newEmail)->where('active', 1)->exists()) {
|
||||
return self::CONFLICT_CONSULTANT_ACCOUNT;
|
||||
}
|
||||
|
||||
return $this->customersOfOtherConsultants($shoppingUser, $newEmail)->isNotEmpty()
|
||||
? self::CONFLICT_OTHER_CONSULTANT
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a change rejected because of customer sovereignty, so it stays visible in the
|
||||
* admin overview until handled, and informs the customer sovereignty inbox.
|
||||
*/
|
||||
public function reportConflict(ShoppingUser $shoppingUser, string $newEmail, ?int $requestedBy = null, ?ShoppingUserEmailChange $change = null): ShoppingUserEmailChange
|
||||
{
|
||||
$newEmail = trim($newEmail);
|
||||
$owners = $this->customersOfOtherConsultants($shoppingUser, $newEmail)
|
||||
->map(fn (ShoppingUser $customer) => $customer->member?->email)
|
||||
->filter()
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$attributes = ['token_hash' => null, 'blocked_at' => now()];
|
||||
if ($change) {
|
||||
$change->forceFill($attributes)->save();
|
||||
} else {
|
||||
$change = ShoppingUserEmailChange::create($attributes + [
|
||||
'shopping_user_id' => $shoppingUser->id,
|
||||
'number' => $shoppingUser->number,
|
||||
'old_email' => $shoppingUser->billing_email,
|
||||
'new_email' => $newEmail,
|
||||
'requested_by_user_id' => $requestedBy,
|
||||
]);
|
||||
}
|
||||
|
||||
Log::warning('CustomerEmailChange: Änderung wegen Kundenhoheit abgelehnt', [
|
||||
'shopping_user_id' => $shoppingUser->id,
|
||||
'number' => $shoppingUser->number,
|
||||
'new_email' => $newEmail,
|
||||
'requested_by_user_id' => $requestedBy,
|
||||
'owners' => $owners,
|
||||
]);
|
||||
|
||||
Mail::to(config('app.priority_mail'))->send(new CustomerEmailChangeBlockedMail(
|
||||
$shoppingUser,
|
||||
$newEmail,
|
||||
$requestedBy ? User::find($requestedBy)?->email : null,
|
||||
$owners,
|
||||
));
|
||||
|
||||
return $change;
|
||||
}
|
||||
|
||||
public function resolveBlocked(ShoppingUserEmailChange $change, ?int $resolvedBy = null): void
|
||||
{
|
||||
$change->forceFill(['resolved_at' => now(), 'resolved_by_user_id' => $resolvedBy])->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Withdraws an open request; the link in the mail stops working.
|
||||
*/
|
||||
public function discard(ShoppingUserEmailChange $change): void
|
||||
{
|
||||
$change->forceFill(['token_hash' => null, 'expires_at' => now()])->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the confirmation for a new address and sends the link to it. Older open
|
||||
* requests of the same customer are replaced.
|
||||
*/
|
||||
public function request(ShoppingUser $shoppingUser, string $newEmail, ?int $requestedBy = null): ShoppingUserEmailChange
|
||||
{
|
||||
$newEmail = trim($newEmail);
|
||||
$token = Str::random(48);
|
||||
|
||||
ShoppingUserEmailChange::forCustomer($shoppingUser)->pending()->delete();
|
||||
|
||||
$change = ShoppingUserEmailChange::create([
|
||||
'shopping_user_id' => $shoppingUser->id,
|
||||
'number' => $shoppingUser->number,
|
||||
'old_email' => $shoppingUser->billing_email,
|
||||
'new_email' => $newEmail,
|
||||
'token_hash' => ShoppingUserEmailChange::hashToken($token),
|
||||
'expires_at' => now()->addHours(self::TTL_HOURS),
|
||||
'requested_by_user_id' => $requestedBy,
|
||||
]);
|
||||
|
||||
Mail::to($newEmail)
|
||||
->locale($shoppingUser->language ?: 'de')
|
||||
->send(new CustomerEmailChangeMail($shoppingUser, $token));
|
||||
|
||||
return $change;
|
||||
}
|
||||
|
||||
public function findConfirmable(string $token): ?ShoppingUserEmailChange
|
||||
{
|
||||
$change = ShoppingUserEmailChange::where('token_hash', ShoppingUserEmailChange::hashToken($token))->first();
|
||||
|
||||
return $change && $change->isConfirmable() ? $change : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Activates the requested address. Fails when the customer's address was changed in
|
||||
* the meantime (an outdated link must not overwrite a newer address) or when the new
|
||||
* address has meanwhile been assigned to another consultant (customer sovereignty).
|
||||
*
|
||||
* @return self::STATUS_*
|
||||
*/
|
||||
public function confirm(ShoppingUserEmailChange $change): string
|
||||
{
|
||||
$shoppingUser = $change->shopping_user;
|
||||
if (! $change->isConfirmable() || ! $shoppingUser || ! $this->sameEmail($shoppingUser->billing_email, $change->old_email)) {
|
||||
return self::STATUS_INVALID;
|
||||
}
|
||||
|
||||
if ($this->ownershipConflict($shoppingUser, $change->new_email)) {
|
||||
$this->reportConflict($shoppingUser, $change->new_email, $change->requested_by_user_id, $change);
|
||||
|
||||
return self::STATUS_CONFLICT;
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($change, $shoppingUser) {
|
||||
$this->apply($shoppingUser, $change->old_email, $change->new_email);
|
||||
$change->forceFill(['confirmed_at' => now(), 'token_hash' => null])->save();
|
||||
});
|
||||
|
||||
return self::STATUS_CONFIRMED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct change without confirmation (admin). Logged like a confirmed request.
|
||||
*/
|
||||
public function changeDirectly(ShoppingUser $shoppingUser, string $newEmail, ?int $changedBy = null): void
|
||||
{
|
||||
$oldEmail = $shoppingUser->billing_email;
|
||||
$newEmail = trim($newEmail);
|
||||
|
||||
DB::transaction(function () use ($shoppingUser, $oldEmail, $newEmail, $changedBy) {
|
||||
$this->apply($shoppingUser, $oldEmail, $newEmail);
|
||||
ShoppingUserEmailChange::forCustomer($shoppingUser)->pending()->delete();
|
||||
ShoppingUserEmailChange::create([
|
||||
'shopping_user_id' => $shoppingUser->id,
|
||||
'number' => $shoppingUser->number,
|
||||
'old_email' => $oldEmail,
|
||||
'new_email' => $newEmail,
|
||||
'confirmed_at' => now(),
|
||||
'requested_by_user_id' => $changedBy,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
public function pendingFor(ShoppingUser $shoppingUser): ?ShoppingUserEmailChange
|
||||
{
|
||||
return ShoppingUserEmailChange::forCustomer($shoppingUser)->pending()->latest('id')->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves all entries of the customer that still carry the old address, plus their
|
||||
* customer abos, to the new address.
|
||||
*
|
||||
* @return array{shopping_users: int, abos: int}
|
||||
*/
|
||||
private function apply(ShoppingUser $shoppingUser, string $oldEmail, string $newEmail): array
|
||||
{
|
||||
$shoppingUserIds = ShoppingUser::query()
|
||||
->where('billing_email', $oldEmail)
|
||||
->when(
|
||||
$shoppingUser->number > 0,
|
||||
fn ($query) => $query->where('number', $shoppingUser->number),
|
||||
fn ($query) => $query->whereKey($shoppingUser->id),
|
||||
)
|
||||
->pluck('id')
|
||||
->push($shoppingUser->id)
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
$updatedShoppingUsers = ShoppingUser::whereIn('id', $shoppingUserIds)->update(['billing_email' => $newEmail]);
|
||||
|
||||
$updatedAbos = UserAbo::query()
|
||||
->whereIn('shopping_user_id', $shoppingUserIds)
|
||||
->where('is_for', 'ot')
|
||||
->where('email', $oldEmail)
|
||||
->update(['email' => $newEmail]);
|
||||
|
||||
$shoppingUser->billing_email = $newEmail;
|
||||
|
||||
Log::info('CustomerEmailChange: E-Mail geändert', [
|
||||
'shopping_user_id' => $shoppingUser->id,
|
||||
'number' => $shoppingUser->number,
|
||||
'old_email' => $oldEmail,
|
||||
'new_email' => $newEmail,
|
||||
'shopping_users' => $updatedShoppingUsers,
|
||||
'abos' => $updatedAbos,
|
||||
]);
|
||||
|
||||
return ['shopping_users' => $updatedShoppingUsers, 'abos' => $updatedAbos];
|
||||
}
|
||||
|
||||
/**
|
||||
* Customers (with a customer number) of other consultants that use the address.
|
||||
*
|
||||
* @return Collection<int, ShoppingUser>
|
||||
*/
|
||||
private function customersOfOtherConsultants(ShoppingUser $shoppingUser, string $email): Collection
|
||||
{
|
||||
return ShoppingUser::query()
|
||||
->with('member')
|
||||
->whereNull('auth_user_id')
|
||||
->whereNotNull('number')
|
||||
->whereNotNull('member_id')
|
||||
->when($shoppingUser->member_id, fn ($query) => $query->where('member_id', '!=', $shoppingUser->member_id))
|
||||
->where('billing_email', $email)
|
||||
->withoutGdprDeleted()
|
||||
->get();
|
||||
}
|
||||
|
||||
private function hasPlaceholderEmail(ShoppingUser $shoppingUser): bool
|
||||
{
|
||||
return (bool) $shoppingUser->faker_mail
|
||||
|| blank($shoppingUser->billing_email)
|
||||
|| str_ends_with((string) $shoppingUser->billing_email, '-faker@mivita.care');
|
||||
}
|
||||
|
||||
private function sameEmail(?string $a, ?string $b): bool
|
||||
{
|
||||
return mb_strtolower(trim((string) $a)) === mb_strtolower(trim((string) $b));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,88 +1,90 @@
|
|||
<?php
|
||||
namespace App\Services;
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Mail\MailCheckout;
|
||||
use App\Mail\MailInfo;
|
||||
use App\Models\ShoppingUser;
|
||||
use App\Services\BusinessPlan\SalesPointsVolume;
|
||||
use App\Services\Shop;
|
||||
use App\User;
|
||||
use Auth;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
|
||||
|
||||
class CustomerPriority
|
||||
{
|
||||
|
||||
protected static $start_number = 1000;
|
||||
|
||||
protected static $user_notice_key = 'like';
|
||||
|
||||
public static function checkForAll(){
|
||||
//only extern no members with no numbers
|
||||
$shopping_users = ShoppingUser::where('auth_user_id', '=', NULL)->where('number', '=', NULL)->orderBy('created_at', 'ASC')->get();
|
||||
foreach ($shopping_users as $shopping_user){
|
||||
if($shopping_user->shopping_order && $shopping_user->shopping_order->user_shop){
|
||||
public static function checkForAll()
|
||||
{
|
||||
// only extern no members with no numbers
|
||||
$shopping_users = ShoppingUser::where('auth_user_id', '=', null)->where('number', '=', null)->orderBy('created_at', 'ASC')->get();
|
||||
foreach ($shopping_users as $shopping_user) {
|
||||
if ($shopping_user->shopping_order && $shopping_user->shopping_order->user_shop) {
|
||||
self::checkOne($shopping_user);
|
||||
}
|
||||
}
|
||||
|
||||
return $shopping_users;
|
||||
}
|
||||
|
||||
public static function checkOne($shopping_user, $mail=false, $newCustomer = true, $entryExistsLike = false){
|
||||
//ist die Mail ein Berater, dann sich selbst zuweisen.
|
||||
if(self::entryIsMember($shopping_user)){
|
||||
public static function checkOne($shopping_user, $mail = false, $newCustomer = true, $entryExistsLike = false)
|
||||
{
|
||||
// ist die Mail ein Berater, dann sich selbst zuweisen.
|
||||
if (self::entryIsMember($shopping_user)) {
|
||||
return 'self';
|
||||
}
|
||||
//look for entry
|
||||
if($entryExistsLike){
|
||||
if(self::entryExistsLike($shopping_user)){ //existiert die Email bei einem anderem member?
|
||||
if($mail){ //send mail
|
||||
// look for entry
|
||||
if ($entryExistsLike) {
|
||||
if (self::entryExistsLike($shopping_user)) { // existiert die Email bei einem anderem member?
|
||||
if ($mail) { // send mail
|
||||
Mail::to(config('app.priority_mail'))->send(new MailInfo($shopping_user, 'check_is_like_customer'));
|
||||
}
|
||||
|
||||
return 'exists';
|
||||
}
|
||||
}else{
|
||||
if(self::entryExists($shopping_user)){ //Berater zuweisen, wenn die Mail existiert
|
||||
} else {
|
||||
if (self::entryExists($shopping_user)) { // Berater zuweisen, wenn die Mail existiert
|
||||
return 'exists';
|
||||
}
|
||||
}
|
||||
if(self::entryLike($shopping_user)){ //existiert die Adresse bei einem anderem member?
|
||||
if($mail){ //send mail
|
||||
if (self::entryLike($shopping_user)) { // existiert die Adresse bei einem anderem member?
|
||||
if ($mail) { // send mail
|
||||
Mail::to(config('app.priority_mail'))->send(new MailInfo($shopping_user, 'check_is_like_customer'));
|
||||
}
|
||||
|
||||
return 'like'.$shopping_user->member_id;
|
||||
}
|
||||
if($newCustomer){
|
||||
if ($newCustomer) {
|
||||
self::newCustomer($shopping_user);
|
||||
}
|
||||
|
||||
return 'new';
|
||||
|
||||
}
|
||||
|
||||
public static function setIsLike($shopping_user, $set_like_shopping_user, $send_member_mail, $change_shopping_user=false)
|
||||
public static function setIsLike($shopping_user, $set_like_shopping_user, $send_member_mail, $change_shopping_user = false)
|
||||
{
|
||||
if ($shopping_user->id === $set_like_shopping_user->id) {
|
||||
//set new customer for shopping_user
|
||||
if($change_shopping_user){
|
||||
// set new customer for shopping_user
|
||||
if ($change_shopping_user) {
|
||||
self::newCustomerNumber($shopping_user);
|
||||
$send_member_mail = false;
|
||||
}else{
|
||||
} else {
|
||||
self::newCustomer($shopping_user);
|
||||
}
|
||||
} else {
|
||||
//set existing customer for shopping_user
|
||||
// set existing customer for shopping_user
|
||||
self::existingCustomer($shopping_user, $set_like_shopping_user);
|
||||
}
|
||||
self::clearIsLike($shopping_user);
|
||||
//SEND MAIL TO MEMBER
|
||||
if ($send_member_mail){
|
||||
// SEND MAIL TO MEMBER
|
||||
if ($send_member_mail) {
|
||||
if ($shopping_user->shopping_order && $shopping_user->shopping_order->shopping_payments) {
|
||||
Mail::to($shopping_user->member->email)->send(new MailCheckout($shopping_user->shopping_order->txaction, $shopping_user->shopping_order, $shopping_user->shopping_order->shopping_payments->last(), false, $shopping_user->shopping_order->mode));
|
||||
}
|
||||
}
|
||||
//set Points and Volume
|
||||
// set Points and Volume
|
||||
if ($shopping_user->shopping_order && $user_sales_volume = $shopping_user->shopping_order->user_sales_volume_no_userid()) {
|
||||
SalesPointsVolume::setToUserAndReCalculate($user_sales_volume, $shopping_user->member_id);
|
||||
}
|
||||
|
|
@ -90,19 +92,20 @@ class CustomerPriority
|
|||
return true;
|
||||
}
|
||||
|
||||
public static function newMemberForOrder($shopping_order, $member_id, Bool $all = false){
|
||||
if($all){
|
||||
if($shopping_order->shopping_user && $shopping_order->shopping_user->number){
|
||||
public static function newMemberForOrder($shopping_order, $member_id, bool $all = false)
|
||||
{
|
||||
if ($all) {
|
||||
if ($shopping_order->shopping_user && $shopping_order->shopping_user->number) {
|
||||
$shopping_users = ShoppingUser::where('number', '=', $shopping_order->shopping_user->number)->get();
|
||||
$new_number = self::nextNumber();
|
||||
foreach ($shopping_users as $shopping_user) {
|
||||
self::changeCustomer($shopping_user, $member_id, $new_number);
|
||||
}
|
||||
}
|
||||
}else{
|
||||
} else {
|
||||
$shopping_order->member_id = $member_id;
|
||||
$shopping_order->save();
|
||||
if($shopping_order->shopping_user){
|
||||
if ($shopping_order->shopping_user) {
|
||||
$shopping_order->shopping_user->member_id = $member_id;
|
||||
$shopping_order->shopping_user->number = self::nextNumber();
|
||||
$shopping_order->shopping_user->save();
|
||||
|
|
@ -110,60 +113,62 @@ class CustomerPriority
|
|||
}
|
||||
}
|
||||
|
||||
public static function newMemberForCustomer($shopping_user, $member_id, Bool $all = false){
|
||||
if($all){
|
||||
if($shopping_user && $shopping_user->number){
|
||||
public static function newMemberForCustomer($shopping_user, $member_id, bool $all = false)
|
||||
{
|
||||
if ($all) {
|
||||
if ($shopping_user && $shopping_user->number) {
|
||||
$shopping_users = ShoppingUser::where('number', '=', $shopping_user->number)->get();
|
||||
$new_number = self::nextNumber();
|
||||
foreach ($shopping_users as $s_user) {
|
||||
self::changeCustomer($s_user, $member_id, $new_number);
|
||||
}
|
||||
}
|
||||
}else{
|
||||
} else {
|
||||
$shopping_user->member_id = $member_id;
|
||||
$shopping_user->number = self::nextNumber();
|
||||
$shopping_user->save();
|
||||
if($shopping_user->shopping_order){
|
||||
if ($shopping_user->shopping_order) {
|
||||
$shopping_user->shopping_order->member_id = $member_id;
|
||||
$shopping_user->shopping_order->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static function checkChangeOne($shopping_user, $data, $mail=false){
|
||||
public static function checkChangeOne($shopping_user, $data, $mail = false)
|
||||
{
|
||||
|
||||
$matches = [];
|
||||
$change = [];
|
||||
$ret = 'update';
|
||||
|
||||
//email geändert
|
||||
if(isset($data['billing_email']) && $shopping_user->billing_email != $data['billing_email']){
|
||||
$found = ShoppingUser::where('auth_user_id', '=', NULL)
|
||||
->where('number', '!=', NULL) //has number
|
||||
// email geändert
|
||||
if (isset($data['billing_email']) && $shopping_user->billing_email != $data['billing_email']) {
|
||||
$found = ShoppingUser::where('auth_user_id', '=', null)
|
||||
->where('number', '!=', null) // has number
|
||||
->where('id', '!=', $shopping_user->id)
|
||||
->where('billing_email', '=', $data['billing_email'])
|
||||
->where('member_id', '!=', $shopping_user->member_id)
|
||||
->get()->pluck('number', 'id')->unique()->toArray();
|
||||
if($found && count($found)){
|
||||
foreach ($found as $key=>$val){
|
||||
if ($found && count($found)) {
|
||||
foreach ($found as $key => $val) {
|
||||
$matches[$key] = $val;
|
||||
}
|
||||
$ret = 'exists';
|
||||
$change['billing_email'] = $data['billing_email'];
|
||||
}
|
||||
}
|
||||
//Anschrift geändert
|
||||
if(isset($data['billing_lastname']) && isset($data['billing_zipcode']) && ($shopping_user->billing_lastname != $data['billing_lastname'] || $shopping_user->billing_zipcode != $data['billing_zipcode'])){
|
||||
// Anschrift geändert
|
||||
if (isset($data['billing_lastname']) && isset($data['billing_zipcode']) && ($shopping_user->billing_lastname != $data['billing_lastname'] || $shopping_user->billing_zipcode != $data['billing_zipcode'])) {
|
||||
$found = ShoppingUser::select('*')
|
||||
->where('auth_user_id', '=', NULL)
|
||||
->where('number', '!=', NULL) //has number
|
||||
->where('auth_user_id', '=', null)
|
||||
->where('number', '!=', null) // has number
|
||||
->where('id', '!=', $shopping_user->id)
|
||||
->where('billing_lastname', '=', $data['billing_lastname'])
|
||||
->where('billing_zipcode', '=', $data['billing_zipcode'])
|
||||
->where('member_id', '!=', $shopping_user->member_id)
|
||||
->get()->pluck('number', 'id')->unique()->toArray();
|
||||
if($found && count($found)){
|
||||
foreach ($found as $key=>$val){
|
||||
if ($found && count($found)) {
|
||||
foreach ($found as $key => $val) {
|
||||
$matches[$key] = $val;
|
||||
}
|
||||
$ret = 'like';
|
||||
|
|
@ -171,13 +176,13 @@ class CustomerPriority
|
|||
$change['billing_zipcode'] = $data['billing_zipcode'];
|
||||
}
|
||||
}
|
||||
if($matches){
|
||||
if ($matches) {
|
||||
$shopping_user->is_like = true;
|
||||
$shopping_user->setNotice(self::$user_notice_key, $matches);
|
||||
$shopping_user->save();
|
||||
}
|
||||
//look for entry
|
||||
if($matches && $mail){ //send mail
|
||||
// look for entry
|
||||
if ($matches && $mail) { // send mail
|
||||
Mail::to(config('app.priority_mail'))->send(new MailInfo($shopping_user, 'change_is_like_customer', $change));
|
||||
}
|
||||
|
||||
|
|
@ -185,96 +190,116 @@ class CustomerPriority
|
|||
|
||||
}
|
||||
|
||||
|
||||
|
||||
public static function checkNewOne($shopping_user, $mail=false){
|
||||
if(self::entryLike($shopping_user)){
|
||||
if($mail){ //send mail
|
||||
public static function checkNewOne($shopping_user, $mail = false)
|
||||
{
|
||||
if (self::entryLike($shopping_user)) {
|
||||
if ($mail) { // send mail
|
||||
Mail::to(config('app.priority_mail'))->send(new MailInfo($shopping_user, 'check_is_like_customer'));
|
||||
}
|
||||
//return 'like';
|
||||
// return 'like';
|
||||
}
|
||||
$shopping_user->number = self::nextNumber();
|
||||
$shopping_user->save();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function entryIsMember($shopping_user){
|
||||
if($user = User::whereEmail($shopping_user->billing_email)->whereActive(1)->first()){
|
||||
public static function entryIsMember($shopping_user)
|
||||
{
|
||||
if ($user = User::whereEmail($shopping_user->billing_email)->whereActive(1)->first()) {
|
||||
$member_id = $user->id;
|
||||
$shopping_user->member_id = $member_id;
|
||||
$shopping_user->number = self::nextNumber();
|
||||
$shopping_user->save();
|
||||
if($shopping_user->shopping_order){
|
||||
if ($shopping_user->shopping_order) {
|
||||
$shopping_user->shopping_order->member_id = $member_id;
|
||||
$shopping_user->shopping_order->save();
|
||||
}
|
||||
return true;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static function entryExists($shopping_user){
|
||||
//check same email
|
||||
$matches = ShoppingUser::where('auth_user_id', '=', NULL)
|
||||
->where('number', '!=', NULL) //has number
|
||||
private static function entryExists($shopping_user)
|
||||
{
|
||||
// check same email: die älteste geprüfte Zuordnung gilt (Kundenhoheit). Einträge, die noch
|
||||
// in Prüfung sind (is_like), dürfen nicht als Vorlage dienen, sonst erbt der Kunde eine
|
||||
// ungeprüfte Zuordnung und bleibt nach der Prüfung beim falschen Berater hängen.
|
||||
$match = ShoppingUser::where('auth_user_id', '=', null)
|
||||
->where('number', '!=', null) // has number
|
||||
->whereNotNull('member_id')
|
||||
->where('id', '!=', $shopping_user->id)
|
||||
->where('billing_email', '=', $shopping_user->billing_email)->get();
|
||||
->where('billing_email', '=', $shopping_user->billing_email)
|
||||
->where(function ($query) {
|
||||
$query->where('is_like', false)->orWhereNull('is_like');
|
||||
})
|
||||
->orderBy('id')
|
||||
->first();
|
||||
|
||||
if($matches && count($matches)){
|
||||
$match = $matches->last();
|
||||
if ($match) {
|
||||
$shopping_user->member_id = $match->member_id;
|
||||
$shopping_user->number = $match->number;
|
||||
$shopping_user->save();
|
||||
//has order
|
||||
if($shopping_user->shopping_order){
|
||||
// has order
|
||||
if ($shopping_user->shopping_order) {
|
||||
$shopping_user->shopping_order->member_id = $match->member_id;
|
||||
$shopping_user->shopping_order->save();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static function entryExistsLike($shopping_user)
|
||||
{
|
||||
//existiert die Email bei einem anderem member?
|
||||
$matches = ShoppingUser::where('auth_user_id', '=', NULL)
|
||||
->where('number', '!=', NULL) //has number
|
||||
// existiert die Email bei einem anderem member?
|
||||
$matches = ShoppingUser::where('auth_user_id', '=', null)
|
||||
->where('number', '!=', null) // has number
|
||||
->where('id', '!=', $shopping_user->id)
|
||||
->where('member_id', '!=', $shopping_user->member_id)
|
||||
->where('billing_email', '=', $shopping_user->billing_email)
|
||||
->where('billing_email', '=', $shopping_user->billing_email)
|
||||
->get()->pluck('number', 'id')->unique()->toArray();
|
||||
|
||||
if($matches && count($matches)){
|
||||
$shopping_user->is_like = true;
|
||||
$shopping_user->setNotice(self::$user_notice_key, $matches);
|
||||
$shopping_user->save();
|
||||
return true;
|
||||
}
|
||||
if ($matches && count($matches)) {
|
||||
$shopping_user->is_like = true;
|
||||
$shopping_user->setNotice(self::$user_notice_key, $matches);
|
||||
$shopping_user->save();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
private static function entryLike($shopping_user){
|
||||
//check same last name und PLZ
|
||||
|
||||
private static function entryLike($shopping_user)
|
||||
{
|
||||
// check same last name und PLZ
|
||||
$matches = ShoppingUser::select('*')
|
||||
->where('auth_user_id', '=', NULL)
|
||||
->where('number', '!=', NULL) //has number
|
||||
->where('auth_user_id', '=', null)
|
||||
->where('number', '!=', null) // has number
|
||||
->where('id', '!=', $shopping_user->id)
|
||||
->where('member_id', '!=', $shopping_user->member_id)
|
||||
->where('billing_lastname', '=', $shopping_user->billing_lastname)
|
||||
->where('billing_zipcode', '=', $shopping_user->billing_zipcode)
|
||||
->get()->pluck('number', 'id')->unique()->toArray();
|
||||
if($matches && count($matches)){
|
||||
if ($matches && count($matches)) {
|
||||
$shopping_user->is_like = true;
|
||||
$shopping_user->setNotice(self::$user_notice_key, $matches);
|
||||
$shopping_user->save();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static function newCustomer($shopping_user){
|
||||
if($shopping_user->shopping_order && $shopping_user->shopping_order->user_shop) {
|
||||
private static function newCustomer($shopping_user)
|
||||
{
|
||||
if ($shopping_user->shopping_order && $shopping_user->shopping_order->user_shop) {
|
||||
$member_id = $shopping_user->shopping_order->user_shop->user_id;
|
||||
$shopping_user->member_id = $member_id;
|
||||
$shopping_user->number = self::nextNumber();
|
||||
|
|
@ -286,51 +311,63 @@ class CustomerPriority
|
|||
|
||||
private static function newCustomerNumber($shopping_user)
|
||||
{
|
||||
\App\Services\Shop::newUserOrder($shopping_user->number);
|
||||
Shop::newUserOrder($shopping_user->number);
|
||||
$shopping_user->number = self::nextNumber();
|
||||
$shopping_user->save();
|
||||
\App\Services\Shop::newUserOrder($shopping_user->number);
|
||||
Shop::newUserOrder($shopping_user->number);
|
||||
|
||||
}
|
||||
|
||||
private static function changeCustomer($shopping_user, $member_id, $number){
|
||||
private static function changeCustomer($shopping_user, $member_id, $number)
|
||||
{
|
||||
$old_number = $shopping_user->number;
|
||||
$shopping_user->member_id = $member_id;
|
||||
$shopping_user->number = $number;
|
||||
$shopping_user->save();
|
||||
if($shopping_user->shopping_order) {
|
||||
if ($shopping_user->shopping_order) {
|
||||
$shopping_user->shopping_order->member_id = $member_id;
|
||||
$shopping_user->shopping_order->save();
|
||||
}
|
||||
\App\Services\Shop::newUserOrder($old_number);
|
||||
\App\Services\Shop::newUserOrder($number);
|
||||
Shop::newUserOrder($old_number);
|
||||
Shop::newUserOrder($number);
|
||||
}
|
||||
|
||||
private static function existingCustomer($shopping_user, $set_like_shopping_user){
|
||||
private static function existingCustomer($shopping_user, $set_like_shopping_user)
|
||||
{
|
||||
$old_number = $shopping_user->number;
|
||||
$shopping_user->member_id = $set_like_shopping_user->member_id;
|
||||
$shopping_user->number = $set_like_shopping_user->number;
|
||||
$shopping_user->save();
|
||||
if($shopping_user->shopping_order) {
|
||||
$shopping_user->shopping_order->member_id = $set_like_shopping_user->member_id;
|
||||
if ($shopping_user->shopping_order) {
|
||||
$shopping_user->shopping_order->member_id = $set_like_shopping_user->member_id;
|
||||
$shopping_user->shopping_order->save();
|
||||
}
|
||||
\App\Services\Shop::newUserOrder($old_number);
|
||||
\App\Services\Shop::newUserOrder($set_like_shopping_user->number);
|
||||
Shop::newUserOrder($old_number);
|
||||
Shop::newUserOrder($set_like_shopping_user->number);
|
||||
}
|
||||
|
||||
private static function clearIsLike($shopping_user){
|
||||
/**
|
||||
* Prüffall abschließen, ohne Berater oder Kundennummer zu ändern (Übersicht Kundenhoheit).
|
||||
*/
|
||||
public static function keepAssignment(ShoppingUser $shopping_user): void
|
||||
{
|
||||
self::clearIsLike($shopping_user);
|
||||
}
|
||||
|
||||
private static function clearIsLike($shopping_user)
|
||||
{
|
||||
$shopping_user->is_like = false;
|
||||
$shopping_user->removeNotice(self::$user_notice_key);
|
||||
$shopping_user->save();
|
||||
|
||||
}
|
||||
|
||||
private static function nextNumber (){
|
||||
if(!$number = ShoppingUser::max('number')){
|
||||
private static function nextNumber()
|
||||
{
|
||||
if (! $number = ShoppingUser::max('number')) {
|
||||
$number = self::$start_number;
|
||||
}
|
||||
return $number+1;
|
||||
}
|
||||
|
||||
return $number + 1;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
140
app/Services/CustomerPriorityOverviewService.php
Normal file
140
app/Services/CustomerPriorityOverviewService.php
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\ShoppingUser;
|
||||
use App\Models\ShoppingUserEmailChange;
|
||||
use App\User;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Open work of the customer sovereignty (Kundenhoheit) for the admin overview: customers
|
||||
* waiting for review, customers that ended up with several consultants, and e-mail changes
|
||||
* that were rejected or are waiting for the customer's confirmation.
|
||||
*/
|
||||
class CustomerPriorityOverviewService
|
||||
{
|
||||
/**
|
||||
* @return Builder<ShoppingUser>
|
||||
*/
|
||||
public function reviewsQuery(): Builder
|
||||
{
|
||||
return ShoppingUser::query()
|
||||
->select('shopping_users.*')
|
||||
->with(['member.account', 'shopping_order.user_shop.user.account'])
|
||||
->where('is_like', true)
|
||||
->withoutGdprDeleted();
|
||||
}
|
||||
|
||||
/**
|
||||
* Customers (by e-mail) whose entries belong to more than one consultant. Customers still
|
||||
* under review are listed there; addresses of consultant accounts are left out, because a
|
||||
* customer who became a consultant gets their own orders assigned on purpose.
|
||||
*
|
||||
* @return Collection<int, array{email: string, name: string, last_at: string, groups: list<array{member_id: int, member: User|null, number: int, entries: int, latest_id: int, last_at: string, oldest: bool}>}>
|
||||
*/
|
||||
public function conflicts(): Collection
|
||||
{
|
||||
$emailKeys = $this->customerEntries()
|
||||
->selectRaw('LOWER(billing_email) as email_key')
|
||||
->groupBy(DB::raw('LOWER(billing_email)'))
|
||||
->havingRaw('COUNT(DISTINCT member_id) > 1')
|
||||
->havingRaw('MAX(CASE WHEN is_like = 1 THEN 1 ELSE 0 END) = 0')
|
||||
->pluck('email_key');
|
||||
|
||||
if ($emailKeys->isEmpty()) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$consultantEmails = User::query()
|
||||
->whereIn(DB::raw('LOWER(email)'), $emailKeys)
|
||||
->pluck('email')
|
||||
->map(fn (string $email) => mb_strtolower($email));
|
||||
$emailKeys = $emailKeys->diff($consultantEmails);
|
||||
|
||||
return $this->customerEntries()
|
||||
->with('member.account')
|
||||
->whereIn(DB::raw('LOWER(billing_email)'), $emailKeys)
|
||||
->orderBy('id')
|
||||
->get()
|
||||
->groupBy(fn (ShoppingUser $entry) => mb_strtolower($entry->billing_email))
|
||||
->map(function (Collection $entries, string $email) {
|
||||
$groups = $entries
|
||||
->groupBy(fn (ShoppingUser $entry) => $entry->member_id.'-'.$entry->number)
|
||||
->values()
|
||||
->map(fn (Collection $group, int $index) => [
|
||||
'member_id' => $group->first()->member_id,
|
||||
'member' => $group->first()->member,
|
||||
'number' => $group->first()->number,
|
||||
'entries' => $group->count(),
|
||||
'latest_id' => $group->last()->id,
|
||||
'last_at' => $group->last()->created_at->format('Y-m-d H:i'),
|
||||
'oldest' => $index === 0,
|
||||
])
|
||||
->all();
|
||||
$latest = $entries->last();
|
||||
|
||||
return [
|
||||
'email' => $email,
|
||||
'name' => trim($latest->billing_firstname.' '.$latest->billing_lastname),
|
||||
'last_at' => $latest->created_at->format('Y-m-d H:i'),
|
||||
'groups' => $groups,
|
||||
];
|
||||
})
|
||||
->sortByDesc('last_at')
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Builder<ShoppingUserEmailChange>
|
||||
*/
|
||||
public function emailChangesQuery(): Builder
|
||||
{
|
||||
return ShoppingUserEmailChange::query()
|
||||
->select('shopping_user_email_changes.*')
|
||||
->with(['shopping_user.member', 'requested_by'])
|
||||
->where(function (Builder $query) {
|
||||
$query->blockedOpen()->orWhere(fn (Builder $pending) => $pending->pending());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts for the menu badge (cheap queries only).
|
||||
*/
|
||||
public function openCount(): int
|
||||
{
|
||||
return $this->reviewsQuery()->count() + ShoppingUserEmailChange::blockedOpen()->count();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{reviews: int, conflicts: int, email_changes: int}
|
||||
*/
|
||||
public function counts(): array
|
||||
{
|
||||
return [
|
||||
'reviews' => $this->reviewsQuery()->count(),
|
||||
'conflicts' => $this->conflicts()->count(),
|
||||
'email_changes' => $this->emailChangesQuery()->count(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer entries that take part in the customer sovereignty.
|
||||
*
|
||||
* @return Builder<ShoppingUser>
|
||||
*/
|
||||
private function customerEntries(): Builder
|
||||
{
|
||||
return ShoppingUser::query()
|
||||
->whereNull('auth_user_id')
|
||||
->whereNotNull('number')
|
||||
->whereNotNull('member_id')
|
||||
->whereNotNull('billing_email')
|
||||
->where('billing_email', '!=', '')
|
||||
->where('billing_email', 'not like', '%-faker@mivita.care')
|
||||
->where(fn (Builder $query) => $query->where('faker_mail', false)->orWhereNull('faker_mail'))
|
||||
->withoutGdprDeleted();
|
||||
}
|
||||
}
|
||||
69
app/Services/FormSpamGuard.php
Normal file
69
app/Services/FormSpamGuard.php
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Contracts\Encryption\DecryptException;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Unsichtbare Bot-Erkennung für öffentliche Formulare (Kontakt, Registrierung):
|
||||
* Honeypot-Feld und Mindest-Ausfüllzeit.
|
||||
*
|
||||
* Erkannte Bots bekommen trotzdem die Erfolgsseite, damit sie nichts dazulernen.
|
||||
*/
|
||||
class FormSpamGuard
|
||||
{
|
||||
public const HONEYPOT_FIELD = 'form_extra';
|
||||
|
||||
public const TIMESTAMP_FIELD = 'form_token';
|
||||
|
||||
public const MIN_SECONDS = 3;
|
||||
|
||||
public function makeFormToken(): string
|
||||
{
|
||||
return encrypt(now()->timestamp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert den Grund, warum die Anfrage als Bot gilt, oder null.
|
||||
*/
|
||||
public function blockReason(Request $request): ?string
|
||||
{
|
||||
if (filled($request->input(self::HONEYPOT_FIELD))) {
|
||||
return 'honeypot';
|
||||
}
|
||||
|
||||
try {
|
||||
$startedAt = (int) decrypt((string) $request->input(self::TIMESTAMP_FIELD));
|
||||
} catch (DecryptException) {
|
||||
return 'invalid_form_token';
|
||||
}
|
||||
|
||||
if (now()->timestamp - $startedAt < self::MIN_SECONDS) {
|
||||
return 'too_fast';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Protokolliert eine abgefangene Anfrage ohne Freitext-Inhalte.
|
||||
*
|
||||
* @param 'contact'|'register' $form
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public static function log(Request $request, string $form, string $reason, array $context = []): void
|
||||
{
|
||||
Log::channel('form_spam')->info('Formular abgefangen', [
|
||||
'form' => $form,
|
||||
'reason' => $reason,
|
||||
'ip' => $request->ip(),
|
||||
'host' => $request->getHost(),
|
||||
'user_shop_id' => Util::getUserShop()?->id,
|
||||
'email_domain' => Str::after((string) $request->input('email'), '@') ?: null,
|
||||
...$context,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ use App\Models\UserAboOrder;
|
|||
use App\Models\UserSalesVolume;
|
||||
use App\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class IncentiveTracker
|
||||
|
|
@ -414,9 +415,37 @@ class IncentiveTracker
|
|||
}
|
||||
}
|
||||
|
||||
if ($incentive->includesSalesPoints()) {
|
||||
foreach (self::latestCountedSalesVolumeAt($participantIds) as $participantId => $timestamp) {
|
||||
$reached[$participantId] = max($reached[$participantId] ?? 0, Carbon::parse($timestamp)->getTimestamp());
|
||||
}
|
||||
}
|
||||
|
||||
return $reached;
|
||||
}
|
||||
|
||||
/**
|
||||
* Juengste gezaehlte Umsatzbuchung je Teilnehmer, gemessen am Buchungszeitpunkt des
|
||||
* Sales Volumes (bleibt beim Neuaufbau der Logs stabil).
|
||||
*
|
||||
* @param list<int> $participantIds
|
||||
* @return Collection<int, string> Teilnehmer-ID => Zeitstempel
|
||||
*/
|
||||
private static function latestCountedSalesVolumeAt(array $participantIds): Collection
|
||||
{
|
||||
$logTable = (new IncentivePointsLog)->getTable();
|
||||
$volumeTable = (new UserSalesVolume)->getTable();
|
||||
|
||||
return IncentivePointsLog::query()
|
||||
->join($volumeTable, "{$volumeTable}.id", '=', "{$logTable}.user_sales_volume_id")
|
||||
->whereIn("{$logTable}.participant_id", $participantIds)
|
||||
->where("{$logTable}.is_storno", false)
|
||||
->where("{$logTable}.points_accumulated", '>', 0)
|
||||
->groupBy("{$logTable}.participant_id")
|
||||
->selectRaw("{$logTable}.participant_id as participant_id, MAX({$volumeTable}.created_at) as reached_at")
|
||||
->pluck('reached_at', 'participant_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Log-Eintrag schreiben (Audit-Trail, keine Duplikate).
|
||||
*/
|
||||
|
|
|
|||
402
app/Services/Team/RecognitionService.php
Normal file
402
app/Services/Team/RecognitionService.php
Normal file
|
|
@ -0,0 +1,402 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Team;
|
||||
|
||||
use App\Models\RecognitionSnapshot;
|
||||
use App\Models\UserSalesVolume;
|
||||
use App\Services\Abo\TeamAboOverviewService;
|
||||
use App\Services\Backoffice\SalesVolumePoints;
|
||||
use App\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* Anerkennungen des Monats: firmenweite Ranglisten in fünf Kategorien, wie sie
|
||||
* bisher monatlich per Mail verschickt wurden (dev/2026-08-12-intensiv-tickets).
|
||||
*
|
||||
* Die Kennzahlen folgen denselben Regeln wie die teaminterne Top-3-Karte
|
||||
* ({@see TopResultsService}) und die VIP-Statistik, nur über alle Berater.
|
||||
* Ein abgeschlossener Monat wird am {@see self::FREEZE_DAY}. des Folgemonats
|
||||
* eingefroren ({@see self::freeze()}); bis dahin ist er vorläufig, damit
|
||||
* Nachbuchungen und Nacht-Repairs noch einfließen.
|
||||
*/
|
||||
class RecognitionService
|
||||
{
|
||||
public const CATEGORIES = [
|
||||
'recruiters',
|
||||
'new_customer_abos',
|
||||
'customer_abo_points',
|
||||
'customer_abos_total',
|
||||
'total_points',
|
||||
];
|
||||
|
||||
public const FIRST_YEAR = 2026;
|
||||
|
||||
public const FIRST_MONTH = 1;
|
||||
|
||||
public const FREEZE_DAY = 3;
|
||||
|
||||
public const LIVE_LIMIT = 10;
|
||||
|
||||
private const DEFAULT_PODIUM_PLACES = 3;
|
||||
|
||||
/**
|
||||
* Abweichende Platzzahl je Kategorie — die Mail führte beim
|
||||
* 1.000-Punkte-Shop immer einen Platz 4 mit.
|
||||
*/
|
||||
private const PODIUM_PLACES = [
|
||||
'total_points' => 4,
|
||||
];
|
||||
|
||||
/**
|
||||
* Mindestwert für einen Platz: bei den Recruitern teilen sich sonst oft
|
||||
* zehn und mehr Partner mit je einem Neupartner einen Platz.
|
||||
*/
|
||||
private const MIN_VALUES = [
|
||||
'recruiters' => 2,
|
||||
];
|
||||
|
||||
private const CACHE_MINUTES = 15;
|
||||
|
||||
public function __construct(private TeamAboOverviewService $teamAboOverview) {}
|
||||
|
||||
/**
|
||||
* Anerkennungen eines abgeschlossenen Monats: eingefroren, falls vorhanden,
|
||||
* sonst vorläufig berechnet.
|
||||
*
|
||||
* @return array{month: int, year: int, is_final: bool, frozen_at: Carbon|null, freeze_date: Carbon, categories: array<string, array<int, array{place: int, value: float|int, members: array<int, array<string, mixed>>}>>}
|
||||
*/
|
||||
public function recognitions(int $month, int $year): array
|
||||
{
|
||||
$snapshot = RecognitionSnapshot::query()
|
||||
->where('year', $year)
|
||||
->where('month', $month)
|
||||
->first();
|
||||
|
||||
$categories = $snapshot
|
||||
? $snapshot->payload['categories']
|
||||
: Cache::remember(
|
||||
self::cacheKey('podium', $month, $year),
|
||||
now()->addMinutes(self::CACHE_MINUTES),
|
||||
fn () => $this->calculate($month, $year)['categories']
|
||||
);
|
||||
|
||||
return [
|
||||
'month' => $month,
|
||||
'year' => $year,
|
||||
'is_final' => $snapshot !== null,
|
||||
'frozen_at' => $snapshot?->frozen_at,
|
||||
'freeze_date' => self::freezeDate($month, $year),
|
||||
'categories' => $this->withProfileImages($categories, 'members'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Live-Ranglisten (Top {@see self::LIVE_LIMIT}) je Kategorie, 15 Minuten gecacht.
|
||||
*
|
||||
* @return array{month: int, year: int, calculated_at: Carbon, categories: array<string, array<int, array<string, mixed>>>}
|
||||
*/
|
||||
public function liveRankings(int $month, int $year): array
|
||||
{
|
||||
$cached = Cache::remember(
|
||||
self::cacheKey('live', $month, $year),
|
||||
now()->addMinutes(self::CACHE_MINUTES),
|
||||
fn () => [
|
||||
'calculated_at' => now()->toIso8601String(),
|
||||
'categories' => $this->calculate($month, $year)['rankings'],
|
||||
]
|
||||
);
|
||||
|
||||
return [
|
||||
'month' => $month,
|
||||
'year' => $year,
|
||||
'calculated_at' => Carbon::parse($cached['calculated_at']),
|
||||
'categories' => $this->withProfileImages($cached['categories']),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Friert einen abgeschlossenen Monat ein. Ein vorhandener Snapshot bleibt
|
||||
* ohne $force unangetastet.
|
||||
*/
|
||||
public function freeze(int $month, int $year, bool $force = false): bool
|
||||
{
|
||||
$exists = RecognitionSnapshot::query()->where('year', $year)->where('month', $month)->exists();
|
||||
|
||||
if ($exists && ! $force) {
|
||||
return false;
|
||||
}
|
||||
|
||||
RecognitionSnapshot::query()->updateOrCreate(
|
||||
['year' => $year, 'month' => $month],
|
||||
['payload' => $this->calculate($month, $year), 'frozen_at' => now()]
|
||||
);
|
||||
|
||||
Cache::forget(self::cacheKey('podium', $month, $year));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ab diesem Zeitpunkt gilt ein Monat als festgeschrieben.
|
||||
*/
|
||||
public static function freezeDate(int $month, int $year): Carbon
|
||||
{
|
||||
return Carbon::create($year, $month, 1)->addMonthNoOverflow()->day(self::FREEZE_DAY)->startOfDay();
|
||||
}
|
||||
|
||||
public static function isFreezable(int $month, int $year): bool
|
||||
{
|
||||
return now()->gte(self::freezeDate($month, $year));
|
||||
}
|
||||
|
||||
/**
|
||||
* Abgeschlossene Monate von heute rückwärts bis zum ersten Anerkennungsmonat.
|
||||
*
|
||||
* @return array<int, array{month: int, year: int}>
|
||||
*/
|
||||
public static function closedMonths(): array
|
||||
{
|
||||
$months = [];
|
||||
$cursor = now()->startOfMonth()->subMonthNoOverflow();
|
||||
$first = Carbon::create(self::FIRST_YEAR, self::FIRST_MONTH, 1);
|
||||
|
||||
while ($cursor->gte($first)) {
|
||||
$months[] = ['month' => (int) $cursor->month, 'year' => (int) $cursor->year];
|
||||
$cursor->subMonthNoOverflow();
|
||||
}
|
||||
|
||||
return $months;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{categories: array<string, array<int, array<string, mixed>>>, rankings: array<string, array<int, array<string, mixed>>>}
|
||||
*/
|
||||
private function calculate(int $month, int $year): array
|
||||
{
|
||||
$userIds = $this->eligibleUserIds();
|
||||
$result = ['categories' => [], 'rankings' => []];
|
||||
|
||||
foreach (self::CATEGORIES as $category) {
|
||||
$entries = $this->sortedEntries($this->categoryValues($category, $userIds, $month, $year));
|
||||
|
||||
$result['categories'][$category] = self::podium(
|
||||
$entries,
|
||||
self::PODIUM_PLACES[$category] ?? self::DEFAULT_PODIUM_PLACES,
|
||||
self::MIN_VALUES[$category] ?? 0
|
||||
);
|
||||
$result['rankings'][$category] = self::ranking($entries, self::LIVE_LIMIT);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle Berater außer den nie aufgeführten Konten (config mivita.recognition_excluded_user_ids).
|
||||
*
|
||||
* @return int[]
|
||||
*/
|
||||
private function eligibleUserIds(): array
|
||||
{
|
||||
return User::query()
|
||||
->whereNotNull('m_level')
|
||||
->whereNull('deleted_at')
|
||||
->whereNotIn('id', config('mivita.recognition_excluded_user_ids', []))
|
||||
->whereHas('account')
|
||||
->pluck('id')
|
||||
->map(fn ($id) => (int) $id)
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int[] $userIds
|
||||
* @return array<int, float|int> user_id => Wert (> 0)
|
||||
*/
|
||||
private function categoryValues(string $category, array $userIds, int $month, int $year): array
|
||||
{
|
||||
if ($userIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$values = match ($category) {
|
||||
'recruiters' => User::query()
|
||||
->firstlinesOf($userIds)
|
||||
->newPartnerIn($month, $year)
|
||||
->groupBy('m_sponsor')
|
||||
->selectRaw('m_sponsor, COUNT(*) as value')
|
||||
->pluck('value', 'm_sponsor')
|
||||
->map(fn ($value) => (int) $value),
|
||||
'new_customer_abos' => $this->teamAboOverview
|
||||
->aboStockQuery($userIds, 'member_id', 'ot', $month, $year)
|
||||
->whereBetween('start_date', [
|
||||
Carbon::create($year, $month, 1)->startOfMonth(),
|
||||
Carbon::create($year, $month, 1)->endOfMonth(),
|
||||
])
|
||||
->groupBy('member_id')
|
||||
->selectRaw('member_id, COUNT(*) as value')
|
||||
->pluck('value', 'member_id')
|
||||
->map(fn ($value) => (int) $value),
|
||||
'customer_abos_total' => $this->teamAboOverview
|
||||
->aboStockQuery($userIds, 'member_id', 'ot', $month, $year)
|
||||
->groupBy('member_id')
|
||||
->selectRaw('member_id, COUNT(*) as value')
|
||||
->pluck('value', 'member_id')
|
||||
->map(fn ($value) => (int) $value),
|
||||
'customer_abo_points' => UserSalesVolume::query()
|
||||
->leftJoin('shopping_orders', 'shopping_orders.id', '=', 'user_sales_volumes.shopping_order_id')
|
||||
->whereIn('user_sales_volumes.user_id', $userIds)
|
||||
->where('user_sales_volumes.month', $month)
|
||||
->where('user_sales_volumes.year', $year)
|
||||
->groupBy('user_sales_volumes.user_id')
|
||||
->selectRaw('user_sales_volumes.user_id, '.SalesVolumePoints::customerAboPoints().' as value')
|
||||
->pluck('value', 'user_id')
|
||||
->map(fn ($value) => round((float) $value, 2)),
|
||||
'total_points' => SalesVolumePoints::totalPointsByUser($userIds, $month, $year)
|
||||
->map(fn (float $points) => round($points, 2)),
|
||||
};
|
||||
|
||||
return $values->filter(fn ($value) => $value > 0)->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wert absteigend, bei Gleichstand nach Name und zuletzt nach user_id, damit
|
||||
* die Reihenfolge reproduzierbar bleibt.
|
||||
*
|
||||
* @param array<int, float|int> $valuesByUserId
|
||||
* @return array<int, array{user_id: int, first_name: string, last_name: string, value: float|int}>
|
||||
*/
|
||||
private function sortedEntries(array $valuesByUserId): array
|
||||
{
|
||||
if ($valuesByUserId === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$users = User::query()
|
||||
->with('account')
|
||||
->whereIn('id', array_keys($valuesByUserId))
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
$entries = [];
|
||||
foreach ($valuesByUserId as $userId => $value) {
|
||||
$account = $users->get($userId)?->account;
|
||||
if (! $account) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$entries[] = [
|
||||
'user_id' => (int) $userId,
|
||||
'first_name' => trim((string) $account->first_name),
|
||||
'last_name' => trim((string) $account->last_name),
|
||||
'value' => $value,
|
||||
];
|
||||
}
|
||||
|
||||
usort($entries, fn (array $a, array $b) => [$b['value'], $a['first_name'], $a['last_name'], $a['user_id']]
|
||||
<=> [$a['value'], $b['first_name'], $b['last_name'], $b['user_id']]);
|
||||
|
||||
return $entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plätze wie in der Mail: Gleichstand teilt sich einen Platz, der nächste
|
||||
* Wert bekommt den nächsten Platz (1, 2, 2, 3 → Platz 1, 2, 3).
|
||||
*
|
||||
* @param array<int, array{user_id: int, first_name: string, last_name: string, value: float|int}> $entries
|
||||
* @return array<int, array{place: int, value: float|int, members: array<int, array{user_id: int, first_name: string, last_name: string}>}>
|
||||
*/
|
||||
private static function podium(array $entries, int $places, float|int $minValue): array
|
||||
{
|
||||
$podium = [];
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
if ($entry['value'] < $minValue) {
|
||||
break;
|
||||
}
|
||||
|
||||
$current = array_key_last($podium);
|
||||
if ($current === null || $podium[$current]['value'] != $entry['value']) {
|
||||
if (count($podium) >= $places) {
|
||||
break;
|
||||
}
|
||||
|
||||
$podium[] = ['place' => count($podium) + 1, 'value' => $entry['value'], 'members' => []];
|
||||
$current = array_key_last($podium);
|
||||
}
|
||||
|
||||
$podium[$current]['members'][] = [
|
||||
'user_id' => $entry['user_id'],
|
||||
'first_name' => $entry['first_name'],
|
||||
'last_name' => $entry['last_name'],
|
||||
];
|
||||
}
|
||||
|
||||
return $podium;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rangliste mit Wettkampf-Rang (1, 2, 2, 4), auf $limit Zeilen gekürzt.
|
||||
*
|
||||
* @param array<int, array{user_id: int, first_name: string, last_name: string, value: float|int}> $entries
|
||||
* @return array<int, array{rank: int, user_id: int, first_name: string, last_name: string, value: float|int}>
|
||||
*/
|
||||
private static function ranking(array $entries, int $limit): array
|
||||
{
|
||||
$rows = [];
|
||||
|
||||
foreach (array_slice($entries, 0, $limit) as $index => $entry) {
|
||||
$previous = $rows[$index - 1] ?? null;
|
||||
$rank = $previous !== null && $previous['value'] == $entry['value'] ? $previous['rank'] : $index + 1;
|
||||
|
||||
$rows[] = ['rank' => $rank] + $entry;
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Profilfotos erst beim Lesen ergänzen — nicht mit einfrieren oder cachen,
|
||||
* der Dateiname ändert sich bei jedem Upload ({@see TopResultsService}).
|
||||
*
|
||||
* @param array<string, array<int, array<string, mixed>>> $categories
|
||||
* @return array<string, array<int, array<string, mixed>>>
|
||||
*/
|
||||
private function withProfileImages(array $categories, ?string $nestedKey = null): array
|
||||
{
|
||||
$userIds = collect($categories)->flatten(1)
|
||||
->flatMap(fn (array $row) => $nestedKey ? $row[$nestedKey] : [$row])
|
||||
->pluck('user_id')
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if ($userIds === []) {
|
||||
return $categories;
|
||||
}
|
||||
|
||||
$imageUrls = User::query()
|
||||
->with('account')
|
||||
->whereIn('id', $userIds)
|
||||
->get()
|
||||
->mapWithKeys(fn (User $member) => [(int) $member->id => $member->account?->getProfileImageUrl()]);
|
||||
|
||||
$addImage = fn (array $row) => $row + ['profile_image_url' => $imageUrls->get((int) $row['user_id'])];
|
||||
|
||||
foreach ($categories as $category => $rows) {
|
||||
foreach ($rows as $index => $row) {
|
||||
if ($nestedKey) {
|
||||
$categories[$category][$index][$nestedKey] = array_map($addImage, $row[$nestedKey]);
|
||||
} else {
|
||||
$categories[$category][$index] = $addImage($row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $categories;
|
||||
}
|
||||
|
||||
private static function cacheKey(string $type, int $month, int $year): string
|
||||
{
|
||||
return "recognitions_{$type}_{$year}_{$month}";
|
||||
}
|
||||
}
|
||||
46
app/Services/TurnstileVerifier.php
Normal file
46
app/Services/TurnstileVerifier.php
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Prüft Cloudflare-Turnstile-Tokens (kostenloses Captcha) serverseitig.
|
||||
*
|
||||
* Schlägt die Prüfung technisch fehl (Cloudflare nicht erreichbar, kein Secret),
|
||||
* gilt das Token als ungültig.
|
||||
*/
|
||||
class TurnstileVerifier
|
||||
{
|
||||
public const VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
|
||||
|
||||
public function verify(?string $token, ?string $ip = null): bool
|
||||
{
|
||||
if (blank($token)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$secret = config('services.turnstile.secret_key');
|
||||
if (blank($secret)) {
|
||||
Log::channel('form_spam')->error('Turnstile: TURNSTILE_SECRET_KEY ist nicht gesetzt.');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$response = Http::asForm()->timeout(5)->post(self::VERIFY_URL, [
|
||||
'secret' => $secret,
|
||||
'response' => $token,
|
||||
'remoteip' => $ip,
|
||||
]);
|
||||
} catch (ConnectionException $e) {
|
||||
Log::channel('form_spam')->warning('Turnstile: Cloudflare nicht erreichbar.', ['error' => $e->getMessage()]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return $response->successful() && $response->json('success') === true;
|
||||
}
|
||||
}
|
||||
|
|
@ -96,6 +96,13 @@ return [
|
|||
'level' => env('LOG_LEVEL', 'info'),
|
||||
'days' => 30,
|
||||
],
|
||||
// Abgefangene Anfragen aus Kontakt- und Registrierungsformular (ohne Freitext)
|
||||
'form_spam' => [
|
||||
'driver' => 'daily',
|
||||
'path' => storage_path('logs/form-spam.log'),
|
||||
'level' => 'info',
|
||||
'days' => 30,
|
||||
],
|
||||
'stack' => [
|
||||
'driver' => 'stack',
|
||||
'channels' => ['single'],
|
||||
|
|
|
|||
|
|
@ -19,4 +19,12 @@ return [
|
|||
// Berater landen, sind User-Fehler über die Hauptwebseite — VP-Punkte
|
||||
// stehen immer dem Berater zu (Kundenentscheid 2026-08-19).
|
||||
'company_user_id' => (int) env('MIVITA_COMPANY_USER_ID', 6),
|
||||
|
||||
// Anerkennungen (Monats-Ranglisten im Salescenter): diese Konten werden nie
|
||||
// aufgeführt — Firmenkonto (6) und Großkundin Ruth Krummel (3), Kundenentscheid 2026-10-07;
|
||||
// Go4 Values (486), Kundenentscheid 2026-10-09.
|
||||
'recognition_excluded_user_ids' => array_values(array_filter(array_map(
|
||||
'intval',
|
||||
explode(',', (string) env('MIVITA_RECOGNITION_EXCLUDED_USER_IDS', '3,6,486'))
|
||||
))),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -37,6 +37,12 @@ return [
|
|||
'secret' => env('STRIPE_SECRET'),
|
||||
],
|
||||
|
||||
// Cloudflare Turnstile (kostenloses Captcha) für das Kontaktformular
|
||||
'turnstile' => [
|
||||
'site_key' => env('TURNSTILE_SITE_KEY'),
|
||||
'secret_key' => env('TURNSTILE_SECRET_KEY'),
|
||||
],
|
||||
|
||||
'vies' => [
|
||||
'enabled' => env('VIES_ENABLED', true),
|
||||
'timeout' => env('VIES_TIMEOUT', 10),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* E-Mail-Änderungen an Kunden (shopping_users): Eine vom Berater eingetragene neue
|
||||
* Adresse wird erst aktiv, wenn der Kunde sie über den Link in der Bestätigungsmail
|
||||
* bestätigt. Direkte Änderungen durch Admins werden mit confirmed_at protokolliert.
|
||||
* Wegen Kundenhoheit abgelehnte Änderungen (blocked_at) bleiben offen, bis ein Admin sie
|
||||
* in der Übersicht „Kundenhoheit“ als erledigt markiert (resolved_at).
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('shopping_user_email_changes', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->unsignedInteger('shopping_user_id')->index();
|
||||
$table->unsignedInteger('number')->nullable()->index();
|
||||
$table->string('old_email');
|
||||
$table->string('new_email');
|
||||
$table->string('token_hash', 64)->nullable()->unique();
|
||||
$table->timestamp('expires_at')->nullable();
|
||||
$table->timestamp('confirmed_at')->nullable();
|
||||
$table->timestamp('blocked_at')->nullable()->index();
|
||||
$table->timestamp('resolved_at')->nullable();
|
||||
$table->unsignedInteger('requested_by_user_id')->nullable();
|
||||
$table->unsignedInteger('resolved_by_user_id')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('shopping_user_email_changes');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('recognition_snapshots', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->unsignedSmallInteger('year');
|
||||
$table->unsignedTinyInteger('month');
|
||||
$table->json('payload');
|
||||
$table->timestamp('frozen_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['year', 'month'], 'recognition_snapshot_unique');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('recognition_snapshots');
|
||||
}
|
||||
};
|
||||
|
|
@ -9,12 +9,17 @@ use Illuminate\Database\Seeder;
|
|||
* Recruiting Challenge 9. Oktober 2026 bis 31. Januar 2027 (Entwicklungsplan E1).
|
||||
*
|
||||
* Legt die Challenge mit den abgestimmten Texten als Entwurf an oder aktualisiert
|
||||
* Texte, Uebersetzungen (EN/ES/FR) und den Qualifikationsbeginn, wenn sie schon
|
||||
* existiert (Schluessel: Slug). Der Beginn steht in den Texten und wird deshalb mit
|
||||
* ihnen abgeglichen; alle anderen Konfigurationswerte werden nur beim Anlegen gesetzt,
|
||||
* damit nachtraegliche Admin-Aenderungen erhalten bleiben.
|
||||
* Texte, Uebersetzungen (EN/ES/FR), den Qualifikationsbeginn und die Wertung, wenn sie
|
||||
* schon existiert (Schluessel: Slug). Beginn und Wertung stehen in den Texten und werden
|
||||
* deshalb mit ihnen abgeglichen; alle anderen Konfigurationswerte werden nur beim Anlegen
|
||||
* gesetzt, damit nachtraegliche Admin-Aenderungen erhalten bleiben.
|
||||
*
|
||||
* Wertung seit 08.10.2026: 50 EUR je Neupartner bleiben, die Rangliste fuer die Top 3
|
||||
* zaehlt aber den Umsatz der Neupartner (wie Montenegro, ohne Einmalpunkte und Abos).
|
||||
* Nach dem Einspielen die Punkte neu aufbauen:
|
||||
*
|
||||
* php artisan db:seed --class=RecruitingChallenge2026Seeder
|
||||
* php artisan incentive:calculate <ID> --force
|
||||
*
|
||||
* Danach im Admin pruefen und auf "Aktiv" stellen. Das Titelbild liegt unter
|
||||
* public/img/incentive/recruiting-challenge-2026-27/. Zur Abstimmung ist
|
||||
|
|
@ -54,12 +59,13 @@ class RecruitingChallenge2026Seeder extends Seeder
|
|||
'fr' => $this->termsFr(),
|
||||
],
|
||||
'qualification_start' => self::QUALIFICATION_START,
|
||||
'points_partner_onetime' => 0,
|
||||
'include_sales_points' => true,
|
||||
];
|
||||
|
||||
$config = [
|
||||
'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,
|
||||
|
|
@ -68,7 +74,6 @@ class RecruitingChallenge2026Seeder extends Seeder
|
|||
'bonus_rank_1' => 500,
|
||||
'bonus_rank_2' => 300,
|
||||
'bonus_rank_3' => 200,
|
||||
'include_sales_points' => false,
|
||||
'include_abos' => false,
|
||||
'status' => 0,
|
||||
'vip_only' => true,
|
||||
|
|
@ -79,7 +84,7 @@ class RecruitingChallenge2026Seeder extends Seeder
|
|||
|
||||
if ($incentive) {
|
||||
$incentive->update($texts);
|
||||
$this->command?->info('Recruiting Challenge aktualisiert (Texte, Uebersetzungen, Beginn), ID '.$incentive->id);
|
||||
$this->command?->info('Recruiting Challenge aktualisiert (Texte, Uebersetzungen, Beginn, Wertung), ID '.$incentive->id);
|
||||
|
||||
return;
|
||||
}
|
||||
|
|
@ -91,11 +96,13 @@ class RecruitingChallenge2026Seeder extends Seeder
|
|||
private function description(): string
|
||||
{
|
||||
return <<<'HTML'
|
||||
<p><strong>Jeder neue Partner bringt dir 50 €. Sofort gezählt, im Februar ausgezahlt.</strong></p>
|
||||
<p>Vom 9. Oktober bis zum 31. Januar zählt jeder neue Partner, der mit dir startet und ein Starterpaket bestellt. Kein Mindestumsatz. Keine Mindestanzahl. Der erste zählt genauso wie der zwanzigste.</p>
|
||||
<p>Und wer am 31. Januar die meisten neuen Partner im Team hat, holt sich obendrauf den Top 3 Bonus: <strong>500 € für Platz 1, 300 € für Platz 2, 200 € für Platz 3.</strong></p>
|
||||
<p>Deine Punkte laufen automatisch mit. Bestätige einmal deine Teilnahme, dann steht dein Name in der Rangliste. Ab da gilt: Jedes Starterpaket zählt.</p>
|
||||
<p><strong>Jeder neue Partner bringt dir 50 €. Und die Top 3 holen sich obendrauf einen Extrabonus.</strong></p>
|
||||
<p>Vom 9. Oktober bis zum 31. Januar zählt jeder neue Partner, der mit dir startet und ein Starterpaket bestellt. Kein Mindestumsatz. Keine Mindestanzahl. Für jeden gibt es 50 €, der erste zählt genauso wie der zwanzigste.</p>
|
||||
<p>Für die Top 3 zählt der Umsatz deiner neuen Partner: Starterpaket, Eigen- und Kundenumsatz in Punkten, bis zum 31. Januar. Wer dann vorn liegt, holt sich den Top 3 Bonus: <strong>500 € für Platz 1, 300 € für Platz 2, 200 € für Platz 3.</strong></p>
|
||||
<p><strong>Rückenwind für den Start:</strong> Bis zum 31. Oktober steigen neue Partner noch vergünstigt ein.</p>
|
||||
<p>Deine Punkte laufen automatisch mit. Bestätige einmal deine Teilnahme, dann steht dein Name in der Rangliste.</p>
|
||||
<p><strong>Fünf neue Partner? 250 €. Zehn? 500 €. Und Platz 1 legt noch 500 € drauf.</strong></p>
|
||||
<p><small>Steuerpflicht: Die Boni werden als Bruttobeiträge ausgewiesen und unterliegen der gesetzlichen Steuer- und Sozialversicherungspflicht.</small></p>
|
||||
HTML;
|
||||
}
|
||||
|
||||
|
|
@ -103,50 +110,58 @@ HTML;
|
|||
{
|
||||
return <<<'HTML'
|
||||
<h5>Teilnahmebedingungen: Recruiting Challenge 2026/27</h5>
|
||||
<p><strong>1. Worum es geht.</strong> mivita veranstaltet die Recruiting Challenge 2026/27. Für jeden neuen Partner, den ein Berater im Aktionszeitraum gewinnt, zahlt mivita eine Prämie. Die drei Berater mit den meisten neuen Partnern erhalten zusätzlich einen Bonus.</p>
|
||||
<p><strong>1. Worum es geht.</strong> mivita veranstaltet die Recruiting Challenge 2026/27. Für jeden neuen Partner, den ein Berater im Aktionszeitraum gewinnt, zahlt mivita eine Prämie. Die drei Berater, deren neue Partner den höchsten Umsatz erzielen, erhalten zusätzlich einen Bonus.</p>
|
||||
<p><strong>2. Wer mitmachen kann.</strong> Alle Berater mit aktivem Beraterkonto. Die Teilnahme wird einmal im Backoffice bestätigt. Neue Partner werden ab dem 9. Oktober 2026 gezählt, egal wann die Bestätigung erfolgt. Ohne Bestätigung erscheint der Berater nicht in der Rangliste und erhält keine Auszahlung.</p>
|
||||
<p><strong>3. Aktionszeitraum.</strong> 9. Oktober 2026 bis 31. Januar 2027. Es zählt das Datum der bezahlten Registrierungsbestellung des neuen Partners.</p>
|
||||
<p><strong>4. Was zählt.</strong> Ein neuer Partner ist eine Person, die sich im Aktionszeitraum mit dem Teilnehmer als Sponsor als Berater registriert und ein Starterpaket bezahlt hat. Eine Registrierung nur mit Mitgliedschaft zählt nicht. Auch Kunden, die im Aktionszeitraum Berater werden, zählen als neue Partner. Jeder neue Partner wird einmal gezählt. Maßgeblich ist der Sponsor, der im System hinterlegt ist: Wird ein neuer Partner nachträglich einem anderen Sponsor zugeordnet, zum Beispiel nach einer Anmeldung über einen falschen Link, zählt er beim neuen Sponsor. Die Rangliste ergibt sich aus der Anzahl neuer Partner.</p>
|
||||
<p><strong>5. Prämie.</strong> 50 € je neuem Partner, ohne Obergrenze. Die Berater auf den Plätzen 1 bis 3 erhalten zusätzlich 500 €, 300 € und 200 €.</p>
|
||||
<p><strong>6. Gleichstand.</strong> Haben mehrere Berater die gleiche Anzahl neuer Partner, liegt vorn, wer diese Anzahl zuerst erreicht hat.</p>
|
||||
<p><strong>7. Widerruf und Storno.</strong> Widerruft ein neuer Partner seine Registrierung oder wird die Bestellung storniert, entfällt die Wertung für diesen Partner. In der eigenen Berechnung steht er dann als storniert. Stichtag ist die Auszahlung im Februar 2027, danach wird nicht mehr zurückgerechnet.</p>
|
||||
<p><strong>8. Auszahlung.</strong> Die Prämien werden nach Ende der Challenge im Februar 2027 gesammelt ausgezahlt. Die Prämie ist eine Einnahme aus der Beratertätigkeit. Für die Versteuerung ist der Berater selbst verantwortlich.</p>
|
||||
<p><strong>9. Ausschluss.</strong> Bei Missbrauch, zum Beispiel Scheinregistrierungen oder Mehrfachkonten, kann mivita einzelne Wertungen streichen oder den Teilnehmer ausschließen.</p>
|
||||
<p><strong>10. Änderungen.</strong> mivita kann die Bedingungen aus sachlichem Grund anpassen. Änderungen werden im Backoffice bekannt gegeben.</p>
|
||||
<p><strong>11. Datenschutz.</strong> Nach Bestätigung der Teilnahme sind Name und Punktestand des Teilnehmers in der Rangliste für andere Berater sichtbar.</p>
|
||||
<p><strong>3. Aktionszeitraum.</strong> 9. Oktober 2026 bis 31. Januar 2027. Für einen neuen Partner zählt das Datum der bezahlten Registrierungsbestellung.</p>
|
||||
<p><strong>4. Was zählt.</strong> Ein neuer Partner ist eine Person, die sich im Aktionszeitraum mit dem Teilnehmer als Sponsor als Berater registriert und ein Starterpaket bezahlt hat. Eine Registrierung nur mit Mitgliedschaft zählt nicht. Auch Kunden, die im Aktionszeitraum Berater werden, zählen als neue Partner. Jeder neue Partner wird einmal gezählt. Maßgeblich ist der Sponsor, der im System hinterlegt ist: Wird ein neuer Partner nachträglich einem anderen Sponsor zugeordnet, zum Beispiel nach einer Anmeldung über einen falschen Link, zählt er beim neuen Sponsor.</p>
|
||||
<p><strong>5. Rangliste.</strong> Die Rangliste ergibt sich aus dem Umsatz der neuen Partner. Gezählt werden die Umsatzpunkte der neuen Partner bis zum 31. Januar 2027: das Starterpaket sowie ihr Eigen- und Kundenumsatz. Der eigene Umsatz des Teilnehmers zählt nicht.</p>
|
||||
<p><strong>6. Gleichstand.</strong> Haben mehrere Berater die gleiche Punktzahl, liegt vorn, wer diese Punktzahl zuerst erreicht hat.</p>
|
||||
<p><strong>7. Prämie und Bonus.</strong> 50 € je neuem Partner, ohne Obergrenze. Die Berater auf den Plätzen 1 bis 3 der Rangliste erhalten zusätzlich 500 €, 300 € und 200 €. Es gibt genau drei Gewinner.</p>
|
||||
<p><strong>8. Widerruf und Storno.</strong> Widerruft ein neuer Partner seine Registrierung oder wird die Bestellung storniert, entfällt die Wertung für diesen Partner. In der eigenen Berechnung steht er dann als storniert. Stornierte Umsätze werden von den Punkten abgezogen. Stichtag ist die Auszahlung im Februar 2027, danach wird nicht mehr zurückgerechnet.</p>
|
||||
<p><strong>9. Auszahlung.</strong> Prämien und Boni werden nach Ende der Challenge im Februar 2027 gesammelt ausgezahlt.</p>
|
||||
<p><strong>10. Steuerpflicht.</strong> Die Boni werden als Bruttobeiträge ausgewiesen und unterliegen der gesetzlichen Steuer- und Sozialversicherungspflicht.</p>
|
||||
<p><strong>11. Ausschluss.</strong> Bei Missbrauch, zum Beispiel Scheinregistrierungen oder Mehrfachkonten, kann mivita einzelne Wertungen streichen oder den Teilnehmer ausschließen.</p>
|
||||
<p><strong>12. Änderungen.</strong> mivita kann die Bedingungen aus sachlichem Grund anpassen. Änderungen werden im Backoffice bekannt gegeben.</p>
|
||||
<p><strong>13. Datenschutz.</strong> Nach Bestätigung der Teilnahme sind Name und Punktestand des Teilnehmers in der Rangliste für andere Berater sichtbar.</p>
|
||||
HTML;
|
||||
}
|
||||
|
||||
private function descriptionEn(): string
|
||||
{
|
||||
return <<<'HTML'
|
||||
<p><strong>Every new partner earns you €50. Counted right away, paid out in February.</strong></p>
|
||||
<p>From 9 October to 31 January, every new partner who starts with you and orders a starter kit counts. No minimum sales. No minimum number. The first one counts just as much as the twentieth.</p>
|
||||
<p>And whoever has the most new partners in their team on 31 January also gets the Top 3 Bonus: <strong>€500 for 1st place, €300 for 2nd place, €200 for 3rd place.</strong></p>
|
||||
<p>Your points are tracked automatically. Confirm your participation once and your name appears in the ranking. From then on: every starter kit counts.</p>
|
||||
<p><strong>Every new partner earns you €50. And the top 3 get an extra bonus on top.</strong></p>
|
||||
<p>From 9 October to 31 January, every new partner who starts with you and orders a starter kit counts. No minimum sales. No minimum number. Each one earns you €50, and the first one counts just as much as the twentieth.</p>
|
||||
<p>For the top 3, the sales of your new partners count: starter kit, own and customer sales in points, up to 31 January. Whoever is ahead then gets the Top 3 Bonus: <strong>€500 for 1st place, €300 for 2nd place, €200 for 3rd place.</strong></p>
|
||||
<p><strong>A boost for the start:</strong> Until 31 October, new partners can still join at a reduced price.</p>
|
||||
<p>Your points are tracked automatically. Confirm your participation once and your name appears in the ranking.</p>
|
||||
<p><strong>Five new partners? €250. Ten? €500. And 1st place adds another €500.</strong></p>
|
||||
<p><small>Tax liability: The bonuses are stated as gross amounts and are subject to statutory tax and social security contributions.</small></p>
|
||||
HTML;
|
||||
}
|
||||
|
||||
private function descriptionEs(): string
|
||||
{
|
||||
return <<<'HTML'
|
||||
<p><strong>Cada nuevo socio te aporta 50 €. Se cuenta al momento y se paga en febrero.</strong></p>
|
||||
<p>Del 9 de octubre al 31 de enero cuenta cada nuevo socio que empiece contigo y pida un kit de inicio. Sin ventas mínimas. Sin número mínimo. El primero cuenta igual que el vigésimo.</p>
|
||||
<p>Y quien tenga más socios nuevos en su equipo el 31 de enero se lleva además el bonus Top 3: <strong>500 € para el 1.er puesto, 300 € para el 2.º puesto y 200 € para el 3.er puesto.</strong></p>
|
||||
<p>Tus puntos se cuentan automáticamente. Confirma una vez tu participación y tu nombre aparecerá en la clasificación. A partir de ahí, cada kit de inicio cuenta.</p>
|
||||
<p><strong>Cada nuevo socio te aporta 50 €. Y el top 3 se lleva además un bonus extra.</strong></p>
|
||||
<p>Del 9 de octubre al 31 de enero cuenta cada nuevo socio que empiece contigo y pida un kit de inicio. Sin ventas mínimas. Sin número mínimo. Por cada uno recibes 50 €, y el primero cuenta igual que el vigésimo.</p>
|
||||
<p>Para el top 3 cuentan las ventas de tus nuevos socios: kit de inicio, ventas propias y de clientes en puntos, hasta el 31 de enero. Quien vaya en cabeza se lleva el bonus Top 3: <strong>500 € para el 1.er puesto, 300 € para el 2.º puesto y 200 € para el 3.er puesto.</strong></p>
|
||||
<p><strong>Impulso para empezar:</strong> Hasta el 31 de octubre, los nuevos socios todavía pueden entrar a un precio reducido.</p>
|
||||
<p>Tus puntos se cuentan automáticamente. Confirma una vez tu participación y tu nombre aparecerá en la clasificación.</p>
|
||||
<p><strong>¿Cinco socios nuevos? 250 €. ¿Diez? 500 €. Y el 1.er puesto suma otros 500 €.</strong></p>
|
||||
<p><small>Obligación fiscal: Los bonus se indican como importes brutos y están sujetos a las obligaciones legales de impuestos y seguridad social.</small></p>
|
||||
HTML;
|
||||
}
|
||||
|
||||
private function descriptionFr(): string
|
||||
{
|
||||
return <<<'HTML'
|
||||
<p><strong>Chaque nouveau partenaire te rapporte 50 €. Comptabilisé immédiatement, versé en février.</strong></p>
|
||||
<p>Du 9 octobre au 31 janvier, chaque nouveau partenaire qui démarre avec toi et commande un kit de démarrage compte. Pas de chiffre d’affaires minimum. Pas de nombre minimum. Le premier compte autant que le vingtième.</p>
|
||||
<p>Et celui ou celle qui a le plus de nouveaux partenaires dans son équipe au 31 janvier remporte en plus le bonus Top 3 : <strong>500 € pour la 1re place, 300 € pour la 2e place, 200 € pour la 3e place.</strong></p>
|
||||
<p>Tes points sont comptabilisés automatiquement. Confirme une fois ta participation et ton nom apparaît dans le classement. Dès lors, chaque kit de démarrage compte.</p>
|
||||
<p><strong>Chaque nouveau partenaire te rapporte 50 €. Et le top 3 remporte en plus un bonus supplémentaire.</strong></p>
|
||||
<p>Du 9 octobre au 31 janvier, chaque nouveau partenaire qui démarre avec toi et commande un kit de démarrage compte. Pas de chiffre d’affaires minimum. Pas de nombre minimum. Chacun te rapporte 50 €, et le premier compte autant que le vingtième.</p>
|
||||
<p>Pour le top 3, c’est le chiffre d’affaires de tes nouveaux partenaires qui compte : kit de démarrage, chiffre d’affaires personnel et clients en points, jusqu’au 31 janvier. Celui ou celle qui est alors en tête remporte le bonus Top 3 : <strong>500 € pour la 1re place, 300 € pour la 2e place, 200 € pour la 3e place.</strong></p>
|
||||
<p><strong>Un coup de pouce pour démarrer :</strong> Jusqu’au 31 octobre, les nouveaux partenaires peuvent encore démarrer à prix réduit.</p>
|
||||
<p>Tes points sont comptabilisés automatiquement. Confirme une fois ta participation et ton nom apparaît dans le classement.</p>
|
||||
<p><strong>Cinq nouveaux partenaires ? 250 €. Dix ? 500 €. Et la 1re place ajoute encore 500 €.</strong></p>
|
||||
<p><small>Obligation fiscale : Les bonus sont indiqués en montants bruts et sont soumis aux obligations légales en matière d’impôts et de cotisations sociales.</small></p>
|
||||
HTML;
|
||||
}
|
||||
|
||||
|
|
@ -154,17 +169,19 @@ HTML;
|
|||
{
|
||||
return <<<'HTML'
|
||||
<h5>Terms of participation: Recruiting Challenge 2026/27</h5>
|
||||
<p><strong>1. What it is about.</strong> mivita is running the Recruiting Challenge 2026/27. For every new partner a consultant wins during the promotion period, mivita pays a reward. The three consultants with the most new partners also receive a bonus.</p>
|
||||
<p><strong>1. What it is about.</strong> mivita is running the Recruiting Challenge 2026/27. For every new partner a consultant wins during the promotion period, mivita pays a reward. The three consultants whose new partners generate the highest sales also receive a bonus.</p>
|
||||
<p><strong>2. Who can take part.</strong> All consultants with an active consultant account. Participation is confirmed once in the back office. New partners are counted from 9 October 2026, regardless of when the confirmation is given. Without confirmation, the consultant does not appear in the ranking and receives no payout.</p>
|
||||
<p><strong>3. Promotion period.</strong> 9 October 2026 to 31 January 2027. The date on which the new partner paid the registration order counts.</p>
|
||||
<p><strong>4. What counts.</strong> A new partner is a person who registers as a consultant during the promotion period with the participant as sponsor and has paid for a starter kit. A registration with membership only does not count. Customers who become consultants during the promotion period also count as new partners. Each new partner is counted once. The sponsor recorded in the system is decisive: if a new partner is later assigned to a different sponsor, for example after registering via the wrong link, they count for the new sponsor. The ranking is based on the number of new partners.</p>
|
||||
<p><strong>5. Reward.</strong> €50 per new partner, with no upper limit. The consultants in places 1 to 3 additionally receive €500, €300 and €200.</p>
|
||||
<p><strong>6. Tie.</strong> If several consultants have the same number of new partners, whoever reached that number first ranks ahead.</p>
|
||||
<p><strong>7. Withdrawal and cancellation.</strong> If a new partner withdraws their registration or the order is cancelled, that partner no longer counts and is marked as cancelled in the consultant's own calculation. The cut-off is the payout in February 2027; no recalculation takes place after that.</p>
|
||||
<p><strong>8. Payout.</strong> The rewards are paid out together after the end of the challenge in February 2027. The reward is income from the consultant activity. The consultant is responsible for any taxes.</p>
|
||||
<p><strong>9. Exclusion.</strong> In case of abuse, for example fake registrations or multiple accounts, mivita may remove individual results or exclude the participant.</p>
|
||||
<p><strong>10. Changes.</strong> mivita may adjust these terms for objective reasons. Changes are announced in the back office.</p>
|
||||
<p><strong>11. Data protection.</strong> Once participation is confirmed, the participant's name and score are visible to other consultants in the ranking.</p>
|
||||
<p><strong>3. Promotion period.</strong> 9 October 2026 to 31 January 2027. For a new partner, the date on which the registration order was paid counts.</p>
|
||||
<p><strong>4. What counts.</strong> A new partner is a person who registers as a consultant during the promotion period with the participant as sponsor and has paid for a starter kit. A registration with membership only does not count. Customers who become consultants during the promotion period also count as new partners. Each new partner is counted once. The sponsor recorded in the system is decisive: if a new partner is later assigned to a different sponsor, for example after registering via the wrong link, they count for the new sponsor.</p>
|
||||
<p><strong>5. Ranking.</strong> The ranking is based on the sales of the new partners. The sales points of the new partners up to 31 January 2027 count: the starter kit as well as their own and customer sales. The participant's own sales do not count.</p>
|
||||
<p><strong>6. Tie.</strong> If several consultants have the same score, whoever reached that score first ranks ahead.</p>
|
||||
<p><strong>7. Reward and bonus.</strong> €50 per new partner, with no upper limit. The consultants in places 1 to 3 of the ranking additionally receive €500, €300 and €200. There are exactly three winners.</p>
|
||||
<p><strong>8. Withdrawal and cancellation.</strong> If a new partner withdraws their registration or the order is cancelled, that partner no longer counts and is marked as cancelled in the consultant's own calculation. Cancelled sales are deducted from the points. The cut-off is the payout in February 2027; no recalculation takes place after that.</p>
|
||||
<p><strong>9. Payout.</strong> Rewards and bonuses are paid out together after the end of the challenge in February 2027.</p>
|
||||
<p><strong>10. Tax liability.</strong> The bonuses are stated as gross amounts and are subject to statutory tax and social security contributions.</p>
|
||||
<p><strong>11. Exclusion.</strong> In case of abuse, for example fake registrations or multiple accounts, mivita may remove individual results or exclude the participant.</p>
|
||||
<p><strong>12. Changes.</strong> mivita may adjust these terms for objective reasons. Changes are announced in the back office.</p>
|
||||
<p><strong>13. Data protection.</strong> Once participation is confirmed, the participant's name and score are visible to other consultants in the ranking.</p>
|
||||
HTML;
|
||||
}
|
||||
|
||||
|
|
@ -172,17 +189,19 @@ HTML;
|
|||
{
|
||||
return <<<'HTML'
|
||||
<h5>Condiciones de participación: Recruiting Challenge 2026/27</h5>
|
||||
<p><strong>1. De qué se trata.</strong> mivita organiza la Recruiting Challenge 2026/27. Por cada nuevo socio que un asesor consiga durante el periodo de la promoción, mivita paga una prima. Los tres asesores con más socios nuevos reciben además un bonus.</p>
|
||||
<p><strong>1. De qué se trata.</strong> mivita organiza la Recruiting Challenge 2026/27. Por cada nuevo socio que un asesor consiga durante el periodo de la promoción, mivita paga una prima. Los tres asesores cuyos nuevos socios generen las ventas más altas reciben además un bonus.</p>
|
||||
<p><strong>2. Quién puede participar.</strong> Todos los asesores con una cuenta de asesor activa. La participación se confirma una vez en el backoffice. Los nuevos socios se cuentan a partir del 9 de octubre de 2026, independientemente de cuándo se confirme. Sin confirmación, el asesor no aparece en la clasificación y no recibe ningún pago.</p>
|
||||
<p><strong>3. Periodo de la promoción.</strong> Del 9 de octubre de 2026 al 31 de enero de 2027. Cuenta la fecha en que el nuevo socio pagó el pedido de registro.</p>
|
||||
<p><strong>4. Qué cuenta.</strong> Un nuevo socio es una persona que, durante el periodo de la promoción, se registra como asesor con el participante como patrocinador y ha pagado un kit de inicio. Un registro solo con membresía no cuenta. También cuentan como nuevos socios los clientes que se conviertan en asesores durante el periodo de la promoción. Cada nuevo socio se cuenta una vez. Es determinante el patrocinador registrado en el sistema: si un nuevo socio se asigna posteriormente a otro patrocinador, por ejemplo tras registrarse a través de un enlace equivocado, cuenta para el nuevo patrocinador. La clasificación se basa en el número de socios nuevos.</p>
|
||||
<p><strong>5. Prima.</strong> 50 € por cada nuevo socio, sin límite. Los asesores en los puestos 1 a 3 reciben además 500 €, 300 € y 200 €.</p>
|
||||
<p><strong>6. Empate.</strong> Si varios asesores tienen el mismo número de socios nuevos, queda por delante quien alcanzó ese número primero.</p>
|
||||
<p><strong>7. Desistimiento y anulación.</strong> Si un nuevo socio revoca su registro o se anula el pedido, ese socio deja de contar y aparece como anulado en el cálculo propio del asesor. La fecha límite es el pago en febrero de 2027; después ya no se recalcula.</p>
|
||||
<p><strong>8. Pago.</strong> Las primas se pagan conjuntamente tras el final de la challenge en febrero de 2027. La prima es un ingreso de la actividad como asesor. El asesor es responsable de su tributación.</p>
|
||||
<p><strong>9. Exclusión.</strong> En caso de abuso, por ejemplo registros ficticios o cuentas múltiples, mivita puede anular resultados concretos o excluir al participante.</p>
|
||||
<p><strong>10. Cambios.</strong> mivita puede adaptar las condiciones por motivos justificados. Los cambios se anuncian en el backoffice.</p>
|
||||
<p><strong>11. Protección de datos.</strong> Tras confirmar la participación, el nombre y la puntuación del participante son visibles para otros asesores en la clasificación.</p>
|
||||
<p><strong>3. Periodo de la promoción.</strong> Del 9 de octubre de 2026 al 31 de enero de 2027. Para un nuevo socio cuenta la fecha en que se pagó el pedido de registro.</p>
|
||||
<p><strong>4. Qué cuenta.</strong> Un nuevo socio es una persona que, durante el periodo de la promoción, se registra como asesor con el participante como patrocinador y ha pagado un kit de inicio. Un registro solo con membresía no cuenta. También cuentan como nuevos socios los clientes que se conviertan en asesores durante el periodo de la promoción. Cada nuevo socio se cuenta una vez. Es determinante el patrocinador registrado en el sistema: si un nuevo socio se asigna posteriormente a otro patrocinador, por ejemplo tras registrarse a través de un enlace equivocado, cuenta para el nuevo patrocinador.</p>
|
||||
<p><strong>5. Clasificación.</strong> La clasificación se basa en las ventas de los nuevos socios. Cuentan los puntos de venta de los nuevos socios hasta el 31 de enero de 2027: el kit de inicio y sus ventas propias y de clientes. No cuentan las ventas propias del participante.</p>
|
||||
<p><strong>6. Empate.</strong> Si varios asesores tienen la misma puntuación, queda por delante quien alcanzó esa puntuación primero.</p>
|
||||
<p><strong>7. Prima y bonus.</strong> 50 € por cada nuevo socio, sin límite. Los asesores en los puestos 1 a 3 de la clasificación reciben además 500 €, 300 € y 200 €. Hay exactamente tres ganadores.</p>
|
||||
<p><strong>8. Desistimiento y anulación.</strong> Si un nuevo socio revoca su registro o se anula el pedido, ese socio deja de contar y aparece como anulado en el cálculo propio del asesor. Las ventas anuladas se descuentan de los puntos. La fecha límite es el pago en febrero de 2027; después ya no se recalcula.</p>
|
||||
<p><strong>9. Pago.</strong> Las primas y los bonus se pagan conjuntamente tras el final de la challenge en febrero de 2027.</p>
|
||||
<p><strong>10. Obligación fiscal.</strong> Los bonus se indican como importes brutos y están sujetos a las obligaciones legales de impuestos y seguridad social.</p>
|
||||
<p><strong>11. Exclusión.</strong> En caso de abuso, por ejemplo registros ficticios o cuentas múltiples, mivita puede anular resultados concretos o excluir al participante.</p>
|
||||
<p><strong>12. Cambios.</strong> mivita puede adaptar las condiciones por motivos justificados. Los cambios se anuncian en el backoffice.</p>
|
||||
<p><strong>13. Protección de datos.</strong> Tras confirmar la participación, el nombre y la puntuación del participante son visibles para otros asesores en la clasificación.</p>
|
||||
HTML;
|
||||
}
|
||||
|
||||
|
|
@ -190,17 +209,19 @@ HTML;
|
|||
{
|
||||
return <<<'HTML'
|
||||
<h5>Conditions de participation : Recruiting Challenge 2026/27</h5>
|
||||
<p><strong>1. De quoi s’agit-il.</strong> mivita organise la Recruiting Challenge 2026/27. Pour chaque nouveau partenaire qu’un conseiller recrute pendant la période de l’opération, mivita verse une prime. Les trois conseillers ayant le plus de nouveaux partenaires reçoivent en plus un bonus.</p>
|
||||
<p><strong>1. De quoi s’agit-il.</strong> mivita organise la Recruiting Challenge 2026/27. Pour chaque nouveau partenaire qu’un conseiller recrute pendant la période de l’opération, mivita verse une prime. Les trois conseillers dont les nouveaux partenaires réalisent le chiffre d’affaires le plus élevé reçoivent en plus un bonus.</p>
|
||||
<p><strong>2. Qui peut participer.</strong> Tous les conseillers disposant d’un compte conseiller actif. La participation est confirmée une fois dans le back-office. Les nouveaux partenaires sont comptés à partir du 9 octobre 2026, quelle que soit la date de confirmation. Sans confirmation, le conseiller n’apparaît pas dans le classement et ne reçoit aucun versement.</p>
|
||||
<p><strong>3. Période de l’opération.</strong> Du 9 octobre 2026 au 31 janvier 2027. C’est la date à laquelle le nouveau partenaire a payé la commande d’inscription qui compte.</p>
|
||||
<p><strong>4. Ce qui compte.</strong> Un nouveau partenaire est une personne qui s’inscrit comme conseiller pendant la période de l’opération avec le participant comme parrain et qui a payé un kit de démarrage. Une inscription avec adhésion seule ne compte pas. Les clients qui deviennent conseillers pendant la période de l’opération comptent également comme nouveaux partenaires. Chaque nouveau partenaire est compté une fois. C’est le parrain enregistré dans le système qui fait foi : si un nouveau partenaire est ensuite rattaché à un autre parrain, par exemple après une inscription via un mauvais lien, il compte pour le nouveau parrain. Le classement se base sur le nombre de nouveaux partenaires.</p>
|
||||
<p><strong>5. Prime.</strong> 50 € par nouveau partenaire, sans plafond. Les conseillers classés aux places 1 à 3 reçoivent en plus 500 €, 300 € et 200 €.</p>
|
||||
<p><strong>6. Égalité.</strong> Si plusieurs conseillers ont le même nombre de nouveaux partenaires, celui qui a atteint ce nombre en premier est classé devant.</p>
|
||||
<p><strong>7. Rétractation et annulation.</strong> Si un nouveau partenaire se rétracte ou si la commande est annulée, ce partenaire n’est plus pris en compte et apparaît comme annulé dans le calcul personnel du conseiller. La date limite est le versement en février 2027 ; aucun recalcul n’a lieu ensuite.</p>
|
||||
<p><strong>8. Versement.</strong> Les primes sont versées ensemble après la fin de la challenge, en février 2027. La prime constitue un revenu de l’activité de conseiller. Le conseiller est responsable de sa déclaration fiscale.</p>
|
||||
<p><strong>9. Exclusion.</strong> En cas d’abus, par exemple des inscriptions fictives ou des comptes multiples, mivita peut annuler certains résultats ou exclure le participant.</p>
|
||||
<p><strong>10. Modifications.</strong> mivita peut adapter les conditions pour un motif objectif. Les modifications sont annoncées dans le back-office.</p>
|
||||
<p><strong>11. Protection des données.</strong> Après confirmation de la participation, le nom et le score du participant sont visibles par les autres conseillers dans le classement.</p>
|
||||
<p><strong>3. Période de l’opération.</strong> Du 9 octobre 2026 au 31 janvier 2027. Pour un nouveau partenaire, c’est la date de paiement de la commande d’inscription qui compte.</p>
|
||||
<p><strong>4. Ce qui compte.</strong> Un nouveau partenaire est une personne qui s’inscrit comme conseiller pendant la période de l’opération avec le participant comme parrain et qui a payé un kit de démarrage. Une inscription avec adhésion seule ne compte pas. Les clients qui deviennent conseillers pendant la période de l’opération comptent également comme nouveaux partenaires. Chaque nouveau partenaire est compté une fois. C’est le parrain enregistré dans le système qui fait foi : si un nouveau partenaire est ensuite rattaché à un autre parrain, par exemple après une inscription via un mauvais lien, il compte pour le nouveau parrain.</p>
|
||||
<p><strong>5. Classement.</strong> Le classement se base sur le chiffre d’affaires des nouveaux partenaires. Comptent les points de chiffre d’affaires des nouveaux partenaires jusqu’au 31 janvier 2027 : le kit de démarrage ainsi que leur chiffre d’affaires personnel et clients. Le chiffre d’affaires personnel du participant ne compte pas.</p>
|
||||
<p><strong>6. Égalité.</strong> Si plusieurs conseillers ont le même score, celui qui a atteint ce score en premier est classé devant.</p>
|
||||
<p><strong>7. Prime et bonus.</strong> 50 € par nouveau partenaire, sans plafond. Les conseillers classés aux places 1 à 3 reçoivent en plus 500 €, 300 € et 200 €. Il y a exactement trois gagnants.</p>
|
||||
<p><strong>8. Rétractation et annulation.</strong> Si un nouveau partenaire se rétracte ou si la commande est annulée, ce partenaire n’est plus pris en compte et apparaît comme annulé dans le calcul personnel du conseiller. Le chiffre d’affaires annulé est déduit des points. La date limite est le versement en février 2027 ; aucun recalcul n’a lieu ensuite.</p>
|
||||
<p><strong>9. Versement.</strong> Les primes et les bonus sont versés ensemble après la fin de la challenge, en février 2027.</p>
|
||||
<p><strong>10. Obligation fiscale.</strong> Les bonus sont indiqués en montants bruts et sont soumis aux obligations légales en matière d’impôts et de cotisations sociales.</p>
|
||||
<p><strong>11. Exclusion.</strong> En cas d’abus, par exemple des inscriptions fictives ou des comptes multiples, mivita peut annuler certains résultats ou exclure le participant.</p>
|
||||
<p><strong>12. Modifications.</strong> mivita peut adapter les conditions pour un motif objectif. Les modifications sont annoncées dans le back-office.</p>
|
||||
<p><strong>13. Protection des données.</strong> Après confirmation de la participation, le nom et le score du participant sont visibles par les autres conseillers dans le classement.</p>
|
||||
HTML;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
46
dev/2026-08-12-intensiv-tickets/september-anerkennungen.md
Normal file
46
dev/2026-08-12-intensiv-tickets/september-anerkennungen.md
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
Herzlichen Glückwunsch zu euren großartigen Erfolgen! 🥳💙
|
||||
|
||||
|
||||
🏆 Top-Recruiter – September
|
||||
🥇 Gabi Ramstötter (2)
|
||||
🥇 Tanja Longerich (2)
|
||||
🥇 Claudia Lattner (2)
|
||||
|
||||
🌟 Die meisten neuen Kunden-Abos im September
|
||||
🥇 Platz 1: Verena Zöllinger (7)
|
||||
🥈 Platz 2: Nicola Maier (6)
|
||||
🥉 Platz 3: Rosa Maria Assenbaum (4), Carmen Graf (4)
|
||||
|
||||
💙 Höchster Umsatz mit Kunden-Abos
|
||||
🥇 Platz 1: Cornelia Böhm-Sodan – 1.096 Punkte
|
||||
🥈 Platz 2: Annette Harsch – 1.042 Punkte
|
||||
🥉 Platz 3: Tanja Longerich – 930 Punkte
|
||||
|
||||
⭐ Die meisten Kunden-Abos insgesamt
|
||||
🥇 Platz 1: Annette Harsch (21)
|
||||
🥈 Platz 2: Rosa Maria Assenbaum (19)
|
||||
🥉 Platz 3: Nina Fleschhut (16), Tanja Longerich (16)
|
||||
|
||||
🚀 Top 3 durch Kundenumsatz & Recruiting – 1.000-Punkte-Shop
|
||||
🥇 Platz 1: Claudia Lattner (5.808 P)
|
||||
🥈 Platz 2: Daniel Biallowons (4.202 P)
|
||||
🥉 Platz 3: Tanja Longerich (3.279 P)
|
||||
Platz 4: Claudia Zürn (3.001 P)
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Prüfung firmenweit (07.10.2026, Live-Stand) – nicht mitschicken
|
||||
|
||||
Nachgerechnet über die gesamte Firma statt nur Claudias Team, mit denselben Regeln wie
|
||||
Dashboard „Die besten Ergebnisse“ und VIP-Statistik. Grundsätzlich ohne das
|
||||
Firmenkonto (mivita care gmbh, User 6) und ohne Ruth Krummel (User 3, Großkunde) –
|
||||
beide werden in den Anerkennungen nie aufgeführt (bestätigt von Kevin, 07.10.2026).
|
||||
|
||||
| Kategorie | Ergebnis |
|
||||
|---|---|
|
||||
| Top-Recruiter | ✅ stimmt (je 2; dahinter rund 10 Partner mit je 1) |
|
||||
| Neue Kunden-Abos | ✅ stimmt; Name korrigiert: Rosa Maria Assenbaum |
|
||||
| Umsatz Kunden-Abos | ✅ stimmt (Böhm-Sodan genau 1.096,5 P) |
|
||||
| Kunden-Abos insgesamt | ✅ stimmt; Name korrigiert: Rosa Maria Assenbaum (Klammer fehlte) |
|
||||
| 1.000-Punkte-Shop | ❌ **falsch**: Daniel Biallowons (4.202,5 P) und Claudia Zürn (3.001,5 P) sind nicht in Claudias Team und fehlten. Jasmin Neuhauser (2.637 P) rutscht auf Platz 5 |
|
||||
234
dev/2026-10-09-spam/README.md
Normal file
234
dev/2026-10-09-spam/README.md
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
# Spam über das Kontaktformular – Entwicklungsplan & Protokoll
|
||||
|
||||
Stand: 09.10.2026 · Status: **Kontaktformular + Registrierung umgesetzt, als `v1.6.0` getaggt, Datenschutzerklärung in `v1.6.1`** (Deploy siehe Abschnitt 7)
|
||||
|
||||
Beispiel-Mail: [spam-mail.md](spam-mail.md)
|
||||
|
||||
---
|
||||
|
||||
## 1. Befund
|
||||
|
||||
Betroffen ist das Kontaktformular `/kontakt`, das auf allen Shops läuft (Hauptseite, `shop.*`, alle Berater-Shops wie `freshzone.mivita.care`).
|
||||
|
||||
- Route: `routes/shared/common.php` (bzw. `routes/domains/main.php`) → `Web\ContactController@store`
|
||||
- Mail: `App\Mail\MailContact`, Vorlage `resources/views/emails/contact.blade.php`
|
||||
- Formular: `resources/views/web/templates/kontakt.blade.php`
|
||||
|
||||
### Was in der Beispiel-Mail passiert
|
||||
|
||||
Die Spam-Mail ist **keine Nachricht an uns**, sondern die **Kopie an den Absender** („Deine Anfrage von freshzone.mivita.care“). Der Controller schickt die Mail so:
|
||||
|
||||
```php
|
||||
Mail::to($contact['email']) // ← Adresse aus dem Formular
|
||||
->bcc([$user_shop->user->email, $contact_mail])
|
||||
->send(new MailContact($contact));
|
||||
```
|
||||
|
||||
Der Spammer trägt also die Adresse eines **fremden Opfers** ein (hier `igor.makhataev@icloud.com`) und seinen Werbetext in Vorname, Nachname und Nachricht. Unser Server verschickt dann eine Mail **im Namen von mivita.care** mit „Hallo <Werbetext>“ und dem Phishing-Link an das Opfer. Wir und der Berater bekommen nur die BCC-Kopie.
|
||||
|
||||
Das heißt:
|
||||
|
||||
1. **Unser Formular wird als Spam-Versender missbraucht.** Das ist der eigentliche Grund, warum das Formular für Spammer interessant ist – nicht, um uns zu erreichen.
|
||||
2. **Ruf der Absender-Domain:** Beschwerden der Empfänger („Als Spam markieren“) landen bei unserer Domain. Das kann dazu führen, dass auch echte Mails (Bestellbestätigungen, Rechnungen, Abo-Mails) im Spam landen.
|
||||
3. Jeder Berater mit Shop bekommt den Spam zusätzlich per BCC.
|
||||
|
||||
### Weitere Schwachstellen
|
||||
|
||||
| # | Schwachstelle | Fundstelle |
|
||||
| - | ------------- | ---------- |
|
||||
| A | Bestätigungsmail geht an beliebige eingegebene Adresse, inklusive aller Eingaben | `ContactController@store` |
|
||||
| B | Kein Rate-Limit (nur globales `throttle:60,1` für die Web-Gruppe) | `routes/shared/common.php` |
|
||||
| C | reCAPTCHA **v2 (Checkbox)** – wird von bezahlten Captcha-Lösediensten zuverlässig geknackt | `kontakt.blade.php` |
|
||||
| D | reCAPTCHA-Schlüssel **inkl. Secret hart im Code** und damit im Git | `ContactController` (und `RegisterController`) |
|
||||
| E | Keine Plausibilitätsprüfung: Vorname/Nachname ohne Längenbegrenzung, Links erlaubt, beliebige Schrift | Validierungsregeln |
|
||||
| F | Eingaben werden **ungefiltert als HTML** in die Mail geschrieben (`{!! nl2br($value) !!}`) – man könnte auch Links/Bilder einschleusen | `emails/contact.blade.php` |
|
||||
| G | Kein Protokoll: Wir sehen nicht, wie viele Versuche kommen und was geblockt wird | – |
|
||||
| H | Validierung inline im Controller statt Form Request | `ContactController` |
|
||||
|
||||
---
|
||||
|
||||
## 2. Vorgeschlagene Maßnahmen
|
||||
|
||||
Reihenfolge nach Wirkung. Stufe 1 allein dürfte den größten Teil erledigen, weil das Formular für Spammer damit wertlos wird.
|
||||
|
||||
### Stufe 1 – sofort, höchste Wirkung
|
||||
|
||||
**1.1 Keine Inhalte mehr an die eingegebene Adresse schicken** (behebt A)
|
||||
- Die Anfrage geht nur noch an `kontakt@mivita.care` bzw. den Berater, mit `Reply-To` = Adresse des Absenders (Antworten funktionieren wie bisher).
|
||||
- Der Absender sieht die Bestätigungsseite. Falls eine Bestätigungsmail gewünscht ist: nur ein **neutraler Text ohne Eingaben** („Wir haben Deine Anfrage erhalten …“), siehe Entscheidung E1.
|
||||
|
||||
**1.2 Rate-Limit** (behebt B)
|
||||
- Eigener Limiter für `POST /kontakt`, z. B. max. 3 Anfragen / 10 Minuten pro IP und max. 3 / Tag pro E-Mail-Adresse.
|
||||
|
||||
**1.3 Honeypot + Mindest-Ausfüllzeit** (unsichtbar für echte Besucher)
|
||||
- Verstecktes Feld, das nur Bots ausfüllen.
|
||||
- Verschlüsselter Zeitstempel beim Laden des Formulars; Absenden schneller als ~3 Sekunden → verwerfen.
|
||||
- Abgefangene Anfragen bekommen trotzdem die normale Erfolgsseite, damit der Bot nichts lernt.
|
||||
|
||||
**1.4 Plausibilitätsregeln** (behebt E)
|
||||
- Vorname/Nachname: max. 50 Zeichen, keine URLs, keine Zeilenumbrüche, nur lateinische Schrift inkl. Umlaute/Akzente (Shops laufen auf de/en/es/fr).
|
||||
- Nachricht: max. ca. 3.000 Zeichen, max. 1–2 Links.
|
||||
- Telefon: nur Ziffern, Leerzeichen, `+ - / ( )`.
|
||||
- Vorname = Nachname = Nachricht (wie im Beispiel) → verwerfen.
|
||||
|
||||
**1.5 Mail-Vorlage absichern** (behebt F)
|
||||
- `{!! nl2br($value) !!}` → `{!! nl2br(e($value)) !!}`.
|
||||
|
||||
### Stufe 2 – Aufräumen & Absichern
|
||||
|
||||
**2.1 Captcha** (behebt C, D) – siehe Entscheidung E2
|
||||
- Schlüssel in `.env` / `config/services.php` verschieben, **Secret bei Google neu erzeugen** (das alte liegt im Git).
|
||||
- Bei der Prüfung zusätzlich `hostname` kontrollieren.
|
||||
- Optional Wechsel auf Cloudflare Turnstile oder reCAPTCHA v3 (unsichtbar, schwerer automatisiert zu lösen).
|
||||
|
||||
**2.2 Form Request** (behebt H)
|
||||
- `ContactStoreRequest` mit Regeln und Fehlermeldungen (de/en/es/fr), Controller verschlanken.
|
||||
|
||||
**2.3 Protokoll geblockter Anfragen** (behebt G) – siehe Entscheidung E4
|
||||
- Eigener Log-Kanal `contact-spam` (Grund, IP, Shop, Zeit – **ohne** Nachrichtentext wegen Datenschutz) oder kleine DB-Tabelle mit Zählern.
|
||||
- Damit sehen wir nach der Umstellung, ob es wirkt, und können Regeln nachschärfen.
|
||||
|
||||
### Stufe 3 – nur falls nötig
|
||||
|
||||
- Wortfilter / Sperrliste für Domains und Linkdienste (z. B. `share.google`, Kurz-URLs).
|
||||
- Gleiche Prüfung auf das Registrierungsformular übertragen (nutzt dieselben reCAPTCHA-Schlüssel).
|
||||
|
||||
---
|
||||
|
||||
## 3. Entscheidungen (abgestimmt am 09.10.2026)
|
||||
|
||||
| # | Frage | Entscheidung |
|
||||
| - | ----- | ------------ |
|
||||
| E1 | Bestätigungsmail an den Absender? | **Nein.** Nur Bestätigungsseite. Die Anfrage geht an `kontakt@mivita.care` (+ Berater per BCC), `Reply-To` = Absender. |
|
||||
| E2 | Captcha | **Wechsel auf Cloudflare Turnstile.** Vorgabe: kostenlos. Turnstile ist dauerhaft kostenlos, ohne Mengenlimit und ohne Zahlungsdaten; die Seite muss nicht über Cloudflare laufen. reCAPTCHA v3 läuft inzwischen über Google Cloud (nur 10.000 Prüfungen/Monat frei, Cloud-Projekt nötig) und ist datenschutzrechtlich ungünstiger. |
|
||||
| E3 | Nicht-lateinische Schrift in Namensfeldern blocken? | **Ja.** |
|
||||
| E4 | Protokoll | **Logdatei** `storage/logs/contact-spam-YYYY-MM-DD.log` (30 Tage). Admin-Übersicht nur bei Bedarf. |
|
||||
| E5 | Berater bekommen weiter eine Kopie? | **Ja**, per BCC. |
|
||||
|
||||
---
|
||||
|
||||
## 4. Umsetzung Kontaktformular (Stand 09.10.2026)
|
||||
|
||||
### Was jetzt passiert, wenn jemand das Formular abschickt
|
||||
|
||||
1. **Rate-Limit** (`throttle:contact-form`, `RouteServiceProvider`): max. 5 Anfragen / 10 Min. pro IP und max. 5 / Tag pro E-Mail-Adresse. Darüber: Fehlermeldung am Formular.
|
||||
2. **Validierung** (`App\Http\Requests\Contact\ContactStoreRequest`):
|
||||
- Vor-/Nachname (`App\Rules\PersonName`): max. 50 Zeichen, nur lateinische Buchstaben inkl. Umlaute/Akzente, Leerzeichen, `- ' .`
|
||||
- Telefon: max. 30 Zeichen, nur `0-9 + - / ( ) .` und Leerzeichen
|
||||
- Nachricht: max. 3.000 Zeichen, max. 2 Links
|
||||
- Turnstile-Token wird bei Cloudflare geprüft (`App\Rules\TurnstileToken` → `App\Services\TurnstileVerifier`). Ist Cloudflare nicht erreichbar oder fehlt das Secret → abgelehnt.
|
||||
3. **Unsichtbare Bot-Erkennung** (`App\Services\FormSpamGuard`): verstecktes Feld `form_extra` (Honeypot) und verschlüsselter Zeitstempel `form_token` (Absenden schneller als 3 Sek. → Bot). Bots bekommen die normale Erfolgsseite, es wird aber nichts verschickt.
|
||||
4. **Versand** (`App\Mail\MailContact`): an `config('app.contact_mail')`, Berater des Shops per BCC, `Reply-To` = Absender. Betreff: „Neue Kontaktanfrage über <shop>.mivita.care“. **Keine Mail mehr an die eingegebene Adresse.**
|
||||
5. Eingaben werden in der Mail HTML-escaped (`resources/views/emails/contact.blade.php`).
|
||||
|
||||
---
|
||||
|
||||
## 5. Umsetzung Registrierung (Stand 09.10.2026)
|
||||
|
||||
Die Registrierung (`/registrierung`, `/reg/{partnernummer}` auf Hauptseite, `shop.*` und Berater-Shops) hatte dieselben Schwächen. Zusätzlich verschickt sie eine Bestätigungsmail an die eingegebene Adresse **mit dem Vornamen in der Anrede** – ohne Namensprüfung hätte sich auch das als Spam-Versender missbrauchen lassen.
|
||||
|
||||
1. **Turnstile statt reCAPTCHA** (`data-action="register"`). Damit ist reCAPTCHA im ganzen System entfernt; die alten Schlüssel stehen nicht mehr im Code.
|
||||
2. **Rate-Limit** (`throttle:register-form`): max. 5 Versuche / 10 Min. und 20 / Tag pro IP (großzügiger, weil Berater auf Veranstaltungen mehrere Partner aus einem WLAN registrieren).
|
||||
3. **Validierung** (`App\Http\Requests\Register\RegisterUserRequest`): Namensregel `PersonName` wie beim Kontaktformular, Anrede nur `mr`/`ms`, Datenschutz muss akzeptiert sein. Passwort min. 8 Zeichen (systemweit, siehe unten).
|
||||
4. **Honeypot + Mindestzeit** (`FormSpamGuard`): Bots werden auf die Erfolgsseite geleitet, es wird kein Benutzer angelegt und keine Mail verschickt.
|
||||
5. **Sponsor-Manipulation verhindert:** Die Partner-Nummer `from_member_id` wurde beim Absenden ungeprüft übernommen – man konnte sich jedem beliebigen (auch gesperrten) Berater unterhängen. Jetzt gilt sie nur, wenn der Berater aktiv ist und einen gültigen Account hat; sonst greift wie bisher Shop-Inhaber bzw. Firma.
|
||||
6. Nur noch geprüfte Felder werden an `UserRepository::create()` übergeben; die Sprache wird direkt beim Anlegen gesetzt.
|
||||
|
||||
### Passwort-Mindestlänge systemweit 8 Zeichen
|
||||
|
||||
Zentral in `AppServiceProvider::boot()`: `Password::defaults(fn () => Password::min(8))`. Alle Stellen, an denen ein neues Passwort gesetzt wird, nutzen `Password::defaults()`: Registrierung, Wizard (Schritt 11), erstes Passwort nach Freischaltung, Passwort ändern, Passwort zurücksetzen (Laravel-Standard). Vorher galten teils 6, teils 8 Zeichen. Bestehende kürzere Passwörter funktionieren beim Login weiter – die Regel greift nur beim Setzen. Texte in `resources/lang/*/passwords.php` angepasst. Tests: `tests/Feature/PasswordPolicyTest.php`.
|
||||
|
||||
### Sicherheitslücke im CRM geschlossen
|
||||
|
||||
`Auth::routes()` in `routes/domains/crm.php` hat neben Login auch `POST /register` auf `my.mivita.care` registriert. Das Formular dazu leitete zwar zum Login um, **der POST selbst legte aber ohne Captcha einen Benutzer an und meldete ihn direkt an** (auf dem Dev-System geprüft: Antwort 419 = Route aktiv, nur CSRF fehlte). Jetzt `Auth::routes(['register' => false])`; der Link „Register“ in `layouts/app.blade.php` ist entfernt.
|
||||
|
||||
Empfehlung: Auf Live einmal prüfen, ob es Benutzer **ohne `account_id`** gibt – das wären Konten, die über diesen Weg angelegt wurden:
|
||||
|
||||
```sql
|
||||
SELECT id, email, created_at FROM users WHERE account_id IS NULL OR account_id = 0 ORDER BY created_at DESC;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Gemeinsames Protokoll & Stellschrauben
|
||||
|
||||
### Protokoll abgefangener Anfragen
|
||||
|
||||
`storage/logs/form-spam-*.log` (30 Tage), eine Zeile pro Vorfall mit `form` (`contact` / `register`), `reason`, IP, Host, Shop-ID, Domain der E-Mail-Adresse – kein Freitext, keine Passwörter.
|
||||
|
||||
| reason | Bedeutung |
|
||||
| ------ | --------- |
|
||||
| `validation` | Validierung fehlgeschlagen, `fields` nennt die Felder (betrifft auch echte Tippfehler) |
|
||||
| `rate_limit` | zu viele Anfragen |
|
||||
| `honeypot` | verstecktes Feld ausgefüllt |
|
||||
| `too_fast` | schneller als 3 Sek. abgeschickt |
|
||||
| `invalid_form_token` | Zeitstempel fehlt/manipuliert (Formular nicht über die Seite geladen) |
|
||||
|
||||
Auswertung auf dem Server (Bash):
|
||||
|
||||
```bash
|
||||
grep -ho '"form":"[a-z]*","reason":"[a-z_]*"' storage/logs/form-spam-*.log | sort | uniq -c
|
||||
```
|
||||
|
||||
### Stellschrauben für spätere Anpassungen
|
||||
|
||||
| Was | Wo |
|
||||
| --- | -- |
|
||||
| Rate-Limits | `RouteServiceProvider::configureRateLimiting()` → `contact-form`, `register-form` |
|
||||
| Namensregel | `App\Rules\PersonName` (Muster, max. Länge) |
|
||||
| Telefon-Muster, max. Links (Kontakt) | Konstanten in `ContactStoreRequest` |
|
||||
| Mindest-Ausfüllzeit, Feldnamen | `FormSpamGuard` |
|
||||
| Fehlermeldungen | `resources/lang/*/form_protection.php` (gemeinsam), `resources/lang/*/contact.php` (Kontakt + Mailtexte) |
|
||||
|
||||
Hinweis Routing: Auf der Hauptdomain überschreibt `routes/domains/main.php` die Kontakt-Route aus `routes/shared/common.php`. Änderungen an der Route deshalb **an beiden Stellen** machen. Die Registrierungs-Route steht in `main.php`, `shop.php` und `user-shop.php`.
|
||||
|
||||
### Geänderte / neue Dateien
|
||||
|
||||
- Controller: `Web/ContactController.php`, `Web/RegisterController.php`
|
||||
- Requests: `Requests/Contact/ContactStoreRequest.php`, `Requests/Register/RegisterUserRequest.php` (neu)
|
||||
- Regeln/Services: `Rules/PersonName.php`, `Rules/TurnstileToken.php`, `Services/TurnstileVerifier.php`, `Services/FormSpamGuard.php` (neu)
|
||||
- `Mail/MailContact.php`, `Repositories/UserRepository.php` (`create`: Sprache beim Anlegen)
|
||||
- Views: `web/templates/kontakt.blade.php`, `web/templates/registrierung.blade.php`, `emails/contact.blade.php`, `layouts/app.blade.php`
|
||||
- Routen: `RouteServiceProvider.php`, `routes/shared/common.php`, `routes/domains/{main,shop,user-shop,crm}.php`
|
||||
- Config: `config/services.php` (`turnstile`), `config/logging.php` (`form_spam`), `.env.example`
|
||||
- Sprachen: `resources/lang/{de,en,es,fr}/contact.php`, `form_protection.php` (neu)
|
||||
- Passwort: `AppServiceProvider.php`, `UserUpdatePasswordController.php`, `WizardController.php`, `Auth/RegisterController.php`, `resources/lang/*/passwords.php`
|
||||
- Tests: `tests/Feature/ContactFormSpamTest.php` (26), `tests/Feature/RegisterFormSpamTest.php` (16), `tests/Feature/PasswordPolicyTest.php` (5)
|
||||
|
||||
---
|
||||
|
||||
## 7. Deployment – vor dem Go-Live erledigen
|
||||
|
||||
1. **Turnstile-Widget anlegen** (kostenlos): https://dash.cloudflare.com → Turnstile → „Add widget“
|
||||
- Hostnames: `mivita.care`, `mivita.shop` (Subdomains wie `freshzone.mivita.care` sind automatisch abgedeckt)
|
||||
- Widget Mode: **Managed**
|
||||
2. Schlüssel in die Live-`.env` eintragen:
|
||||
```
|
||||
TURNSTILE_SITE_KEY=...
|
||||
TURNSTILE_SECRET_KEY=...
|
||||
```
|
||||
✅ Widget angelegt am 09.10.2026. **Ohne diese Schlüssel lehnen Kontaktformular und Registrierung jede Anfrage ab.** Lokal sind die Cloudflare-Testschlüssel eingetragen, die immer bestehen.
|
||||
3. ✅ **Datenschutzerklärung** angepasst (09.10.2026): neuer Absatz „Schutz unserer Formulare vor Missbrauch (Cloudflare Turnstile)“ am Ende von Abschnitt V in `resources/lang/{de,en,es,fr}/dataprotect.php`, inkl. Rate-Limit und 30-Tage-Protokoll; „Stand“ auf 09.10.2026. reCAPTCHA war dort nie erwähnt. Die Erklärung wird ohnehin komplett neu gefasst – dabei Abschnitt XI prüfen (verweist noch auf das ungültige „Privacy Shield“).
|
||||
4. Deploy wie üblich per Tag (`dev/betrieb/README.md`), keine Migration: `bash dev/betrieb/deploy.sh v1.6.1` (enthält v1.6.0)
|
||||
5. Danach den alten reCAPTCHA-Schlüssel in der Google-Konsole löschen (das Secret liegt in der Git-Historie).
|
||||
6. SQL-Abfrage aus Abschnitt 5 (Benutzer ohne Account) auf Live ausführen.
|
||||
7. Nach ein paar Tagen das Log auswerten (Befehl oben) und hier im Protokoll eintragen.
|
||||
|
||||
### Noch offen (nur bei Bedarf)
|
||||
|
||||
- Wortfilter / Sperrliste für Linkdienste, falls trotzdem Spam durchkommt.
|
||||
|
||||
---
|
||||
|
||||
## 8. Protokoll
|
||||
|
||||
| Datum | Was | Version |
|
||||
| ----- | --- | ------- |
|
||||
| 09.10.2026 | Analyse, Plan erstellt (dieses Dokument) | – |
|
||||
| 09.10.2026 | Entscheidungen E1–E5 abgestimmt (E2: Turnstile statt reCAPTCHA, weil kostenlos) | – |
|
||||
| 09.10.2026 | Stufe 1 + 2 umgesetzt, 26 Tests grün (`tests/Feature/ContactFormSpamTest.php`) | noch nicht deployt |
|
||||
| 09.10.2026 | Registrierung auf Turnstile + gleiche Schutzschichten, Sponsor-Prüfung, offenes `POST /register` im CRM geschlossen; Schutz-Bausteine verallgemeinert (`FormSpamGuard`, `PersonName`, Log `form-spam`), 15 Tests grün (`tests/Feature/RegisterFormSpamTest.php`) | noch nicht deployt |
|
||||
| 09.10.2026 | Passwort-Mindestlänge systemweit auf 8 Zeichen (zentral über `Password::defaults()`), Turnstile-Widget bei Cloudflare angelegt | noch nicht deployt |
|
||||
| 09.10.2026 | Echte Turnstile-Schlüssel lokal geprüft: Secret von Cloudflare akzeptiert, Site-Key auf Hauptseite und Berater-Shop ausgeliefert, gefälschtes Token wird abgelehnt und protokolliert | noch nicht deployt |
|
||||
| 09.10.2026 | Auf dem Testsystem im Browser geprüft (läuft), Commit + Tag für den Deploy | v1.6.0 |
|
||||
| 09.10.2026 | Datenschutzerklärung (DE/EN/ES/FR) um Cloudflare Turnstile und Missbrauchsprotokoll ergänzt | v1.6.1 |
|
||||
24
dev/2026-10-09-spam/spam-mail.md
Normal file
24
dev/2026-10-09-spam/spam-mail.md
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
Hallo Приветствуем! Напоминаем, что на вашем бонусном счёте находится 926USD. Бонус будет аннулирован через 10 дней. Воспользоваться бонусом или вывести средства вы можете по ссылке: https://share.google/O8hzS8W7vPuroPXdL Всего доброго, команда сайта
|
||||
|
||||
Deine Anfrage von freshzone.mivita.care
|
||||
|
||||
Anrede:
|
||||
Frau
|
||||
Vorname:
|
||||
Приветствуем! Напоминаем, что на вашем бонусном счёте находится 926USD. Бонус будет аннулирован через 10 дней. Воспользоваться бонусом или вывести средства вы можете по ссылке: https://share.google/O8hzS8W7vPuroPXdL
|
||||
Всего доброго, команда сайта
|
||||
Nachname:
|
||||
Приветствуем! Напоминаем, что на вашем бонусном счёте находится 926USD. Бонус будет аннулирован через 10 дней. Воспользоваться бонусом или вывести средства вы можете по ссылке: https://share.google/O8hzS8W7vPuroPXdL
|
||||
Всего доброго, команда сайта
|
||||
E-Mail:
|
||||
igor.makhataev@icloud.com
|
||||
Telefon:
|
||||
88538273161
|
||||
Nachricht:
|
||||
Приветствуем! Напоминаем, что на вашем бонусном счёте находится 918USD. Бонус будет аннулирован через 11 дней. Воспользоваться бонусом или вывести средства вы можете по ссылке: https://share.google/SzivlySgl5DxoQgWn
|
||||
Всего доброго, команда сайта
|
||||
|
||||
|
||||
Für weitere Fragen stehen wir Dir gerne zur Verfügung.
|
||||
|
||||
Mit freundlichen Grüßen
|
||||
10
resources/lang/de/contact.php
Normal file
10
resources/lang/de/contact.php
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'phone_invalid' => 'Bitte gib eine gültige Telefonnummer ein.',
|
||||
'too_many_links' => 'Die Nachricht enthält zu viele Links.',
|
||||
'mail_subject' => 'Neue Kontaktanfrage über :source',
|
||||
'mail_headline' => 'Neue Kontaktanfrage',
|
||||
'mail_intro' => 'Über das Kontaktformular von :source ist folgende Anfrage eingegangen.',
|
||||
'mail_reply_hint' => 'Antworte einfach auf diese E-Mail, um den Absender direkt zu erreichen.',
|
||||
];
|
||||
|
|
@ -35,4 +35,21 @@ return [
|
|||
'under_review' => 'in Prüfung',
|
||||
'select' => 'wählen',
|
||||
'language_hint' => 'Die Rechnungen und Dokumente werden in dieser Sprache erstellt.',
|
||||
'email_change_requested' => 'Die neue E-Mail-Adresse :email ist noch nicht aktiv. Wir haben dem Kunden einen Bestätigungslink an diese Adresse geschickt. Erst nach seiner Bestätigung gilt sie für Login und Abo.',
|
||||
'email_change_pending' => 'Neue E-Mail-Adresse :email wartet auf Bestätigung durch den Kunden (angefragt am :date, Link gültig bis :until). Bis dahin gilt die bisherige Adresse.',
|
||||
'email_change_edit_hint' => 'Eine geänderte E-Mail-Adresse wird erst aktiv, wenn der Kunde sie über den Link in der Bestätigungsmail bestätigt.',
|
||||
'email_change_mail_subject' => 'Bitte bestätige deine neue E-Mail-Adresse',
|
||||
'email_change_mail_intro' => 'für dein Kundenkonto bei mivita.care wurde diese E-Mail-Adresse als neue Adresse eingetragen. Bitte bestätige sie mit dem Button. Danach meldest du dich im Kundenportal mit dieser Adresse an, und alle Nachrichten zu deinem Abo kommen hierher.',
|
||||
'email_change_mail_button' => 'E-Mail-Adresse bestätigen',
|
||||
'email_change_mail_hint' => 'Der Link ist :days Tage gültig. Wenn du die Änderung nicht veranlasst hast, ignoriere diese E-Mail einfach – dann bleibt alles wie bisher. Falls der Button nicht funktioniert, kopiere diesen Link in deinen Browser:',
|
||||
'email_change_confirm_title' => 'Neue E-Mail-Adresse bestätigen',
|
||||
'email_change_confirm_copy' => 'Bitte bestätige, dass du künftig diese E-Mail-Adresse für dein Kundenkonto und dein Abo nutzen möchtest:',
|
||||
'email_change_confirm_button' => 'Ja, E-Mail-Adresse bestätigen',
|
||||
'email_change_done_title' => 'E-Mail-Adresse bestätigt',
|
||||
'email_change_done_copy' => 'Ab sofort meldest du dich im Kundenportal mit :email an.',
|
||||
'email_change_invalid_title' => 'Link ungültig',
|
||||
'email_change_invalid_copy' => 'Der Link ist abgelaufen, wurde schon verwendet oder durch eine neuere Änderung ersetzt. Bitte wende dich an deinen Berater.',
|
||||
'email_change_blocked' => 'Die E-Mail-Adresse :email wurde nicht übernommen: Sie ist bereits einem Beraterkonto oder einem Kunden eines anderen Beraters zugeordnet (Kundenhoheit). Der Support wurde informiert.',
|
||||
'email_change_conflict_title' => 'E-Mail-Adresse bereits vergeben',
|
||||
'email_change_conflict_copy' => 'Diese E-Mail-Adresse ist bereits einem anderen Kundenkonto zugeordnet und kann deshalb nicht übernommen werden. Bitte wende dich an deinen Berater.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -82,6 +82,14 @@ return [
|
|||
Aufgrund der Regelungen des Telemediengesetzes (TMG) sind wir zur Angabe einer allgemeinen Mailadresse im Impressum unserer Website verpflichtet, über die Sie uns per Mail kontaktieren können. Weiterhin haben Sie die Möglichkeit, über Kontaktformulare auf unserer Website Kontakt mit uns aufzunehmen. Über die Kontaktformulare können Sie u.a. Anfragen zu den Produkten oder unserem Geschäft stellen bzw. sich als Berater bei uns bewerben. Die von Ihnen per Mail oder unsere Kontaktformulare an uns übermittelten personenbezogenen Daten werden automatisch für Zwecke der Bearbeitung oder der Kontaktaufnahme zu Ihnen gespeichert.
|
||||
<br><br>
|
||||
Mit Absenden Ihrer Nachricht willigen Sie in die Verarbeitung der übermittelten Daten ein. Die Verarbeitung erfolgt auf Grundlage des Art. 6 Abs. 1 lit. a DSGVO mit Ihrer Einwilligung. Sie können Ihre Einwilligung jederzeit durch Mitteilung an uns widerrufen, ohne dass die Rechtmäßigkeit der aufgrund der Einwilligung bis zum Widerruf erfolgten Verarbeitung berührt wird. Ihre E-Mail-Adresse nutzen wir nur zur Bearbeitung Ihrer Anfrage. Ihre Daten werden anschließend gelöscht, sofern Sie der weitergehenden Verarbeitung und Nutzung nicht zugestimmt haben.
|
||||
<br><br>
|
||||
<strong>Schutz unserer Formulare vor Missbrauch (Cloudflare Turnstile)</strong>
|
||||
<br>
|
||||
Zum Schutz unseres Kontaktformulars und unserer Registrierung vor Spam und automatisierten Zugriffen (Bots) setzen wir den Dienst „Turnstile“ der Cloudflare, Inc., 101 Townsend St., San Francisco, CA 94107, USA, ein. Turnstile prüft beim Aufruf dieser Formulare im Hintergrund, ob die Eingabe von einem Menschen stammt. Dabei werden insbesondere Ihre IP-Adresse sowie technische Merkmale Ihres Browsers und Geräts (z. B. Browserkennung/User-Agent und Merkmale der verschlüsselten Verbindung) an Cloudflare übermittelt und dort ausgewertet. Cloudflare verarbeitet diese Daten in unserem Auftrag zur Erkennung von Bots und nach eigenen Angaben außerdem zur Verbesserung der Bot-Erkennung.
|
||||
<br><br>
|
||||
Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO. Unser berechtigtes Interesse liegt darin, unsere Formulare vor missbräuchlicher Nutzung zu schützen, insbesondere davor, dass über unsere Website Spam-Nachrichten an Dritte verschickt werden. Dabei kann eine Übermittlung in die USA erfolgen. Cloudflare ist unter dem EU-US Data Privacy Framework zertifiziert; ergänzend gelten die EU-Standardvertragsklauseln. Weitere Informationen: https://www.cloudflare.com/turnstile-privacy-policy/ und https://www.cloudflare.com/privacypolicy/
|
||||
<br><br>
|
||||
Zusätzlich begrenzen wir die Anzahl der Anfragen je IP-Adresse und protokollieren abgewiesene Anfragen (Zeitpunkt, IP-Adresse, aufgerufene Seite, Domain-Teil der angegebenen E-Mail-Adresse und Grund der Abweisung) für 30 Tage, ebenfalls auf Grundlage von Art. 6 Abs. 1 lit. f DSGVO. Der Inhalt Ihrer Nachricht und Passwörter werden dabei nicht protokolliert.
|
||||
<br><br>',
|
||||
'data_protect_copy6' => '<strong>VI. Rechtsgrundlagen der Verarbeitung</strong>
|
||||
<br>
|
||||
|
|
@ -260,8 +268,8 @@ Finden Sie weitere Datenschutzinformationen von Google unter: https://www.googl
|
|||
Es werden jedoch keine Informationen herausgegeben, mit denen sich Nutzer persönlich identifizieren lassen.<br>
|
||||
Wenn Sie nicht an dem Tracking-Verfahren teilnehmen möchten, können Sie auch das hierfür erforderliche Setzen eines Cookies ablehnen – etwa per Browser-Einstellung, die das automatische Setzen von Cookies generell deaktiviert. Sie können Cookies für Conversion-Tracking auch deaktivieren, indem Sie Ihren Browser so einstellen, dass Cookies von der Domain „www.googleadservices.com“ blockiert werden.<br>
|
||||
Googles Datenschutzbelehrung zum Conversion-Tracking finden Sie hier ( https://services.google.com/sitestats/de.html )',
|
||||
'data_protect_stand_shop' => 'Stand: 18.02.2022',
|
||||
'data_protect_stand_site' => 'Stand: 05.10.2018',
|
||||
'data_protect_stand_shop' => 'Stand: 09.10.2026',
|
||||
'data_protect_stand_site' => 'Stand: 09.10.2026',
|
||||
'data_protect' => 'Datenschutz',
|
||||
|
||||
];
|
||||
|
|
|
|||
8
resources/lang/de/form_protection.php
Normal file
8
resources/lang/de/form_protection.php
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'captcha_failed' => 'Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut.',
|
||||
'name_invalid' => 'Bitte gib einen gültigen Namen ein (nur Buchstaben, max. 50 Zeichen).',
|
||||
'too_many_requests' => 'Zu viele Anfragen. Bitte versuche es später erneut.',
|
||||
'honeypot_label' => 'Bitte dieses Feld leer lassen',
|
||||
];
|
||||
|
|
@ -211,6 +211,7 @@ return [
|
|||
'payout_top_bonus_text' => 'Zusatz-Bonus für die Top :n: :list.',
|
||||
'payout_rank_label' => 'Platz :n',
|
||||
'payout_ranking_hint' => 'Die Rangliste zählt deine Neupartner mit Starterpaket im Qualifikationszeitraum. Die Auszahlung erfolgt gesammelt nach Ende des Zeitraums.',
|
||||
'payout_ranking_sales_hint' => 'Die Rangliste zählt den Umsatz deiner Neupartner im Qualifikationszeitraum: Starterpaket, Eigen- und Kundenumsatz in Punkten. Die Auszahlung erfolgt gesammelt nach Ende des Zeitraums.',
|
||||
'new_partners_short' => 'Neupartner',
|
||||
'badge_challenge' => 'Challenge',
|
||||
'export_user_id' => 'Berater-ID',
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ return [
|
|||
'tools' => 'Tools',
|
||||
'system_status' => 'Systemstatus',
|
||||
'news_archive' => 'News Archiv',
|
||||
'recognitions' => 'Anerkennungen',
|
||||
'incentive' => 'Incentive',
|
||||
'incentives' => 'Incentives',
|
||||
'create' => 'Neu anlegen',
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
<?php
|
||||
|
||||
return array (
|
||||
'password' => 'Passwörter müssen mindestens 6 Zeichen lang sein und korrekt bestätigt werden.',
|
||||
'reset' => 'Das Passwort wurde zurückgesetzt!',
|
||||
'sent' => 'E-Mail zum Zurücksetzen Ihres Passworts wurde versendet',
|
||||
'token' => 'Der Passwort-Wiederherstellungs-Schlüssel ist ungültig oder abgelaufen.',
|
||||
'user' => 'Es konnte leider kein Nutzer mit dieser E-Mail-Adresse gefunden werden.',
|
||||
);
|
||||
return [
|
||||
'password' => 'Passwörter müssen mindestens 8 Zeichen lang sein und korrekt bestätigt werden.',
|
||||
'reset' => 'Das Passwort wurde zurückgesetzt!',
|
||||
'sent' => 'E-Mail zum Zurücksetzen Ihres Passworts wurde versendet',
|
||||
'token' => 'Der Passwort-Wiederherstellungs-Schlüssel ist ungültig oder abgelaufen.',
|
||||
'user' => 'Es konnte leider kein Nutzer mit dieser E-Mail-Adresse gefunden werden.',
|
||||
];
|
||||
|
|
|
|||
52
resources/lang/de/recognitions.php
Normal file
52
resources/lang/de/recognitions.php
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'title' => 'Anerkennungen',
|
||||
'hero_kicker' => 'Anerkennungen des Monats',
|
||||
'hero_title' => 'Unsere Erfolge im :month',
|
||||
'hero_text' => 'Herzlichen Glückwunsch zu euren großartigen Erfolgen! Hinter jedem Platz stehen Begeisterung, Ausdauer und ganz viel Einsatz – wir sind sehr stolz auf euch.',
|
||||
'period_label' => 'Monat',
|
||||
'period_invalid' => 'Bitte einen gültigen Monat wählen.',
|
||||
'show' => 'Anzeigen',
|
||||
'status_final' => 'Endgültig · festgeschrieben am :date',
|
||||
'status_preliminary' => 'Vorläufig · wird am :date festgeschrieben',
|
||||
'place' => 'Platz :place',
|
||||
'empty' => 'In diesem Monat gibt es hier keine Platzierungen.',
|
||||
'no_months' => 'Es gibt noch keinen abgeschlossenen Monat.',
|
||||
'recruiters_min_hint' => 'Platzierung ab 2 Neupartnern im Monat.',
|
||||
'live_title' => 'Live: So entwickelt sich der :month',
|
||||
'live_text' => 'Die aktuellen Top :limit je Kategorie – alles ist noch offen!',
|
||||
'live_updated' => 'Stand: :time Uhr · wird alle 15 Minuten aktualisiert',
|
||||
'live_rank' => 'Platz',
|
||||
'live_name' => 'Name',
|
||||
'live_value' => 'Wert',
|
||||
'live_empty' => 'Noch keine Einträge in diesem Monat.',
|
||||
'self' => 'ich',
|
||||
'categories' => [
|
||||
'recruiters' => [
|
||||
'title' => 'Top-Recruiter',
|
||||
'description' => 'Die meisten neuen Partner im Monat (direkt gesponsert, Starterpaket bezahlt).',
|
||||
'unit' => 'Neupartner',
|
||||
],
|
||||
'new_customer_abos' => [
|
||||
'title' => 'Die meisten neuen Kunden-Abos',
|
||||
'description' => 'Neu abgeschlossene Kunden-Abos im Monat.',
|
||||
'unit' => 'neue Abos',
|
||||
],
|
||||
'customer_abo_points' => [
|
||||
'title' => 'Höchster Umsatz mit Kunden-Abos',
|
||||
'description' => 'Punkte aus Kunden-Abo-Bestellungen im Monat.',
|
||||
'unit' => 'Punkte',
|
||||
],
|
||||
'customer_abos_total' => [
|
||||
'title' => 'Die meisten Kunden-Abos insgesamt',
|
||||
'description' => 'Aktive Kunden-Abos zum Monatsende (im laufenden Monat: heute).',
|
||||
'unit' => 'Abos',
|
||||
],
|
||||
'total_points' => [
|
||||
'title' => 'Kundenumsatz & Recruiting – 1.000-Punkte-Shop',
|
||||
'description' => 'Höchste Punktzahl aus Kundenumsatz, Eigenumsatz und Recruiting.',
|
||||
'unit' => 'Punkte',
|
||||
],
|
||||
],
|
||||
];
|
||||
10
resources/lang/en/contact.php
Normal file
10
resources/lang/en/contact.php
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'phone_invalid' => 'Please enter a valid phone number.',
|
||||
'too_many_links' => 'The message contains too many links.',
|
||||
'mail_subject' => 'New contact request via :source',
|
||||
'mail_headline' => 'New contact request',
|
||||
'mail_intro' => 'The following request was submitted via the contact form on :source.',
|
||||
'mail_reply_hint' => 'Simply reply to this email to contact the sender directly.',
|
||||
];
|
||||
|
|
@ -35,4 +35,21 @@ return [
|
|||
'delete_customer' => 'Delete customer',
|
||||
'deleted_successfully' => 'Customer was deleted successfully',
|
||||
'delete_customer_confirm' => 'Are you sure you want to delete this customer?',
|
||||
'email_change_requested' => 'The new e-mail address :email is not active yet. We sent the customer a confirmation link to this address. It will be used for login and subscription only after the customer confirms it.',
|
||||
'email_change_pending' => 'New e-mail address :email is awaiting confirmation by the customer (requested on :date, link valid until :until). Until then the previous address remains in use.',
|
||||
'email_change_edit_hint' => 'A changed e-mail address only becomes active once the customer confirms it via the link in the confirmation e-mail.',
|
||||
'email_change_mail_subject' => 'Please confirm your new e-mail address',
|
||||
'email_change_mail_intro' => 'this e-mail address has been entered as the new address of your customer account at mivita.care. Please confirm it with the button. Afterwards you log in to the customer portal with this address and all messages about your subscription will be sent here.',
|
||||
'email_change_mail_button' => 'Confirm e-mail address',
|
||||
'email_change_mail_hint' => 'The link is valid for :days days. If you did not request this change, simply ignore this e-mail – nothing will change. If the button does not work, copy this link into your browser:',
|
||||
'email_change_confirm_title' => 'Confirm new e-mail address',
|
||||
'email_change_confirm_copy' => 'Please confirm that you want to use this e-mail address for your customer account and subscription from now on:',
|
||||
'email_change_confirm_button' => 'Yes, confirm e-mail address',
|
||||
'email_change_done_title' => 'E-mail address confirmed',
|
||||
'email_change_done_copy' => 'From now on you log in to the customer portal with :email.',
|
||||
'email_change_invalid_title' => 'Invalid link',
|
||||
'email_change_invalid_copy' => 'The link has expired, has already been used or was replaced by a newer change. Please contact your consultant.',
|
||||
'email_change_blocked' => 'The e-mail address :email was not applied: it already belongs to a consultant account or to a customer of another consultant (customer sovereignty). Support has been informed.',
|
||||
'email_change_conflict_title' => 'E-mail address already in use',
|
||||
'email_change_conflict_copy' => 'This e-mail address is already assigned to another customer account and therefore cannot be applied. Please contact your consultant.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -10,12 +10,20 @@ return [
|
|||
'data_protect_copy2' => '<strong>I. Definitions</strong> <br> To make our data protection declaration easier to understand, we would first like to explain to you some of the terms used. For the purposes of this privacy policy, the term: <br><br> 1) “personal data” means any information relating to an identified or identifiable natural person (hereinafter “data subject”); An identifiable natural person is a natural person who can be identified directly or indirectly, in particular by reference to an identifier such as a name, an identification number, location data, an online identifier or to one or more special features that express the physical , physiological, genetic, psychological, economic, cultural or social identity of that natural person; <br><br> 2) "Processing" means any operation or series of operations carried out with or without the aid of automated procedures in connection with personal data, such as the collection, recording, organization, structuring, storage, adaptation or modification, reading, retrieving, using, disclosing by transmission, dissemination or any other form of provision, matching or combination, restriction, deletion or destruction; <br><br> 3) "Restriction of processing" means the marking of stored personal data with the aim of restricting their future processing; <br><br> 4) "Profiling" means any type of automated processing of personal data, which consists in using these personal data to evaluate certain personal aspects relating to a natural person, in particular aspects relating to analyze or predict that natural person\'s work performance, economic situation, health, personal preferences, interests, reliability, behavior, location or movements; <br><br> 5) "Pseudonymization" means the processing of personal data in such a way that the personal data can no longer be attributed to a specific data subject without the use of additional information, provided that this additional information is kept separately and is subject to technical and organizational measures that ensure that the personal data is not assigned to an identified or identifiable natural person; <br><br> 6) “Controller” means the natural or legal person, public authority, institution or other body which, alone or jointly with others, decides on the purposes and means of processing personal data; If the purposes and means of this processing are determined by Union or Member State law, the controller or the specific criteria for its nomination may be provided for by Union or Member State law <br><br> 7) “Processor” means a natural or legal person, public authority, agency or other body that processes personal data on behalf of the controller; <br><br> 8) “Recipient” means a natural or legal person, public authority, agency or other body to which personal data is disclosed, regardless of whether it is a third party or not. However, public authorities which may receive personal data in the context of a specific investigative task under Union or Member State law shall not be deemed to be recipients; the processing of these data by the said authorities will be carried out in accordance with the applicable data protection rules in accordance with the purposes of the processing; <br><br> 9) “Third party” means a natural or legal person, public authority, agency or other body, other than the data subject, the controller, the processor and the persons authorized under the direct responsibility of the controller or the processor to process personal data; <br><br> 10) “Consent” of the data subject means any voluntary, informed and unambiguous expression of will in the specific case in the form of a statement or other clear confirmatory act by which the data subject indicates that he or she agrees to the processing of personal data concerning them; <br><br> 11) “personal data breach” means a breach of security that, whether accidental or unlawful, results in the destruction, loss, alteration, or unauthorized disclosure of or access to personal data, that have been transmitted, stored or otherwise processed. <br><br>',
|
||||
'data_protect_copy3' => '<strong>II. Responsible person</strong> <br> The person responsible within the meaning of the General Data Protection Regulation as well as the data protection laws applicable in the member states of the European Union and other data protection regulations is: <br><br> <strong>mivita care gmbh</strong><br> Mr Alois Ried<br> Leinfeld 2<br> 87755 Kirchhaslach<br> Telephone: +49 (0) 8333 94 61 767<br> Mail: info@mivita.care<br> <br><br>',
|
||||
'data_protect_copy4' => '<strong>III. Cookies</strong>',
|
||||
'data_protect_copy5' => '<br> Our website uses cookies. Cookies are small text files that are stored and stored on a computer system via an Internet browser. <br> Many websites and servers use cookies. Many cookies contain a so-called cookie ID. A cookie ID is a unique identifier of the cookie. It consists of a string through which websites and servers can be assigned to the specific Internet browser in which the cookie was stored. This allows the websites and servers visited to distinguish the individual browser of the data subject from other Internet browsers that contain other cookies. A specific Internet browser can be recognized and identified via the unique cookie ID. <br> By using cookies, we can provide you with more user-friendly services that would not be possible without the cookie setting. <br> Using a cookie, the information and offers on our website can be optimized for the user. As already mentioned, cookies enable us to recognize the users of our website. The purpose of this recognition is to make it easier for you to use our websites. For example, the user of a website that uses cookies does not have to re-enter their access data each time they visit the website because this is done by the website and the cookie stored on the user\'s computer system. <br> The data processed by cookies is necessary for the purposes mentioned to protect our legitimate interests in accordance with Article 6 Paragraph 1 Sentence 1 Letter f of the GDPR. <br> You can prevent the setting of cookies through our website at any time by adjusting the appropriate settings on the Internet browser used and thus permanently object to the setting of cookies. Furthermore, cookies that have already been set can be deleted at any time via an internet browser or other software programs. This is possible in all common internet browsers. If you deactivate the setting of cookies in the Internet browser you use, not all functions of our website may be fully usable. <br><br> IV. Data and information that is stored when you visit our website <br> Every time you visit our website, general data and information is recorded that is stored in the server\'s log files. <br><br> <strong>The following data/information may be collected:</strong> <br> - the browser types and versions used - the operating system used, - the website from which you access our website - the sub-sites, which are accessed on our website - the date and time of access to our website, - your IP address - your Internet service provider - other similar data and information that serves to avert danger in the event of attacks on our IT systems . <br><br> We need this information in order to be able to accurately provide the content of our website. to ensure the long-term functionality of our IT system and to be able to provide the authorities with the information necessary for criminal prosecution in the event of a cyber attack. This is also our legitimate interest within the legal basis for processing this data in accordance with Article 6 Paragraph 1 Letter f of the GDPR. When using this general data and information, we do not draw any conclusions about the data subject. We store the anonymous data from the server log files separately from all personal data. V. Contacting us electronically <br><br> Due to the regulations of the Telemedia Act (TMG), we are obliged to provide a general email address in the imprint of our website, which you can use to contact us by email. You also have the option of contacting us using the contact forms on our website. You can use the contact forms to make inquiries about the products or our business or apply to us as a consultant. The personal data you send to us via email or our contact forms will be automatically stored for the purposes of processing or contacting you. <br><br> By sending your message, you consent to the processing of the transmitted data. Processing is carried out on the basis of Article 6 Paragraph 1 Letter a GDPR with your consent. You can revoke your consent at any time by notifying us, without this affecting the lawfulness of the processing carried out based on your consent before its revocation. We only use your email address to process your request. Your data will then be deleted unless you have consented to further processing and use. <br><br>',
|
||||
'data_protect_copy5' => '<br> Our website uses cookies. Cookies are small text files that are stored and stored on a computer system via an Internet browser. <br> Many websites and servers use cookies. Many cookies contain a so-called cookie ID. A cookie ID is a unique identifier of the cookie. It consists of a string through which websites and servers can be assigned to the specific Internet browser in which the cookie was stored. This allows the websites and servers visited to distinguish the individual browser of the data subject from other Internet browsers that contain other cookies. A specific Internet browser can be recognized and identified via the unique cookie ID. <br> By using cookies, we can provide you with more user-friendly services that would not be possible without the cookie setting. <br> Using a cookie, the information and offers on our website can be optimized for the user. As already mentioned, cookies enable us to recognize the users of our website. The purpose of this recognition is to make it easier for you to use our websites. For example, the user of a website that uses cookies does not have to re-enter their access data each time they visit the website because this is done by the website and the cookie stored on the user\'s computer system. <br> The data processed by cookies is necessary for the purposes mentioned to protect our legitimate interests in accordance with Article 6 Paragraph 1 Sentence 1 Letter f of the GDPR. <br> You can prevent the setting of cookies through our website at any time by adjusting the appropriate settings on the Internet browser used and thus permanently object to the setting of cookies. Furthermore, cookies that have already been set can be deleted at any time via an internet browser or other software programs. This is possible in all common internet browsers. If you deactivate the setting of cookies in the Internet browser you use, not all functions of our website may be fully usable. <br><br> IV. Data and information that is stored when you visit our website <br> Every time you visit our website, general data and information is recorded that is stored in the server\'s log files. <br><br> <strong>The following data/information may be collected:</strong> <br> - the browser types and versions used - the operating system used, - the website from which you access our website - the sub-sites, which are accessed on our website - the date and time of access to our website, - your IP address - your Internet service provider - other similar data and information that serves to avert danger in the event of attacks on our IT systems . <br><br> We need this information in order to be able to accurately provide the content of our website. to ensure the long-term functionality of our IT system and to be able to provide the authorities with the information necessary for criminal prosecution in the event of a cyber attack. This is also our legitimate interest within the legal basis for processing this data in accordance with Article 6 Paragraph 1 Letter f of the GDPR. When using this general data and information, we do not draw any conclusions about the data subject. We store the anonymous data from the server log files separately from all personal data. V. Contacting us electronically <br><br> Due to the regulations of the Telemedia Act (TMG), we are obliged to provide a general email address in the imprint of our website, which you can use to contact us by email. You also have the option of contacting us using the contact forms on our website. You can use the contact forms to make inquiries about the products or our business or apply to us as a consultant. The personal data you send to us via email or our contact forms will be automatically stored for the purposes of processing or contacting you. <br><br> By sending your message, you consent to the processing of the transmitted data. Processing is carried out on the basis of Article 6 Paragraph 1 Letter a GDPR with your consent. You can revoke your consent at any time by notifying us, without this affecting the lawfulness of the processing carried out based on your consent before its revocation. We only use your email address to process your request. Your data will then be deleted unless you have consented to further processing and use. <br><br>
|
||||
<strong>Protection of our forms against misuse (Cloudflare Turnstile)</strong>
|
||||
<br>
|
||||
To protect our contact form and our registration against spam and automated access (bots), we use the “Turnstile” service of Cloudflare, Inc., 101 Townsend St., San Francisco, CA 94107, USA. When these forms are loaded, Turnstile checks in the background whether the input comes from a human. In particular, your IP address and technical characteristics of your browser and device (e.g. browser identifier/user agent and characteristics of the encrypted connection) are transmitted to Cloudflare and evaluated there. Cloudflare processes this data on our behalf to detect bots and, according to its own information, also to improve its bot detection.
|
||||
<br><br>
|
||||
The legal basis is Art. 6 (1) (f) GDPR. Our legitimate interest lies in protecting our forms against misuse, in particular against our website being used to send spam messages to third parties. Data may be transferred to the USA in this context. Cloudflare is certified under the EU-US Data Privacy Framework; the EU Standard Contractual Clauses apply in addition. Further information: https://www.cloudflare.com/turnstile-privacy-policy/ and https://www.cloudflare.com/privacypolicy/
|
||||
<br><br>
|
||||
In addition, we limit the number of requests per IP address and log rejected requests (time, IP address, page accessed, domain part of the email address provided and reason for rejection) for 30 days, likewise on the basis of Art. 6 (1) (f) GDPR. The content of your message and passwords are not logged.
|
||||
<br><br>',
|
||||
'data_protect_copy6' => '<strong>VI. Legal basis for processing</strong> <br> If we obtain your consent for a specific processing purpose, Art. 6 I lit. a GDPR serves as the legal basis for the processing operation. <br><br> If the processing of personal data is necessary to fulfill a contract, the processing is based on Art. 6 I lit. b GDPR. This also applies to processing operations that are necessary to carry out pre-contractual measures, such as inquiries about our products or other services. <br><br> If we are subject to a legal obligation that requires the processing of personal data, such as to fulfill tax obligations, the legal basis for the processing is Art. 6 I lit. c GDPR. <br><br> If the processing of personal data is necessary to protect the vital interests of the data subject or another natural person, the processing would be based on Art. 6 I lit. d GDPR. <br><br> Ultimately, Art. 6 I lit. f GDPR can be the legal basis for a processing operation. This is the case if the processing operation is not covered by any of the aforementioned legal bases and the processing is necessary to protect a legitimate interest of our company or a third party, provided that the interests, fundamental rights and freedoms of the data subject do not prevail. If the processing of personal data is based on Article 6 I lit. f GDPR, our legitimate interest is to carry out our business activities for the benefit of the well-being of our employees/shareholders. <br><br>',
|
||||
'data_protect_copy7' => '<strong>VII. Legal or contractual regulations for the provision of personal data</strong> <br> The provision of personal data is partly required by law (e.g. tax regulations) and can also result from contractual regulations (e.g. information about the contractual partner). If you want to conclude a contract with us, it is necessary that you provide us with personal data, which will subsequently be processed by us. Failure to provide personal data would mean that we would not be able to conclude a contract with you. <br><br>',
|
||||
'data_protect_copy8' => '<strong>VIII. Routine deletion/blocking of personal data</strong> <br> We only process and store personal data for the period necessary to achieve the storage purpose or if this is required by the European legislator or another legislator in laws or regulations was provided. If the storage purpose no longer applies or if a storage period prescribed by the European legislator or another responsible legislator expires, the personal data will be blocked or deleted routinely and in accordance with legal regulations. <br><br> After the respective statutory retention period has expired, the relevant data will be routinely deleted unless it is no longer required to fulfill the contract or initiate a contract. According to the legal requirements in Germany, storage takes place in particular for 10 years in accordance with Sections 147 Paragraph 1 AO, 257 Paragraph 1 Nos. 1 and 4, Paragraph 4 HGB (books, records, management reports, accounting documents, trading books, more relevant for taxation). documents, etc.) and 6 years in accordance with Section 257 Paragraph 1 Nos. 2 and 3, Paragraph 4 HGB (commercial letters). <br><br>',
|
||||
'data_protect_copy9' => '<strong>IX. Your rights</strong> <br> <strong>1) Right to confirmation and information</strong> <br> You have the right to request confirmation from us as to whether the relevant personal data is being processed. If you would like to exercise legal rights, you can contact us at any time. <br><br> You also have the right to receive free information from us about the personal data stored about you and a copy of this information. You are also entitled to information about the following information: <br> - the processing purposes - the categories of personal data that are processed - the recipients or categories of recipients to whom the personal data have been disclosed or are still being disclosed, in particular recipients in Third countries or international organizations - if possible, the planned period for which the personal data will be stored or, if this is not possible, the criteria for determining that period - the existence of a right to rectification or deletion of the personal data concerning them or to restriction of processing by the controller or a right to object to this processing - the existence of a right to lodge a complaint with a supervisory authority - if the personal data are not collected from the data subject: all available information about the origin of the data - the existence of automated decision-making including profiling in accordance with Article 22 Para. 1 and 4 GDPR and - at least in these cases - meaningful information about the logic involved as well as the scope and intended effects of such processing for the data subject <br><br> You also have the right to information about: whether personal data has been transferred to a third country or to an international organization. If this is the case, you also have the right to receive information about the appropriate guarantees in connection with the transmission. <br><br> If you would like to exercise this right to information, you can contact us at any time. <br><br> <strong>2) Right to rectification</strong> <br> You have the right to request the immediate correction of incorrect personal data concerning you. You also have the right, taking into account the purposes of the processing, to request the completion of incomplete personal data - including by means of a supplementary declaration. <br><br> If you would like to exercise this right to correction, you can contact us at any time. <br><br> <strong>3) Right to deletion (“Right to be forgotten”)</strong> <br> You have the right to request that the personal data concerning you be deleted immediately, if one of the following reasons applies and to the extent that the processing is not necessary: <br> - The personal data were collected or otherwise processed for purposes for which they are no longer necessary; - You revoke your consent on which the processing was based in accordance with Article 6 Paragraph 1 Letter a GDPR or Article 9 Paragraph 2 Letter a GDPR and there is no other legal basis for the processing; - You object to the processing in accordance with Art. 21 Para. 1 GDPR and there are no overriding legitimate reasons for the processing, or you object to the processing in accordance with Art. 21 Para. 2 GDPR; - The personal data was processed unlawfully; - The deletion of personal data is necessary to comply with a legal obligation under Union or Member State law to which we are subject. - The personal data was collected in relation to information society services offered in accordance with Art. 8 Para. 1 GDPR. <br><br>',
|
||||
'data_protect_stand_shop' => 'As of: February 18, 2022',
|
||||
'data_protect_stand_site' => 'As of: October 5, 2018',
|
||||
'data_protect_stand_shop' => 'As of: October 9, 2026',
|
||||
'data_protect_stand_site' => 'As of: October 9, 2026',
|
||||
'data_protect' => 'Privacy Policy',
|
||||
];
|
||||
|
|
|
|||
8
resources/lang/en/form_protection.php
Normal file
8
resources/lang/en/form_protection.php
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'captcha_failed' => 'The security check failed. Please try again.',
|
||||
'name_invalid' => 'Please enter a valid name (letters only, max. 50 characters).',
|
||||
'too_many_requests' => 'Too many requests. Please try again later.',
|
||||
'honeypot_label' => 'Please leave this field empty',
|
||||
];
|
||||
|
|
@ -211,6 +211,7 @@ return [
|
|||
'payout_top_bonus_text' => 'Extra bonus for the top :n: :list.',
|
||||
'payout_rank_label' => 'Rank :n',
|
||||
'payout_ranking_hint' => 'The ranking counts your new partners with a starter kit during the qualification period. Payout is made collectively after the period ends.',
|
||||
'payout_ranking_sales_hint' => 'The ranking counts the sales of your new partners during the qualification period: starter kit, own and customer sales in points. Payout is made collectively after the period ends.',
|
||||
'new_partners_short' => 'New partners',
|
||||
'badge_challenge' => 'Challenge',
|
||||
'export_user_id' => 'Consultant ID',
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ return [
|
|||
'tools' => 'Tools',
|
||||
'system_status' => 'System status',
|
||||
'news_archive' => 'News Archive',
|
||||
'recognitions' => 'Recognitions',
|
||||
'incentive' => 'Incentive',
|
||||
'incentives' => 'Incentives',
|
||||
'create' => 'Create new',
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
<?php
|
||||
|
||||
return array (
|
||||
'password' => 'Passwords must be at least six characters and match the confirmation.',
|
||||
'reset' => 'Your password has been reset!',
|
||||
'sent' => 'We have e-mailed your password reset link!',
|
||||
'token' => 'This password reset token is invalid.',
|
||||
'user' => 'We can\'t find a user with that e-mail address.',
|
||||
);
|
||||
return [
|
||||
'password' => 'Passwords must be at least eight characters and match the confirmation.',
|
||||
'reset' => 'Your password has been reset!',
|
||||
'sent' => 'We have e-mailed your password reset link!',
|
||||
'token' => 'This password reset token is invalid.',
|
||||
'user' => 'We can\'t find a user with that e-mail address.',
|
||||
];
|
||||
|
|
|
|||
52
resources/lang/en/recognitions.php
Normal file
52
resources/lang/en/recognitions.php
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'title' => 'Recognitions',
|
||||
'hero_kicker' => 'Recognitions of the month',
|
||||
'hero_title' => 'Our successes in :month',
|
||||
'hero_text' => 'Congratulations on your great achievements! Every place stands for enthusiasm, perseverance and a lot of commitment – we are very proud of you.',
|
||||
'period_label' => 'Month',
|
||||
'period_invalid' => 'Please choose a valid month.',
|
||||
'show' => 'Show',
|
||||
'status_final' => 'Final · locked on :date',
|
||||
'status_preliminary' => 'Preliminary · will be locked on :date',
|
||||
'place' => 'Place :place',
|
||||
'empty' => 'No placements this month.',
|
||||
'no_months' => 'There is no completed month yet.',
|
||||
'recruiters_min_hint' => 'Placement from 2 new partners per month.',
|
||||
'live_title' => 'Live: how :month is shaping up',
|
||||
'live_text' => 'The current top :limit per category – everything is still open!',
|
||||
'live_updated' => 'As of :time · updated every 15 minutes',
|
||||
'live_rank' => 'Place',
|
||||
'live_name' => 'Name',
|
||||
'live_value' => 'Value',
|
||||
'live_empty' => 'No entries this month yet.',
|
||||
'self' => 'me',
|
||||
'categories' => [
|
||||
'recruiters' => [
|
||||
'title' => 'Top recruiters',
|
||||
'description' => 'Most new partners in the month (directly sponsored, starter package paid).',
|
||||
'unit' => 'new partners',
|
||||
],
|
||||
'new_customer_abos' => [
|
||||
'title' => 'Most new customer subscriptions',
|
||||
'description' => 'Newly started customer subscriptions in the month.',
|
||||
'unit' => 'new subscriptions',
|
||||
],
|
||||
'customer_abo_points' => [
|
||||
'title' => 'Highest revenue from customer subscriptions',
|
||||
'description' => 'Points from customer subscription orders in the month.',
|
||||
'unit' => 'points',
|
||||
],
|
||||
'customer_abos_total' => [
|
||||
'title' => 'Most customer subscriptions overall',
|
||||
'description' => 'Active customer subscriptions at month end (current month: today).',
|
||||
'unit' => 'subscriptions',
|
||||
],
|
||||
'total_points' => [
|
||||
'title' => 'Customer revenue & recruiting – 1,000 points shop',
|
||||
'description' => 'Highest points from customer revenue, own revenue and recruiting.',
|
||||
'unit' => 'points',
|
||||
],
|
||||
],
|
||||
];
|
||||
10
resources/lang/es/contact.php
Normal file
10
resources/lang/es/contact.php
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'phone_invalid' => 'Por favor, introduce un número de teléfono válido.',
|
||||
'too_many_links' => 'El mensaje contiene demasiados enlaces.',
|
||||
'mail_subject' => 'Nueva solicitud de contacto a través de :source',
|
||||
'mail_headline' => 'Nueva solicitud de contacto',
|
||||
'mail_intro' => 'Se ha recibido la siguiente solicitud a través del formulario de contacto de :source.',
|
||||
'mail_reply_hint' => 'Responde simplemente a este correo para contactar directamente con el remitente.',
|
||||
];
|
||||
|
|
@ -35,4 +35,21 @@ return [
|
|||
'delete_customer' => 'Eliminar cliente',
|
||||
'deleted_successfully' => 'El cliente se eliminó correctamente',
|
||||
'delete_customer_confirm' => '¿Estás seguro de que quieres eliminar este cliente?',
|
||||
'email_change_requested' => 'La nueva dirección de correo :email todavía no está activa. Hemos enviado al cliente un enlace de confirmación a esta dirección. Solo se usará para el acceso y la suscripción cuando el cliente la confirme.',
|
||||
'email_change_pending' => 'La nueva dirección de correo :email está pendiente de confirmación por el cliente (solicitada el :date, enlace válido hasta :until). Hasta entonces se mantiene la dirección anterior.',
|
||||
'email_change_edit_hint' => 'Una dirección de correo modificada solo se activa cuando el cliente la confirma mediante el enlace del correo de confirmación.',
|
||||
'email_change_mail_subject' => 'Confirma tu nueva dirección de correo electrónico',
|
||||
'email_change_mail_intro' => 'esta dirección de correo se ha registrado como nueva dirección de tu cuenta de cliente en mivita.care. Confírmala con el botón. Después accederás al portal de clientes con esta dirección y todos los mensajes sobre tu suscripción llegarán aquí.',
|
||||
'email_change_mail_button' => 'Confirmar dirección de correo',
|
||||
'email_change_mail_hint' => 'El enlace es válido durante :days días. Si no has solicitado este cambio, ignora este correo y todo seguirá igual. Si el botón no funciona, copia este enlace en tu navegador:',
|
||||
'email_change_confirm_title' => 'Confirmar nueva dirección de correo',
|
||||
'email_change_confirm_copy' => 'Confirma que a partir de ahora quieres usar esta dirección de correo para tu cuenta de cliente y tu suscripción:',
|
||||
'email_change_confirm_button' => 'Sí, confirmar dirección de correo',
|
||||
'email_change_done_title' => 'Dirección de correo confirmada',
|
||||
'email_change_done_copy' => 'A partir de ahora accedes al portal de clientes con :email.',
|
||||
'email_change_invalid_title' => 'Enlace no válido',
|
||||
'email_change_invalid_copy' => 'El enlace ha caducado, ya se ha utilizado o ha sido sustituido por un cambio más reciente. Ponte en contacto con tu asesor.',
|
||||
'email_change_blocked' => 'La dirección de correo :email no se ha aplicado: ya está asignada a una cuenta de asesor o a un cliente de otro asesor (titularidad del cliente). Se ha informado al soporte.',
|
||||
'email_change_conflict_title' => 'Dirección de correo ya en uso',
|
||||
'email_change_conflict_copy' => 'Esta dirección de correo ya está asignada a otra cuenta de cliente y por eso no se puede aplicar. Ponte en contacto con tu asesor.',
|
||||
];
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
8
resources/lang/es/form_protection.php
Normal file
8
resources/lang/es/form_protection.php
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'captcha_failed' => 'La verificación de seguridad ha fallado. Por favor, inténtalo de nuevo.',
|
||||
'name_invalid' => 'Por favor, introduce un nombre válido (solo letras, máx. 50 caracteres).',
|
||||
'too_many_requests' => 'Demasiadas solicitudes. Por favor, inténtalo más tarde.',
|
||||
'honeypot_label' => 'Por favor, deja este campo vacío',
|
||||
];
|
||||
|
|
@ -211,6 +211,7 @@ return [
|
|||
'payout_top_bonus_text' => 'Bono adicional para el top :n: :list.',
|
||||
'payout_rank_label' => 'Puesto :n',
|
||||
'payout_ranking_hint' => 'El ranking cuenta tus nuevos socios con paquete de inicio durante el periodo de cualificación. El pago se realiza de forma conjunta al finalizar el periodo.',
|
||||
'payout_ranking_sales_hint' => 'El ranking cuenta las ventas de tus nuevos socios durante el periodo de cualificación: paquete de inicio, ventas propias y de clientes en puntos. El pago se realiza de forma conjunta al finalizar el periodo.',
|
||||
'new_partners_short' => 'Nuevos socios',
|
||||
'badge_challenge' => 'Reto',
|
||||
'export_user_id' => 'ID del asesor',
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ return [
|
|||
'tools' => 'Herramientas',
|
||||
'system_status' => 'Estado del sistema',
|
||||
'news_archive' => 'Archivo de Noticias',
|
||||
'recognitions' => 'Reconocimientos',
|
||||
'incentive' => 'Incentivo',
|
||||
'incentives' => 'Incentivos',
|
||||
'create' => 'Crear nuevo',
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
<?php
|
||||
|
||||
return array (
|
||||
'password' => 'Las contraseñas deben tener al menos 6 caracteres y deben confirmarse correctamente.',
|
||||
'reset' => '¡La contraseña ha sido restablecida!',
|
||||
'sent' => 'Se ha enviado un correo electrónico para restablecer su contraseña.',
|
||||
'token' => 'La clave de recuperación de contraseña no es válida o ha caducado.',
|
||||
'user' => 'Lamentablemente no se pudo encontrar ningún usuario con esta dirección de correo electrónico.',
|
||||
);
|
||||
return [
|
||||
'password' => 'Las contraseñas deben tener al menos 8 caracteres y deben confirmarse correctamente.',
|
||||
'reset' => '¡La contraseña ha sido restablecida!',
|
||||
'sent' => 'Se ha enviado un correo electrónico para restablecer su contraseña.',
|
||||
'token' => 'La clave de recuperación de contraseña no es válida o ha caducado.',
|
||||
'user' => 'Lamentablemente no se pudo encontrar ningún usuario con esta dirección de correo electrónico.',
|
||||
];
|
||||
|
|
|
|||
52
resources/lang/es/recognitions.php
Normal file
52
resources/lang/es/recognitions.php
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'title' => 'Reconocimientos',
|
||||
'hero_kicker' => 'Reconocimientos del mes',
|
||||
'hero_title' => 'Nuestros éxitos en :month',
|
||||
'hero_text' => '¡Enhorabuena por vuestros grandes logros! Detrás de cada puesto hay entusiasmo, constancia y mucho esfuerzo: estamos muy orgullosos de vosotros.',
|
||||
'period_label' => 'Mes',
|
||||
'period_invalid' => 'Elige un mes válido.',
|
||||
'show' => 'Mostrar',
|
||||
'status_final' => 'Definitivo · cerrado el :date',
|
||||
'status_preliminary' => 'Provisional · se cerrará el :date',
|
||||
'place' => 'Puesto :place',
|
||||
'empty' => 'No hay clasificaciones este mes.',
|
||||
'no_months' => 'Todavía no hay ningún mes cerrado.',
|
||||
'recruiters_min_hint' => 'Clasificación a partir de 2 nuevos socios en el mes.',
|
||||
'live_title' => 'En directo: así evoluciona :month',
|
||||
'live_text' => 'El top :limit actual por categoría: ¡todo sigue abierto!',
|
||||
'live_updated' => 'Estado: :time · se actualiza cada 15 minutos',
|
||||
'live_rank' => 'Puesto',
|
||||
'live_name' => 'Nombre',
|
||||
'live_value' => 'Valor',
|
||||
'live_empty' => 'Todavía no hay entradas este mes.',
|
||||
'self' => 'yo',
|
||||
'categories' => [
|
||||
'recruiters' => [
|
||||
'title' => 'Mejores reclutadores',
|
||||
'description' => 'Más socios nuevos en el mes (patrocinio directo, paquete de inicio pagado).',
|
||||
'unit' => 'socios nuevos',
|
||||
],
|
||||
'new_customer_abos' => [
|
||||
'title' => 'Más suscripciones nuevas de clientes',
|
||||
'description' => 'Suscripciones de clientes iniciadas en el mes.',
|
||||
'unit' => 'suscripciones nuevas',
|
||||
],
|
||||
'customer_abo_points' => [
|
||||
'title' => 'Mayor facturación con suscripciones de clientes',
|
||||
'description' => 'Puntos de pedidos de suscripciones de clientes en el mes.',
|
||||
'unit' => 'puntos',
|
||||
],
|
||||
'customer_abos_total' => [
|
||||
'title' => 'Más suscripciones de clientes en total',
|
||||
'description' => 'Suscripciones de clientes activas a final de mes (mes en curso: hoy).',
|
||||
'unit' => 'suscripciones',
|
||||
],
|
||||
'total_points' => [
|
||||
'title' => 'Facturación de clientes y reclutamiento – tienda de 1.000 puntos',
|
||||
'description' => 'Mayor puntuación por facturación de clientes, facturación propia y reclutamiento.',
|
||||
'unit' => 'puntos',
|
||||
],
|
||||
],
|
||||
];
|
||||
10
resources/lang/fr/contact.php
Normal file
10
resources/lang/fr/contact.php
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'phone_invalid' => 'Veuillez saisir un numéro de téléphone valide.',
|
||||
'too_many_links' => 'Le message contient trop de liens.',
|
||||
'mail_subject' => 'Nouvelle demande de contact via :source',
|
||||
'mail_headline' => 'Nouvelle demande de contact',
|
||||
'mail_intro' => 'La demande suivante a été envoyée via le formulaire de contact de :source.',
|
||||
'mail_reply_hint' => 'Répondez simplement à cet e-mail pour contacter directement l’expéditeur.',
|
||||
];
|
||||
|
|
@ -35,4 +35,21 @@ return [
|
|||
'under_review' => 'en vérification',
|
||||
'select' => 'sélectionner',
|
||||
'language_hint' => 'Les factures et documents sont créés dans cette langue.',
|
||||
'email_change_requested' => 'La nouvelle adresse e-mail :email n\'est pas encore active. Nous avons envoyé au client un lien de confirmation à cette adresse. Elle ne sera utilisée pour la connexion et l\'abonnement qu\'après sa confirmation.',
|
||||
'email_change_pending' => 'La nouvelle adresse e-mail :email attend la confirmation du client (demandée le :date, lien valable jusqu\'au :until). D\'ici là, l\'ancienne adresse reste valable.',
|
||||
'email_change_edit_hint' => 'Une adresse e-mail modifiée ne devient active qu\'après confirmation par le client via le lien de l\'e-mail de confirmation.',
|
||||
'email_change_mail_subject' => 'Merci de confirmer ta nouvelle adresse e-mail',
|
||||
'email_change_mail_intro' => 'cette adresse e-mail a été enregistrée comme nouvelle adresse de ton compte client chez mivita.care. Merci de la confirmer avec le bouton. Tu te connecteras ensuite au portail client avec cette adresse et tous les messages concernant ton abonnement arriveront ici.',
|
||||
'email_change_mail_button' => 'Confirmer l\'adresse e-mail',
|
||||
'email_change_mail_hint' => 'Le lien est valable :days jours. Si tu n\'as pas demandé ce changement, ignore simplement cet e-mail – rien ne changera. Si le bouton ne fonctionne pas, copie ce lien dans ton navigateur :',
|
||||
'email_change_confirm_title' => 'Confirmer la nouvelle adresse e-mail',
|
||||
'email_change_confirm_copy' => 'Merci de confirmer que tu souhaites désormais utiliser cette adresse e-mail pour ton compte client et ton abonnement :',
|
||||
'email_change_confirm_button' => 'Oui, confirmer l\'adresse e-mail',
|
||||
'email_change_done_title' => 'Adresse e-mail confirmée',
|
||||
'email_change_done_copy' => 'Désormais, tu te connectes au portail client avec :email.',
|
||||
'email_change_invalid_title' => 'Lien invalide',
|
||||
'email_change_invalid_copy' => 'Le lien a expiré, a déjà été utilisé ou a été remplacé par une modification plus récente. Merci de contacter ton conseiller.',
|
||||
'email_change_blocked' => 'L\'adresse e-mail :email n\'a pas été appliquée : elle est déjà attribuée à un compte conseiller ou à un client d\'un autre conseiller (priorité client). Le support a été informé.',
|
||||
'email_change_conflict_title' => 'Adresse e-mail déjà utilisée',
|
||||
'email_change_conflict_copy' => 'Cette adresse e-mail est déjà attribuée à un autre compte client et ne peut donc pas être appliquée. Merci de contacter ton conseiller.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -80,6 +80,14 @@ return [
|
|||
Vous pouvez nous contacter par e-mail via l’adresse indiquée dans les mentions légales ou par les formulaires de contact du site. Les données personnelles transmises par e-mail ou formulaire sont enregistrées automatiquement pour traiter votre demande et vous contacter.
|
||||
<br><br>
|
||||
En envoyant votre message, vous consentez au traitement des données transmises. Le traitement repose sur l’art. 6 al. 1 let. a RGPD. Vous pouvez révoquer ce consentement à tout moment. Votre adresse e-mail est utilisée uniquement pour traiter votre demande, puis supprimée sauf consentement à un traitement ultérieur.
|
||||
<br><br>
|
||||
<strong>Protection de nos formulaires contre les abus (Cloudflare Turnstile)</strong>
|
||||
<br>
|
||||
Afin de protéger notre formulaire de contact et notre inscription contre le spam et les accès automatisés (bots), nous utilisons le service « Turnstile » de Cloudflare, Inc., 101 Townsend St., San Francisco, CA 94107, États-Unis. Lors du chargement de ces formulaires, Turnstile vérifie en arrière-plan si la saisie provient d’un être humain. À cette fin, votre adresse IP ainsi que des caractéristiques techniques de votre navigateur et de votre appareil (p. ex. identifiant du navigateur/user agent et caractéristiques de la connexion chiffrée) sont notamment transmises à Cloudflare et y sont analysées. Cloudflare traite ces données pour notre compte afin de détecter les bots et, selon ses propres indications, également pour améliorer sa détection des bots.
|
||||
<br><br>
|
||||
La base juridique est l’art. 6 al. 1 let. f RGPD. Notre intérêt légitime consiste à protéger nos formulaires contre toute utilisation abusive, en particulier contre l’envoi de messages de spam à des tiers via notre site web. Un transfert de données vers les États-Unis peut avoir lieu dans ce cadre. Cloudflare est certifiée dans le cadre du Data Privacy Framework UE-États-Unis ; les clauses contractuelles types de l’UE s’appliquent en complément. Plus d’informations : https://www.cloudflare.com/turnstile-privacy-policy/ et https://www.cloudflare.com/privacypolicy/
|
||||
<br><br>
|
||||
En outre, nous limitons le nombre de demandes par adresse IP et enregistrons les demandes refusées (date et heure, adresse IP, page consultée, partie domaine de l’adresse e-mail indiquée et motif du refus) pendant 30 jours, également sur la base de l’art. 6 al. 1 let. f RGPD. Le contenu de votre message et les mots de passe ne sont pas enregistrés.
|
||||
<br><br>',
|
||||
'data_protect_copy6' => '<strong>VI. Bases juridiques du traitement</strong>
|
||||
<br>
|
||||
|
|
@ -186,7 +194,7 @@ return [
|
|||
Les informations collectées servent à établir des statistiques de conversion. Aucune information permettant d’identifier personnellement les utilisateurs n’est transmise.<br>
|
||||
Vous pouvez refuser le cookie nécessaire via les paramètres de votre navigateur ou bloquer les cookies du domaine « www.googleadservices.com ».<br>
|
||||
La politique de confidentialité Google relative au conversion tracking est disponible ici (https://services.google.com/sitestats/de.html)',
|
||||
'data_protect_stand_shop' => 'Version : 18.02.2022',
|
||||
'data_protect_stand_site' => 'Version : 05.10.2018',
|
||||
'data_protect_stand_shop' => 'Version : 09.10.2026',
|
||||
'data_protect_stand_site' => 'Version : 09.10.2026',
|
||||
'data_protect' => 'Protection des données',
|
||||
];
|
||||
|
|
|
|||
8
resources/lang/fr/form_protection.php
Normal file
8
resources/lang/fr/form_protection.php
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'captcha_failed' => 'La vérification de sécurité a échoué. Veuillez réessayer.',
|
||||
'name_invalid' => 'Veuillez saisir un nom valide (lettres uniquement, 50 caractères max.).',
|
||||
'too_many_requests' => 'Trop de demandes. Veuillez réessayer plus tard.',
|
||||
'honeypot_label' => 'Veuillez laisser ce champ vide',
|
||||
];
|
||||
|
|
@ -186,6 +186,7 @@ return [
|
|||
'payout_top_bonus_text' => 'Bonus supplémentaire pour le top :n : :list.',
|
||||
'payout_rank_label' => 'Place :n',
|
||||
'payout_ranking_hint' => 'Le classement compte tes nouveaux partenaires avec kit de démarrage pendant la période de qualification. Le versement est effectué groupé après la fin de la période.',
|
||||
'payout_ranking_sales_hint' => 'Le classement compte le chiffre d’affaires de tes nouveaux partenaires pendant la période de qualification : kit de démarrage, chiffre d’affaires personnel et clients en points. Le versement est effectué groupé après la fin de la période.',
|
||||
'new_partners_short' => 'Nouveaux partenaires',
|
||||
'badge_challenge' => 'Challenge',
|
||||
'export_user_id' => 'ID du conseiller',
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ return [
|
|||
'tools' => 'Outils',
|
||||
'system_status' => 'État du système',
|
||||
'news_archive' => 'Archive des actualités',
|
||||
'recognitions' => 'Distinctions',
|
||||
'incentive' => 'Incentive',
|
||||
'incentives' => 'Incentives',
|
||||
'create' => 'Créer',
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'password' => 'Les mots de passe doivent contenir au moins 6 caractères et être correctement confirmés.',
|
||||
'password' => 'Les mots de passe doivent contenir au moins 8 caractères et être correctement confirmés.',
|
||||
'reset' => 'Le mot de passe a été réinitialisé !',
|
||||
'sent' => 'L’e-mail de réinitialisation de votre mot de passe a été envoyé.',
|
||||
'token' => 'La clé de récupération du mot de passe est invalide ou a expiré.',
|
||||
|
|
|
|||
52
resources/lang/fr/recognitions.php
Normal file
52
resources/lang/fr/recognitions.php
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'title' => 'Distinctions',
|
||||
'hero_kicker' => 'Distinctions du mois',
|
||||
'hero_title' => 'Nos succès en :month',
|
||||
'hero_text' => 'Félicitations pour vos magnifiques résultats ! Derrière chaque place, il y a de l’enthousiasme, de la persévérance et beaucoup d’engagement – nous sommes très fiers de vous.',
|
||||
'period_label' => 'Mois',
|
||||
'period_invalid' => 'Veuillez choisir un mois valide.',
|
||||
'show' => 'Afficher',
|
||||
'status_final' => 'Définitif · arrêté le :date',
|
||||
'status_preliminary' => 'Provisoire · sera arrêté le :date',
|
||||
'place' => 'Place :place',
|
||||
'empty' => 'Aucun classement ce mois-ci.',
|
||||
'no_months' => 'Il n’y a encore aucun mois clôturé.',
|
||||
'recruiters_min_hint' => 'Classement à partir de 2 nouveaux partenaires dans le mois.',
|
||||
'live_title' => 'En direct : l’évolution de :month',
|
||||
'live_text' => 'Le top :limit actuel par catégorie – tout reste possible !',
|
||||
'live_updated' => 'État : :time · mis à jour toutes les 15 minutes',
|
||||
'live_rank' => 'Place',
|
||||
'live_name' => 'Nom',
|
||||
'live_value' => 'Valeur',
|
||||
'live_empty' => 'Pas encore d’entrées ce mois-ci.',
|
||||
'self' => 'moi',
|
||||
'categories' => [
|
||||
'recruiters' => [
|
||||
'title' => 'Meilleurs recruteurs',
|
||||
'description' => 'Le plus de nouveaux partenaires dans le mois (parrainage direct, pack de démarrage payé).',
|
||||
'unit' => 'nouveaux partenaires',
|
||||
],
|
||||
'new_customer_abos' => [
|
||||
'title' => 'Le plus de nouveaux abonnements clients',
|
||||
'description' => 'Abonnements clients démarrés dans le mois.',
|
||||
'unit' => 'nouveaux abonnements',
|
||||
],
|
||||
'customer_abo_points' => [
|
||||
'title' => 'Plus gros chiffre d’affaires avec les abonnements clients',
|
||||
'description' => 'Points issus des commandes d’abonnements clients dans le mois.',
|
||||
'unit' => 'points',
|
||||
],
|
||||
'customer_abos_total' => [
|
||||
'title' => 'Le plus d’abonnements clients au total',
|
||||
'description' => 'Abonnements clients actifs en fin de mois (mois en cours : aujourd’hui).',
|
||||
'unit' => 'abonnements',
|
||||
],
|
||||
'total_points' => [
|
||||
'title' => 'CA clients & recrutement – boutique 1 000 points',
|
||||
'description' => 'Le plus de points grâce au CA clients, au CA personnel et au recrutement.',
|
||||
'unit' => 'points',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
|
@ -8,6 +8,7 @@
|
|||
Aufbewahrungspflicht an den Bestellungen erhalten. Der Eintrag kann nicht mehr bearbeitet werden.
|
||||
</div>
|
||||
@endif
|
||||
@include('admin.customer._email_change_pending')
|
||||
@if($isAdmin)
|
||||
<div class="row">
|
||||
<div class="col-md-3 mb-3">
|
||||
|
|
|
|||
|
|
@ -351,6 +351,10 @@
|
|||
</div>
|
||||
@endif
|
||||
@else
|
||||
@include('admin.customer._email_change_pending')
|
||||
@if(!$isAdmin)
|
||||
<p class="text-muted small mb-2">{{ __('customer.email_change_edit_hint') }}</p>
|
||||
@endif
|
||||
<div class="form-row">
|
||||
<div class="form-group col-md-6">
|
||||
<label class="form-label" for="billing_email">{{ __('E-Mail Address') }}*</label>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
@php($pendingEmailChange = isset($shopping_user->id) && is_numeric($shopping_user->id) ? app(\App\Services\CustomerEmailChangeService::class)->pendingFor($shopping_user) : null)
|
||||
@if($pendingEmailChange)
|
||||
<div class="alert alert-warning">
|
||||
{{ __('customer.email_change_pending', [
|
||||
'email' => $pendingEmailChange->new_email,
|
||||
'date' => $pendingEmailChange->created_at->format('d.m.Y H:i'),
|
||||
'until' => $pendingEmailChange->expires_at->format('d.m.Y H:i'),
|
||||
]) }}
|
||||
</div>
|
||||
@endif
|
||||
159
resources/views/admin/customer_priority/index.blade.php
Normal file
159
resources/views/admin/customer_priority/index.blade.php
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
@extends('layouts.layout-2')
|
||||
|
||||
@section('content')
|
||||
<h4 class="font-weight-bold py-2 mb-2">Kundenhoheit</h4>
|
||||
<p class="text-muted mb-3">
|
||||
Jeder Kunde gehört genau einem Berater. Erkennungsmerkmal ist die E-Mail-Adresse, ohne E-Mail die Adresse
|
||||
(Nachname + PLZ). Hier steht alles, was noch eine Entscheidung braucht – unabhängig davon, ob die Info-Mail
|
||||
gelesen wurde. Ist eine Liste leer, ist nichts zu tun.
|
||||
</p>
|
||||
|
||||
@php($tabs = [
|
||||
'reviews' => ['label' => 'Offene Prüfungen', 'count' => $counts['reviews']],
|
||||
'conflicts' => ['label' => 'Kunde bei mehreren Beratern', 'count' => $counts['conflicts']],
|
||||
'email-changes' => ['label' => 'E-Mail-Änderungen', 'count' => $counts['email_changes']],
|
||||
])
|
||||
|
||||
<div class="nav-tabs-top mb-4">
|
||||
<ul class="nav nav-tabs">
|
||||
@foreach($tabs as $key => $item)
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ $tab === $key ? 'active' : '' }}" href="{{ route('admin_customer_priority', [$key]) }}">
|
||||
{{ $item['label'] }}
|
||||
<span class="badge badge-pill {{ $item['count'] ? 'badge-danger' : 'badge-default' }} ml-1">{{ $item['count'] }}</span>
|
||||
</a>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
|
||||
<div class="tab-content">
|
||||
<div class="tab-pane fade show active">
|
||||
<div class="card-body pb-0">
|
||||
@if($tab === 'reviews')
|
||||
<p>
|
||||
<strong>Was ist das?</strong> Beim Anlegen oder Ändern eines Kunden wurde dieselbe E-Mail oder
|
||||
dieselbe Adresse bei einem anderen Berater gefunden. Bis zur Entscheidung ist der Kunde für den
|
||||
Berater gesperrt und kann bei zwei Beratern liegen.
|
||||
</p>
|
||||
<p class="mb-0">
|
||||
<strong>Was tun?</strong> „Prüfen & zuordnen“ zeigt den Kunden neben den Treffern: Ist es
|
||||
derselbe Kunde, dem bestehenden Berater zuordnen, sonst als neuen Kunden bestätigen.
|
||||
„Zuordnung beibehalten“ schließt den Fall ohne Änderung (z. B. alte Fälle, die sich erledigt haben).
|
||||
</p>
|
||||
@elseif($tab === 'conflicts')
|
||||
<p>
|
||||
<strong>Was ist das?</strong> Diese Kunden liegen mit derselben E-Mail-Adresse bei mehreren
|
||||
Beratern, ohne dass eine Prüfung offen ist. Ursachen waren u. a. das Umhängen einzelner
|
||||
Bestellungen und ein inzwischen behobener Fehler bei der Zuordnung im Shop.
|
||||
</p>
|
||||
<p class="mb-0">
|
||||
<strong>Was tun?</strong> Nach Kundenhoheit gilt die <span class="badge badge-success">älteste Zuordnung</span>.
|
||||
Bei jeder <span class="badge badge-warning">weiteren Zuordnung</span> über „Berater zuweisen“ →
|
||||
„Jeder Bestellung dieses Kunden“ den richtigen Berater wählen. Bereits abgerechnete Punkte
|
||||
ändern sich dadurch nicht automatisch. Kunden, die inzwischen selbst Berater sind, stehen nicht in
|
||||
dieser Liste – ihre eigenen Bestellungen laufen gewollt auf sie selbst.
|
||||
</p>
|
||||
@else
|
||||
<p>
|
||||
<strong>Was ist das?</strong> E-Mail-Änderungen durch Berater. Eine neue Adresse gilt erst, wenn
|
||||
der Kunde sie bestätigt hat. Abgelehnt wird sie, wenn sie schon einem anderen Berater oder einem
|
||||
Beraterkonto gehört.
|
||||
</p>
|
||||
<p class="mb-0">
|
||||
<strong>Was tun?</strong> Bei <span class="badge badge-danger">abgelehnt</span> klären, ob der Kunde
|
||||
dem anderen Berater gehört (dann ggf. in „Kunde bei mehreren Beratern“ oder über die Kundenansicht
|
||||
umhängen) und danach „Erledigt“ klicken. <span class="badge badge-info">wartet auf Bestätigung</span>
|
||||
ist nur zur Info; mit „Verwerfen“ wird der Link ungültig.
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
<div class="card-datatable table-responsive">
|
||||
<table class="table table-striped table-bordered" id="datatables-customer-priority">
|
||||
<thead>
|
||||
<tr>
|
||||
@if($tab === 'reviews')
|
||||
<th>Seit</th>
|
||||
<th>Kunde</th>
|
||||
<th>Zugeordnet</th>
|
||||
<th>Bestellt im Shop</th>
|
||||
<th>Treffer</th>
|
||||
<th>Aktion</th>
|
||||
@elseif($tab === 'conflicts')
|
||||
<th>Kunde</th>
|
||||
<th>Zuordnungen</th>
|
||||
<th>Letzter Eintrag</th>
|
||||
@else
|
||||
<th>Datum</th>
|
||||
<th>Kunde</th>
|
||||
<th>Änderung</th>
|
||||
<th>Angefragt von</th>
|
||||
<th>Status</th>
|
||||
<th>Aktion</th>
|
||||
@endif
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
$(document).ready(function () {
|
||||
@if($tab === 'reviews')
|
||||
var config = {
|
||||
url: '{!! route('admin_customer_priority_reviews_datatable') !!}',
|
||||
serverSide: true,
|
||||
order: [[0, 'asc']],
|
||||
columns: [
|
||||
{data: 'created', name: 'created'},
|
||||
{data: 'customer', name: 'customer'},
|
||||
{data: 'member', name: 'member', searchable: false, orderable: false},
|
||||
{data: 'shop', name: 'shop', searchable: false, orderable: false},
|
||||
{data: 'matches', name: 'matches', searchable: false, orderable: false},
|
||||
{data: 'action', name: 'action', searchable: false, orderable: false},
|
||||
]
|
||||
};
|
||||
@elseif($tab === 'conflicts')
|
||||
var config = {
|
||||
url: '{!! route('admin_customer_priority_conflicts_datatable') !!}',
|
||||
serverSide: true,
|
||||
order: [],
|
||||
columns: [
|
||||
{data: 'customer', name: 'customer', orderable: false},
|
||||
{data: 'groups', name: 'groups', searchable: false, orderable: false},
|
||||
{data: 'last', name: 'last', searchable: false, orderable: false},
|
||||
]
|
||||
};
|
||||
@else
|
||||
var config = {
|
||||
url: '{!! route('admin_customer_priority_email_changes_datatable') !!}',
|
||||
serverSide: true,
|
||||
order: [[0, 'desc']],
|
||||
columns: [
|
||||
{data: 'created', name: 'created'},
|
||||
{data: 'customer', name: 'customer', searchable: false, orderable: false},
|
||||
{data: 'change', name: 'change', searchable: false, orderable: false},
|
||||
{data: 'requested_by', name: 'requested_by', searchable: false, orderable: false},
|
||||
{data: 'status', name: 'status', searchable: false, orderable: false},
|
||||
{data: 'action', name: 'action', searchable: false, orderable: false},
|
||||
]
|
||||
};
|
||||
@endif
|
||||
|
||||
$('#datatables-customer-priority').DataTable({
|
||||
processing: true,
|
||||
serverSide: config.serverSide,
|
||||
ajax: {url: config.url},
|
||||
order: config.order,
|
||||
columns: config.columns,
|
||||
bLengthChange: false,
|
||||
iDisplayLength: 50,
|
||||
language: {
|
||||
url: '/js/datatables-{{ \App::getLocale() }}.json'
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@endsection
|
||||
|
|
@ -140,6 +140,9 @@
|
|||
|
||||
<div class="col-sm-6">
|
||||
<p class="font-weight-bold">Ist das vielleicht dieser Kunde?</p>
|
||||
@if(empty($possibles))
|
||||
<div class="alert alert-secondary">Die damals gefundenen Treffer gibt es nicht mehr. Ordne den Kunden links zu oder schließe den Prüffall in der Übersicht Kundenhoheit mit „Zuordnung beibehalten“.</div>
|
||||
@endif
|
||||
@foreach($possibles as $possible)
|
||||
@if(isset($possible[0]))
|
||||
@php($possible = $possible[0])
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@
|
|||
<input type="hidden" name="action" value="{{$data['action']}}">
|
||||
<input type="hidden" name="id" value="{{$data['id']}}">
|
||||
<input type="hidden" name="view" value="{{$data['view']}}">
|
||||
@if(!empty($data['back']))
|
||||
<input type="hidden" name="back" value="{{ $data['back'] }}">
|
||||
@endif
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group col">
|
||||
|
|
@ -31,12 +34,13 @@
|
|||
@if($value->member_id && $value->shopping_user && $value->shopping_user->number)
|
||||
<div class="custom-controls-stacked">
|
||||
<label class="custom-control custom-radio">
|
||||
<input name="customer_set_member_for" value="0" type="radio" class="custom-control-input" checked="">
|
||||
<span class="custom-control-label">Nur dieser Bestellung einen neuen Berater zuweisen</span>
|
||||
<input name="customer_set_member_for" value="1" type="radio" class="custom-control-input" checked="">
|
||||
<span class="custom-control-label">Jeder Bestellung vom Kunden #{{$value->shopping_user->number}} einen neuen Berater zuweisen</span>
|
||||
</label>
|
||||
<label class="custom-control custom-radio">
|
||||
<input name="customer_set_member_for" value="1" type="radio" class="custom-control-input">
|
||||
<span class="custom-control-label">Jeder Bestellung vom Kunden #{{$value->shopping_user->number}} einen neuen Berater zuweisen</span>
|
||||
<input name="customer_set_member_for" value="0" type="radio" class="custom-control-input">
|
||||
<span class="custom-control-label">Nur dieser Bestellung einen neuen Berater zuweisen
|
||||
<br><small class="text-warning">Achtung: Der Kunde liegt danach bei zwei Beratern (Kundenhoheit).</small></span>
|
||||
</label>
|
||||
</div>
|
||||
@endif
|
||||
|
|
@ -45,12 +49,13 @@
|
|||
@if($value->member_id && $value->number)
|
||||
<div class="custom-controls-stacked">
|
||||
<label class="custom-control custom-radio">
|
||||
<input name="customer_set_member_for" value="0" type="radio" class="custom-control-input" checked="">
|
||||
<span class="custom-control-label">Nur diesen Kunden Bestellung einen neuen Berater zuweisen</span>
|
||||
<input name="customer_set_member_for" value="1" type="radio" class="custom-control-input" checked="">
|
||||
<span class="custom-control-label">Jeder Bestellung diesen Kunden #{{$value->number}} einen neuen Berater zuweisen</span>
|
||||
</label>
|
||||
<label class="custom-control custom-radio">
|
||||
<input name="customer_set_member_for" value="1" type="radio" class="custom-control-input">
|
||||
<span class="custom-control-label">Jeder Bestellung diesen Kunden #{{$value->number}} einen neuen Berater zuweisen</span>
|
||||
<input name="customer_set_member_for" value="0" type="radio" class="custom-control-input">
|
||||
<span class="custom-control-label">Nur diesen Kunden Bestellung einen neuen Berater zuweisen
|
||||
<br><small class="text-warning">Achtung: Der Kunde liegt danach bei zwei Beratern (Kundenhoheit).</small></span>
|
||||
</label>
|
||||
</div>
|
||||
@endif
|
||||
|
|
|
|||
|
|
@ -154,7 +154,7 @@
|
|||
{{__('email.'.$key)}}:
|
||||
</td>
|
||||
<td style="color:#37302d; text-align: left; vertical-align: top;line-height:1.6em;" align="center">
|
||||
{!! nl2br($value) !!}
|
||||
{!! nl2br(e($value)) !!}
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
|
|
|
|||
108
resources/views/emails/customer_email_change.blade.php
Normal file
108
resources/views/emails/customer_email_change.blade.php
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
|
||||
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>{{ $mailTitle }}</title>
|
||||
<style type="text/css">
|
||||
a { text-decoration: none; border: 0; outline: none; color: #919f7a; }
|
||||
a:hover { color: #b6b600; }
|
||||
td, h1, h2, h3 { font-family: Helvetica, Verdana, Arial, sans-serif; font-weight: 400; }
|
||||
td { text-align: left; }
|
||||
body { -webkit-font-smoothing: antialiased; width: 100%; height: 100%; color: #37302d; background: #ffffff; font-size: 15px; line-height: 26px; }
|
||||
table { border-collapse: separate !important; }
|
||||
.headline { color: #37302d; font-size: 18px; }
|
||||
.force-full-width { width: 100% !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="body" style="padding:0; margin:0; display:block; background:#f8f8f8; -webkit-text-size-adjust:none" bgcolor="#f8f8f8">
|
||||
<table align="left" cellpadding="0" cellspacing="0" width="100%" height="100%">
|
||||
<tr>
|
||||
<td align="left" valign="top" bgcolor="#f8f8f8" width="100%">
|
||||
<br>
|
||||
<table style="margin: 0 auto;" cellpadding="0" cellspacing="0" width="700" class="w320">
|
||||
<tr>
|
||||
<td align="left" valign="top">
|
||||
<table style="margin: 0 auto;" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tr>
|
||||
<td style="text-align:center;">
|
||||
<center>
|
||||
<picture style="text-align: center">
|
||||
<img src="https://my.mivita.care/images/logo_mivita.png" alt="mivita.care" style="border:none" width="230">
|
||||
</picture>
|
||||
</center>
|
||||
<br>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<table cellpadding="10" style="padding: 10px;" cellspacing="0" border="0" width="100%" bgcolor="#ffffff">
|
||||
<tr>
|
||||
<td class="headline">
|
||||
<b>{{ $mailTitle }}</b>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="color:#37302d;line-height:1.6em;">
|
||||
{{ $salutation }},
|
||||
<br><br>
|
||||
{{ __('customer.email_change_mail_intro') }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<table style="background-color: #f6fdf5; border: 2px solid #b5c49b; border-radius: 8px; margin: 20px 0;" cellpadding="15" cellspacing="0" width="100%">
|
||||
<tr>
|
||||
<td style="text-align: center; padding-top: 10px;">
|
||||
<a href="{{ $link }}" style="display: inline-block; background: #FFCC00; color: #37302d; padding: 12px 24px; text-decoration: none; border-radius: 5px; font-weight: bold; font-size: 14px;">
|
||||
{{ __('customer.email_change_mail_button') }}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="color:#7B7B7E; font-size: 13px; line-height:1.6em;">
|
||||
{{ __('customer.email_change_mail_hint', ['days' => (int) (\App\Services\CustomerEmailChangeService::TTL_HOURS / 24)]) }}
|
||||
<br>
|
||||
<a href="{{ $link }}" style="color:#7B7B7E; text-decoration: underline; word-break: break-all;">{{ $link }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="color:#37302d;line-height:1.6em;">
|
||||
<br>
|
||||
{{ $greetings }}<br>
|
||||
{{ $sender }}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<center>
|
||||
<table cellpadding="0" cellspacing="0" class="force-full-width" bgcolor="#f8f8f8" style="margin: 0 auto; text-align: center;line-height:1.6em;">
|
||||
<tr>
|
||||
<td style="color:#7B7B7E; font-size:14px; text-align: center;line-height:1.6em;" align="center">
|
||||
<p>{{__('email.footer_copy1')}}</p>
|
||||
<br>
|
||||
<a href="https://www.mivita.care" style="color: #7B7B7E; text-decoration: underline;">www.mivita.care</a>
|
||||
<br>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="color:#bbbbbb; font-size:12px; text-align: center;line-height:1.6em;" align="center">
|
||||
<p>{{__('email.footer_copy2')}}</p>
|
||||
<a href="https://www.mivita.care/datenschutz">{{ __('dataprotect.data_protect') }}</a> <br>
|
||||
<p>{{__('email.footer_copy3')}}</p>
|
||||
<br><br>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</center>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -40,9 +40,6 @@
|
|||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ route('login') }}">{{ __('Login') }}</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ route('register') }}">{{ __('Register') }}</a>
|
||||
</li>
|
||||
@else
|
||||
<li class="nav-item dropdown">
|
||||
<a id="navbarDropdown" class="nav-link dropdown-toggle" href="#" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" v-pre>
|
||||
|
|
|
|||
|
|
@ -55,6 +55,12 @@
|
|||
<div>{{ __('navigation.news_archive') }}</div>
|
||||
</a>
|
||||
</li>
|
||||
<li class="sidenav-item{{ Request::is('user/recognitions') ? ' active' : '' }}">
|
||||
<a href="{{ route('user_recognitions') }}" class="sidenav-link"><i
|
||||
class="sidenav-icon ion ion-md-trophy"></i>
|
||||
<div>{{ __('navigation.recognitions') }}</div>
|
||||
</a>
|
||||
</li>
|
||||
@if (Auth::user()->isVIP())
|
||||
<li class="sidenav-item @if (Request::is('user/backoffice/statistics*')) open @endif">
|
||||
<a href="javascript:void(0)" class="sidenav-link sidenav-toggle">
|
||||
|
|
@ -326,6 +332,18 @@
|
|||
<div>{{ __('navigation.clients') }}</div>
|
||||
</a>
|
||||
</li>
|
||||
@php $customerPriorityOpen = Cache::remember('customer_priority_open_count', 60, fn () => app(\App\Services\CustomerPriorityOverviewService::class)->openCount()); @endphp
|
||||
<li class="sidenav-item{{ Request::is('admin/customer-priority*') ? ' active' : '' }}">
|
||||
<a href="{{ route('admin_customer_priority') }}" class="sidenav-link"><i
|
||||
class="sidenav-icon ion ion-md-git-compare"></i>
|
||||
<div>Kundenhoheit</div>
|
||||
@if ($customerPriorityOpen)
|
||||
<div class="pl-1 ml-auto">
|
||||
<div class="badge badge-danger">{{ $customerPriorityOpen }}</div>
|
||||
</div>
|
||||
@endif
|
||||
</a>
|
||||
</li>
|
||||
<li class="sidenav-item @if (Request::is('admin/newsletter-lists*')) open @endif">
|
||||
<a href="javascript:void(0)" class="sidenav-link sidenav-toggle">
|
||||
<i class="sidenav-icon ion ion-md-mail"></i>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
@extends('portal.layouts.public')
|
||||
|
||||
@section('content')
|
||||
<div class="text-center" style="max-width: 640px; margin: 0 auto;">
|
||||
<i class="ion ion-md-mail text-primary" style="font-size: 3rem;"></i>
|
||||
<h4 class="font-weight-bold mt-3">{{ __('customer.email_change_confirm_title') }}</h4>
|
||||
<p class="text-muted">{{ __('customer.email_change_confirm_copy') }}</p>
|
||||
<p class="font-weight-bold">{{ $change->new_email }}</p>
|
||||
|
||||
<form method="POST" action="{{ route('customer_email_change_store', [$token]) }}">
|
||||
@csrf
|
||||
<button type="submit" class="btn btn-primary">{{ __('customer.email_change_confirm_button') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
@endsection
|
||||
23
resources/views/portal/customer/email_change_done.blade.php
Normal file
23
resources/views/portal/customer/email_change_done.blade.php
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
@extends('portal.layouts.public')
|
||||
|
||||
@section('content')
|
||||
<div class="text-center" style="max-width: 640px; margin: 0 auto;">
|
||||
@if ($status === \App\Services\CustomerEmailChangeService::STATUS_CONFIRMED)
|
||||
<i class="ion ion-md-checkmark-circle text-success" style="font-size: 3rem;"></i>
|
||||
<h4 class="font-weight-bold mt-3">{{ __('customer.email_change_done_title') }}</h4>
|
||||
<p class="text-muted">{{ __('customer.email_change_done_copy', ['email' => $email]) }}</p>
|
||||
@elseif ($status === \App\Services\CustomerEmailChangeService::STATUS_CONFLICT)
|
||||
<i class="ion ion-md-alert text-warning" style="font-size: 3rem;"></i>
|
||||
<h4 class="font-weight-bold mt-3">{{ __('customer.email_change_conflict_title') }}</h4>
|
||||
<p class="text-muted">{{ __('customer.email_change_conflict_copy') }}</p>
|
||||
@else
|
||||
<i class="ion ion-md-close-circle text-danger" style="font-size: 3rem;"></i>
|
||||
<h4 class="font-weight-bold mt-3">{{ __('customer.email_change_invalid_title') }}</h4>
|
||||
<p class="text-muted">{{ __('customer.email_change_invalid_copy') }}</p>
|
||||
@endif
|
||||
|
||||
<a href="{{ \App\Services\Util::getMyMivitaPortalUrl() }}" class="btn btn-outline-secondary btn-sm mt-2">
|
||||
<i class="ion ion-md-home mr-1"></i>{{ __('abo.card_update_to_portal') }}
|
||||
</a>
|
||||
</div>
|
||||
@endsection
|
||||
|
|
@ -23,7 +23,7 @@
|
|||
</ul>
|
||||
<p class="text-muted small mb-0">
|
||||
<i class="ion ion-md-information-circle mr-1"></i>
|
||||
{{ __('incentive.payout_ranking_hint') }}
|
||||
{{ $incentive->ranksBySalesOfNewPartners() ? __('incentive.payout_ranking_sales_hint') : __('incentive.payout_ranking_hint') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1381,7 +1381,7 @@
|
|||
<span class="tile-label">{{ __('incentive.payout_per_partner_label') }}</span>
|
||||
</div>
|
||||
@endif
|
||||
@if ($incentive->rankBonuses() !== [] && !$incentive->ranksByPartnerCount())
|
||||
@if ($incentive->rankBonuses() !== [] && !$incentive->isCashChallenge())
|
||||
<div>
|
||||
<span class="tile-label d-block mb-1">{{ __('incentive.payout_top_bonus_title', ['n' => count($incentive->rankBonuses())]) }}</span>
|
||||
@foreach ($incentive->rankBonuses() as $bonusRank => $bonusAmount)
|
||||
|
|
@ -1415,7 +1415,7 @@
|
|||
</div>
|
||||
@endif
|
||||
|
||||
@if ($incentive->hasCashPayout() && $incentive->ranksByPartnerCount())
|
||||
@if ($incentive->isCashChallenge())
|
||||
{{-- Top 3 Bonus (Recruiting-Challenge) --}}
|
||||
<div class="col-md-4">
|
||||
<div class="card info-tile h-100 shadow-sm" id="incentive-bonus-tile">
|
||||
|
|
@ -1468,7 +1468,7 @@
|
|||
</div>
|
||||
|
||||
{{-- ===== SO FUNKTIONIERT DIE CHALLENGE (Geldpraemie) / PUNKTE-DETAIL ===== --}}
|
||||
@if ($incentive->hasCashPayout() && $incentive->ranksByPartnerCount())
|
||||
@if ($incentive->isCashChallenge())
|
||||
<div class="points-section points-section-wide mb-5" id="incentive-how-it-works">
|
||||
<h4 class="points-heading text-left">
|
||||
<i class="ion ion-md-cash text-warning mr-1"></i>
|
||||
|
|
@ -1490,7 +1490,7 @@
|
|||
@if ($incentive->rankBonuses() !== [])
|
||||
<li>{{ __('incentive.payout_top_bonus_text', ['n' => count($incentive->rankBonuses()), 'list' => collect($incentive->rankBonuses())->map(fn ($amount, $rank) => __('incentive.payout_rank_label', ['n' => $rank]).' '.\App\Services\Incentive\IncentivePayoutService::formatAmount($amount))->implode(', ')]) }}</li>
|
||||
@endif
|
||||
<li>{{ __('incentive.payout_ranking_hint') }}</li>
|
||||
<li>{{ $incentive->ranksBySalesOfNewPartners() ? __('incentive.payout_ranking_sales_hint') : __('incentive.payout_ranking_hint') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
324
resources/views/user/recognitions/index.blade.php
Normal file
324
resources/views/user/recognitions/index.blade.php
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
@extends('layouts.layout-2')
|
||||
|
||||
@section('styles')
|
||||
<style>
|
||||
.recognition-hero {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: .75rem;
|
||||
background: linear-gradient(135deg, #6b7758 0%, #4f5940 100%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.recognition-hero .hero-trophy {
|
||||
position: absolute;
|
||||
right: -1rem;
|
||||
bottom: -2.5rem;
|
||||
font-size: 13rem;
|
||||
line-height: 1;
|
||||
color: rgba(255, 255, 255, .08);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.recognition-hero .hero-kicker {
|
||||
letter-spacing: .08em;
|
||||
color: #f4d77a;
|
||||
}
|
||||
|
||||
.recognition-hero .badge-status {
|
||||
background: rgba(255, 255, 255, .18);
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.recognition-card .card-header {
|
||||
border-bottom: 0;
|
||||
padding-bottom: .25rem;
|
||||
}
|
||||
|
||||
.recognition-card .category-icon {
|
||||
font-size: 1.6rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.recognition-place {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: .5rem;
|
||||
padding: .6rem .75rem;
|
||||
margin-bottom: .5rem;
|
||||
background: #f7f7f5;
|
||||
}
|
||||
|
||||
.recognition-place.place-1 {
|
||||
background: #fff7dc;
|
||||
box-shadow: inset 3px 0 0 #e0b537;
|
||||
}
|
||||
|
||||
.recognition-place.place-2 {
|
||||
background: #f1f3f5;
|
||||
box-shadow: inset 3px 0 0 #a9b0b8;
|
||||
}
|
||||
|
||||
.recognition-place.place-3 {
|
||||
background: #fbefe6;
|
||||
box-shadow: inset 3px 0 0 #c98a5a;
|
||||
}
|
||||
|
||||
.recognition-place .place-medal {
|
||||
flex: 0 0 2.25rem;
|
||||
font-size: 1.6rem;
|
||||
line-height: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.recognition-place .place-number {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.8rem;
|
||||
height: 1.8rem;
|
||||
border-radius: 50%;
|
||||
background: #dfe2d8;
|
||||
color: #4f5940;
|
||||
font-weight: 700;
|
||||
font-size: .85rem;
|
||||
}
|
||||
|
||||
.recognition-place .place-members {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.recognition-place .place-member {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.recognition-place .place-member + .place-member {
|
||||
margin-top: .35rem;
|
||||
}
|
||||
|
||||
.recognition-place .place-value {
|
||||
flex: 0 0 auto;
|
||||
text-align: right;
|
||||
padding-left: .75rem;
|
||||
}
|
||||
|
||||
.recognition-live .live-dot {
|
||||
display: inline-block;
|
||||
width: .6rem;
|
||||
height: .6rem;
|
||||
border-radius: 50%;
|
||||
background: #d9534f;
|
||||
margin-right: .4rem;
|
||||
animation: recognition-pulse 1.6s infinite;
|
||||
}
|
||||
|
||||
@keyframes recognition-pulse {
|
||||
0% { box-shadow: 0 0 0 0 rgba(217, 83, 79, .6); }
|
||||
70% { box-shadow: 0 0 0 .5rem rgba(217, 83, 79, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(217, 83, 79, 0); }
|
||||
}
|
||||
|
||||
.recognition-live .nav-pills .nav-link {
|
||||
font-size: .85rem;
|
||||
}
|
||||
|
||||
.recognition-live .nav-pills .nav-link.active,
|
||||
.recognition-live .nav-pills .nav-link.active:hover,
|
||||
.recognition-live .nav-pills .nav-link.active:focus {
|
||||
background-color: #d7d700;
|
||||
color: #565600;
|
||||
}
|
||||
|
||||
.recognition-live .table td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
</style>
|
||||
@endsection
|
||||
|
||||
@section('content')
|
||||
@php
|
||||
$viewerId = (int) auth()->id();
|
||||
$medals = [1 => '🥇', 2 => '🥈', 3 => '🥉'];
|
||||
$categoryIcons = [
|
||||
'recruiters' => '🏆',
|
||||
'new_customer_abos' => '🌟',
|
||||
'customer_abo_points' => '💙',
|
||||
'customer_abos_total' => '⭐',
|
||||
'total_points' => '🚀',
|
||||
];
|
||||
$formatValue = fn ($value) => number_format((float) $value, floor((float) $value) == (float) $value ? 0 : 1, ',', '.');
|
||||
$periodOptions = collect($periods)->mapWithKeys(fn (array $period) => [
|
||||
sprintf('%d-%02d', $period['year'], $period['month']) => HTMLHelper::getMonth($period['month']).' '.$period['year'],
|
||||
])->all();
|
||||
$selectedPeriod = $selected ? sprintf('%d-%02d', $selected['year'], $selected['month']) : null;
|
||||
@endphp
|
||||
|
||||
<h4 class="font-weight-bold py-2 mb-2 d-flex flex-wrap justify-content-between align-items-center w-100">
|
||||
<div class="mb-2 mb-sm-0">
|
||||
<i class="ion ion-md-trophy text-warning mr-1"></i>{{ __('recognitions.title') }}
|
||||
</div>
|
||||
@if ($periodOptions !== [])
|
||||
<form method="GET" action="{{ route('user_recognitions') }}" class="form-inline">
|
||||
<label for="period" class="small text-muted font-weight-normal mr-2">{{ __('recognitions.period_label') }}</label>
|
||||
{{ html()->select('period', $periodOptions, $selectedPeriod)->id('period')->class('custom-select custom-select-sm mr-2')->attribute('onchange', 'this.form.submit()') }}
|
||||
<noscript><button type="submit" class="btn btn-sm btn-primary">{{ __('recognitions.show') }}</button></noscript>
|
||||
</form>
|
||||
@endif
|
||||
</h4>
|
||||
|
||||
@if ($recognitions === null)
|
||||
<div class="alert alert-info">{{ __('recognitions.no_months') }}</div>
|
||||
@else
|
||||
<div class="recognition-hero p-4 p-md-5 mb-4">
|
||||
<i class="ion ion-md-trophy hero-trophy"></i>
|
||||
<div class="position-relative">
|
||||
<div class="hero-kicker text-uppercase small font-weight-bold mb-2">{{ __('recognitions.hero_kicker') }}</div>
|
||||
<h2 class="font-weight-bold text-white mb-2">
|
||||
{{ __('recognitions.hero_title', ['month' => HTMLHelper::getMonth($recognitions['month']).' '.$recognitions['year']]) }}
|
||||
🥳💙
|
||||
</h2>
|
||||
<p class="mb-3" style="max-width: 46rem; opacity: .9;">{{ __('recognitions.hero_text') }}</p>
|
||||
@if ($recognitions['is_final'])
|
||||
<span class="badge badge-status px-2 py-1">
|
||||
<i class="ion ion-md-lock mr-1"></i>{{ __('recognitions.status_final', ['date' => $recognitions['frozen_at']?->format('d.m.Y')]) }}
|
||||
</span>
|
||||
@else
|
||||
<span class="badge badge-status px-2 py-1">
|
||||
<i class="ion ion-md-time mr-1"></i>{{ __('recognitions.status_preliminary', ['date' => $recognitions['freeze_date']->format('d.m.Y')]) }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
@foreach (\App\Services\Team\RecognitionService::CATEGORIES as $category)
|
||||
@php $podium = $recognitions['categories'][$category] ?? []; @endphp
|
||||
<div class="col-md-6 col-xl-4 d-flex align-items-stretch mb-4">
|
||||
<div class="card recognition-card w-100">
|
||||
<div class="card-header bg-transparent d-flex align-items-start">
|
||||
<span class="category-icon mr-2">{{ $categoryIcons[$category] }}</span>
|
||||
<div>
|
||||
<h6 class="font-weight-bold mb-1">{{ __('recognitions.categories.'.$category.'.title') }}</h6>
|
||||
<div class="text-muted small">{{ __('recognitions.categories.'.$category.'.description') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body pt-2">
|
||||
@forelse ($podium as $place)
|
||||
<div class="recognition-place place-{{ $place['place'] }}">
|
||||
<div class="place-medal">
|
||||
@if (isset($medals[$place['place']]))
|
||||
<span title="{{ __('recognitions.place', ['place' => $place['place']]) }}">{{ $medals[$place['place']] }}</span>
|
||||
@else
|
||||
<span class="place-number" title="{{ __('recognitions.place', ['place' => $place['place']]) }}">{{ $place['place'] }}</span>
|
||||
@endif
|
||||
</div>
|
||||
<div class="place-members ml-2">
|
||||
@foreach ($place['members'] as $member)
|
||||
<div class="place-member">
|
||||
@include('components._avatar', [
|
||||
'imageUrl' => $member['profile_image_url'] ?? null,
|
||||
'firstName' => $member['first_name'],
|
||||
'lastName' => $member['last_name'],
|
||||
'size' => 36,
|
||||
])
|
||||
<span class="ml-2 font-weight-semibold text-truncate">
|
||||
{{ $member['first_name'] }} {{ $member['last_name'] }}
|
||||
@if ((int) $member['user_id'] === $viewerId)
|
||||
<span class="badge badge-pill badge-info ml-1">{{ __('recognitions.self') }}</span>
|
||||
@endif
|
||||
</span>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
<div class="place-value">
|
||||
<div class="font-weight-bold" style="font-size: 1.1rem;">{{ $formatValue($place['value']) }}</div>
|
||||
<div class="text-muted small">{{ __('recognitions.categories.'.$category.'.unit') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<div class="text-muted small">{{ __('recognitions.empty') }}</div>
|
||||
@endforelse
|
||||
@if ($category === 'recruiters')
|
||||
<div class="text-muted small mt-2">{{ __('recognitions.recruiters_min_hint') }}</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="card recognition-live mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-1 d-flex align-items-center">
|
||||
<span class="live-dot"></span>
|
||||
{{ __('recognitions.live_title', ['month' => HTMLHelper::getMonth($live['month']).' '.$live['year']]) }}
|
||||
</h5>
|
||||
<div class="text-muted small">
|
||||
{{ __('recognitions.live_text', ['limit' => \App\Services\Team\RecognitionService::LIVE_LIMIT]) }}
|
||||
· {{ __('recognitions.live_updated', ['time' => $live['calculated_at']->format('d.m.Y H:i')]) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ul class="nav nav-pills flex-wrap mb-3" role="tablist">
|
||||
@foreach (\App\Services\Team\RecognitionService::CATEGORIES as $category)
|
||||
<li class="nav-item mb-1">
|
||||
<a class="nav-link {{ $loop->first ? 'active' : '' }}" data-toggle="tab" href="#live-{{ $category }}" role="tab">
|
||||
{{ $categoryIcons[$category] }} {{ __('recognitions.categories.'.$category.'.title') }}
|
||||
</a>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
<div class="tab-content">
|
||||
@foreach (\App\Services\Team\RecognitionService::CATEGORIES as $category)
|
||||
<div class="tab-pane fade {{ $loop->first ? 'show active' : '' }}" id="live-{{ $category }}" role="tabpanel">
|
||||
@if (empty($live['categories'][$category]))
|
||||
<div class="text-muted small">{{ __('recognitions.live_empty') }}</div>
|
||||
@else
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 5rem;">{{ __('recognitions.live_rank') }}</th>
|
||||
<th>{{ __('recognitions.live_name') }}</th>
|
||||
<th class="text-right">{{ __('recognitions.categories.'.$category.'.unit') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($live['categories'][$category] as $row)
|
||||
<tr class="{{ (int) $row['user_id'] === $viewerId ? 'table-info' : '' }}">
|
||||
<td class="font-weight-bold">
|
||||
{{ $medals[$row['rank']] ?? $row['rank'].'.' }}
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
@include('components._avatar', [
|
||||
'imageUrl' => $row['profile_image_url'] ?? null,
|
||||
'firstName' => $row['first_name'],
|
||||
'lastName' => $row['last_name'],
|
||||
'size' => 32,
|
||||
])
|
||||
<span class="ml-2">
|
||||
{{ $row['first_name'] }} {{ $row['last_name'] }}
|
||||
@if ((int) $row['user_id'] === $viewerId)
|
||||
<span class="badge badge-pill badge-info ml-1">{{ __('recognitions.self') }}</span>
|
||||
@endif
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-right font-weight-bold">{{ $formatValue($row['value']) }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
|
@ -74,7 +74,7 @@
|
|||
</style>
|
||||
|
||||
<!-- /PAGE HEADER -->
|
||||
<script src='https://www.google.com/recaptcha/api.js?explicit&hl={{ \App::getLocale() }}'></script>
|
||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
||||
|
||||
<!-- -->
|
||||
<section>
|
||||
|
|
@ -87,6 +87,11 @@
|
|||
|
||||
<div class="box-static box-transparent box-bordered padding-20">
|
||||
{!! Form::open(['action' => '/kontakt']) !!}
|
||||
{{ html()->hidden(\App\Services\FormSpamGuard::TIMESTAMP_FIELD, $form_token) }}
|
||||
<div style="position: absolute; left: -10000px; top: auto; width: 1px; height: 1px; overflow: hidden;" aria-hidden="true">
|
||||
<label for="{{ \App\Services\FormSpamGuard::HONEYPOT_FIELD }}">{{ __('form_protection.honeypot_label') }}</label>
|
||||
{{ html()->text(\App\Services\FormSpamGuard::HONEYPOT_FIELD)->attributes(['tabindex' => '-1', 'autocomplete' => 'off']) }}
|
||||
</div>
|
||||
<div class="text-right" style="margin-bottom: 8px; margin-top: -16px;">
|
||||
<em class="small">* {{trans('register.required_fields')}}</em>
|
||||
</div>
|
||||
|
|
@ -129,7 +134,12 @@
|
|||
</div>
|
||||
<div class="form-group col-md-12">
|
||||
{!! Form::label('phone', __('Phone')) !!}
|
||||
{!! Form::text('phone', null, ['class' => 'form-control', 'placeholder'=>__('Phone')]) !!}
|
||||
{!! Form::text('phone', null, ['class' => 'form-control '.($errors->has('phone') ? 'error is-invalid' : ''), 'placeholder'=>__('Phone')]) !!}
|
||||
@if ($errors->has('phone'))
|
||||
<div class="alert alert-danger error"><!-- DANGER -->
|
||||
{{ $errors->first('phone') }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<div class="row contact-row">
|
||||
|
|
@ -208,10 +218,10 @@
|
|||
|
||||
<div class="row contact-row">
|
||||
<div class="col-md-12">
|
||||
<div class="g-recaptcha" data-sitekey="{{$GOOGLE_ReCAPTCHA_KEY}}"></div>
|
||||
@if ($errors->has('g-recaptcha-response'))
|
||||
<div class="cf-turnstile" data-sitekey="{{ $turnstile_site_key }}" data-action="contact" data-language="{{ app()->getLocale() }}"></div>
|
||||
@if ($errors->has('cf-turnstile-response'))
|
||||
<div class="alert alert-danger error"><!-- DANGER -->
|
||||
{{ $errors->first('g-recaptcha-response') }}
|
||||
{{ $errors->first('cf-turnstile-response') }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@
|
|||
}
|
||||
</style>
|
||||
|
||||
<script src='https://www.google.com/recaptcha/api.js?explicit&hl={{ \App::getLocale() }}'></script>
|
||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
||||
|
||||
<section>
|
||||
<div class="container">
|
||||
|
|
@ -87,6 +87,11 @@
|
|||
<div class="box-static box-transparent box-bordered padding-20">
|
||||
|
||||
{!! Form::open(['action' => '/registrierung']) !!}
|
||||
{{ html()->hidden(\App\Services\FormSpamGuard::TIMESTAMP_FIELD, $form_token) }}
|
||||
<div style="position: absolute; left: -10000px; top: auto; width: 1px; height: 1px; overflow: hidden;" aria-hidden="true">
|
||||
<label for="{{ \App\Services\FormSpamGuard::HONEYPOT_FIELD }}">{{ __('form_protection.honeypot_label') }}</label>
|
||||
{{ html()->text(\App\Services\FormSpamGuard::HONEYPOT_FIELD)->attributes(['tabindex' => '-1', 'autocomplete' => 'off']) }}
|
||||
</div>
|
||||
@if(isset($from_member_id))
|
||||
{!! Form::hidden('from_member_id', $from_member_id) !!}
|
||||
@endif
|
||||
|
|
@ -189,12 +194,12 @@
|
|||
|
||||
<div class="row contact-row">
|
||||
<div class="col-md-12">
|
||||
<div class="g-recaptcha" data-sitekey="{{$GOOGLE_ReCAPTCHA_KEY}}"></div>
|
||||
@if ($errors->has('g-recaptcha-response'))
|
||||
<div class="cf-turnstile" data-sitekey="{{ $turnstile_site_key }}" data-action="register" data-language="{{ app()->getLocale() }}"></div>
|
||||
@if ($errors->has('cf-turnstile-response'))
|
||||
<div class="alert alert-danger error"><!-- DANGER -->
|
||||
{{ $errors->first('g-recaptcha-response') }}
|
||||
{{ $errors->first('cf-turnstile-response') }}
|
||||
</div>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@
|
|||
/* ROUTING FOR CRM my.mivita / CMS */
|
||||
Route::domain(config('app.pre_url_crm').config('app.domain').config('app.tld_care'))->group(function () {
|
||||
|
||||
Auth::routes();
|
||||
// Selbstregistrierung im CRM ist aus (Formular leitet zum Login) — POST /register darf es daher auch nicht geben
|
||||
Auth::routes(['register' => false]);
|
||||
Route::get('/logout', function () {
|
||||
Auth::logout();
|
||||
|
||||
|
|
@ -133,6 +134,9 @@ Route::domain(config('app.pre_url_crm').config('app.domain').config('app.tld_car
|
|||
Route::get('/user/backoffice/statistics/export', 'User\BackofficeStatisticsController@export')->name('user_backoffice_statistics_export');
|
||||
Route::get('/user/backoffice/statistics/export-overview', 'User\BackofficeStatisticsController@overviewExport')->name('user_backoffice_statistics_overview_export');
|
||||
|
||||
// user recognitions (Anerkennungen des Monats, firmenweit)
|
||||
Route::get('/user/recognitions', 'User\RecognitionController@index')->name('user_recognitions');
|
||||
|
||||
// user team
|
||||
Route::get('/user/team/add/member', 'User\TeamController@addMember')->name('user_team_add_member');
|
||||
|
||||
|
|
@ -343,6 +347,15 @@ Route::domain(config('app.pre_url_crm').config('app.domain').config('app.tld_car
|
|||
Route::post('/admin/customer/edit/{id}', 'CustomerController@store')->name('admin_customer_edit');
|
||||
Route::get('/admin/customer/datatable', 'CustomerController@getCustomers')->name('admin_customer_datatable');
|
||||
|
||||
// Kundenhoheit: offene Prüfungen, Kunden bei mehreren Beratern, E-Mail-Änderungen
|
||||
Route::get('/admin/customer-priority/datatable/reviews', 'Admin\CustomerPriorityController@reviewsDatatable')->name('admin_customer_priority_reviews_datatable');
|
||||
Route::get('/admin/customer-priority/datatable/conflicts', 'Admin\CustomerPriorityController@conflictsDatatable')->name('admin_customer_priority_conflicts_datatable');
|
||||
Route::get('/admin/customer-priority/datatable/email-changes', 'Admin\CustomerPriorityController@emailChangesDatatable')->name('admin_customer_priority_email_changes_datatable');
|
||||
Route::post('/admin/customer-priority/keep/{id}', 'Admin\CustomerPriorityController@keepAssignment')->name('admin_customer_priority_keep');
|
||||
Route::post('/admin/customer-priority/email-change/{id}/resolve', 'Admin\CustomerPriorityController@resolveEmailChange')->name('admin_customer_priority_email_resolve');
|
||||
Route::post('/admin/customer-priority/email-change/{id}/discard', 'Admin\CustomerPriorityController@discardEmailChange')->name('admin_customer_priority_email_discard');
|
||||
Route::get('/admin/customer-priority/{tab?}', 'Admin\CustomerPriorityController@index')->name('admin_customer_priority');
|
||||
|
||||
// newsletter lists
|
||||
Route::get('/admin/newsletter-lists', 'Admin\NewsletterListController@index')->name('admin_newsletter_lists');
|
||||
Route::get('/admin/newsletter-lists/datatable', 'Admin\NewsletterListController@datatable')->name('admin_newsletter_lists_datatable');
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@
|
|||
|
|
||||
*/
|
||||
|
||||
|
||||
Route::get('/datenschutz', 'HomeController@legalDataProtected')->name('datenschutz');
|
||||
Route::get('/impressum', 'HomeController@legalImprint')->name('impressum');
|
||||
Route::get('/datenschutz', 'HomeController@legalDataProtected')->name('data_protected');
|
||||
|
|
@ -15,7 +14,7 @@ Route::get('/impressum', 'HomeController@legalImprint')->name('imprint');
|
|||
Route::get('/agb', 'HomeController@legalAGB')->name('agb');
|
||||
|
||||
Route::get('/kontakt', 'Web\ContactController@create')->name('contact_create');
|
||||
Route::post('/kontakt', 'Web\ContactController@store')->name('contact_store');
|
||||
Route::post('/kontakt', 'Web\ContactController@store')->middleware('throttle:contact-form')->name('contact_store');
|
||||
|
||||
// Public DHL Tracking
|
||||
Route::get('/tracking', 'DhlShipmentController@track')->name('public.tracking');
|
||||
|
|
@ -23,9 +22,8 @@ Route::post('/tracking', 'DhlShipmentController@track')->name('public.tracking.c
|
|||
|
||||
Route::get('/registrierung', 'Web\RegisterController@index')->name('main.register_user');
|
||||
Route::get('/reg/{member_id?}', 'Web\RegisterController@member')->name('main.register_user_member');
|
||||
Route::post('/registrierung', 'Web\RegisterController@register')->name('main.register_user_post');
|
||||
Route::post('/registrierung', 'Web\RegisterController@register')->middleware('throttle:register-form')->name('main.register_user_post');
|
||||
Route::get('/registrierung/finish', 'Web\RegisterController@finish')->name('main.register_user_finish');
|
||||
|
||||
Route::get('/', 'Web\SiteController@index')->name('/');
|
||||
Route::get('/{site}/{subsite?}/{product_slug?}', 'Web\SiteController@site')->name('base.site');
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ use App\Http\Controllers\Portal\CustomerController;
|
|||
use App\Http\Controllers\Portal\InController;
|
||||
use App\Http\Controllers\Portal\OrderController;
|
||||
use App\Http\Controllers\Web\AboCardUpdateController;
|
||||
use App\Http\Controllers\Web\CustomerEmailChangeController;
|
||||
|
||||
/* ROUTING FOR CRM my.mivita / CMS */
|
||||
|
||||
|
|
@ -41,6 +42,11 @@ Route::domain(config('app.pre_url_portal').config('app.domain').config('app.tld_
|
|||
Route::post('abo/karte/{token}/stripe', [AboCardUpdateController::class, 'startStripe'])->middleware('throttle:10,1')->name('abo_card_update_stripe');
|
||||
Route::get('abo/karte/{token}/return/{status}', [AboCardUpdateController::class, 'return'])->middleware('throttle:10,1')->name('abo_card_update_return');
|
||||
|
||||
// Login-freie Bestätigung einer neuen Kunden-E-Mail (vom Berater eingetragen). Erst nach
|
||||
// Bestätigung wird die Adresse für Login und Abo verwendet.
|
||||
Route::get('kunde/email-bestaetigen/{token}', [CustomerEmailChangeController::class, 'show'])->middleware('throttle:10,1')->name('customer_email_change_show');
|
||||
Route::post('kunde/email-bestaetigen/{token}', [CustomerEmailChangeController::class, 'store'])->middleware('throttle:10,1')->name('customer_email_change_store');
|
||||
|
||||
// Geschützte Kunden-Routen
|
||||
Route::middleware('auth:customers')->group(function () {
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ Route::get('/home', 'Web\SiteController@index')->name('shop.home');
|
|||
|
||||
Route::get('/registrierung', 'Web\RegisterController@index')->name('shop.register_user');
|
||||
Route::get('/reg/{member_id?}', 'Web\RegisterController@member')->name('shop.register_user_member');
|
||||
Route::post('/registrierung', 'Web\RegisterController@register')->name('shop.register_user_post');
|
||||
Route::post('/registrierung', 'Web\RegisterController@register')->middleware('throttle:register-form')->name('shop.register_user_post');
|
||||
Route::get('/registrierung/finish', 'Web\RegisterController@finish')->name('shop.register_user_finish');
|
||||
|
||||
Route::get('/', 'Web\SiteController@index')->name('shop.home');
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ Route::get('/home', 'Web\SiteController@index')->name('user-shop.home.alt');
|
|||
|
||||
Route::get('/registrierung', 'Web\RegisterController@index')->name('user-shop.register_user');
|
||||
Route::get('/reg/{member_id?}', 'Web\RegisterController@member')->name('user-shop.register_user_member');
|
||||
Route::post('/registrierung', 'Web\RegisterController@register')->name('user-shop.register_user_post');
|
||||
Route::post('/registrierung', 'Web\RegisterController@register')->middleware('throttle:register-form')->name('user-shop.register_user_post');
|
||||
Route::get('/registrierung/finish', 'Web\RegisterController@finish')->name('user-shop.register_user_finish');
|
||||
|
||||
Route::get('/user/card/add/{id}/{quantity?}/{product_slug?}', 'Web\CardController@addToCardGet')->name('user-shop.card_add_get');
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ if (EarlyDomainParser::getCurrentDomainType() === 'checkout') {
|
|||
Route::get('/impressum', 'HomeController@legalImprint')->name('legal.imprint');
|
||||
Route::get('/agb', 'HomeController@legalAGB')->name('legal.agb');
|
||||
Route::get('/kontakt', 'Web\ContactController@create')->name('contact.create');
|
||||
Route::post('/kontakt', 'Web\ContactController@store')->name('contact.store');
|
||||
Route::post('/kontakt', 'Web\ContactController@store')->middleware('throttle:contact-form')->name('contact.store');
|
||||
Route::get('/zahlungsarten', 'HomeController@zahlungsarten')->name('zahlungsarten');
|
||||
Route::get('/versandkosten', 'HomeController@versandkosten')->name('versandkosten');
|
||||
}
|
||||
|
|
|
|||
240
tests/Feature/ContactFormSpamTest.php
Normal file
240
tests/Feature/ContactFormSpamTest.php
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Middleware\SubdomainResolver;
|
||||
use App\Mail\MailContact;
|
||||
use App\Models\UserShop;
|
||||
use App\Services\FormSpamGuard;
|
||||
use App\Services\TurnstileVerifier;
|
||||
use App\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config([
|
||||
'app.contact_mail' => 'kontakt@mivita.test',
|
||||
'services.turnstile.site_key' => 'site-key',
|
||||
'services.turnstile.secret_key' => 'secret-key',
|
||||
]);
|
||||
Mail::fake();
|
||||
$this->turnstileResponse = fn () => Http::response(['success' => true]);
|
||||
Http::fake(['challenges.cloudflare.com/*' => fn () => ($this->turnstileResponse)()]);
|
||||
});
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $overrides
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function contactPayload(array $overrides = []): array
|
||||
{
|
||||
return array_merge([
|
||||
'salutation' => 'ms',
|
||||
'first_name' => 'Anna',
|
||||
'last_name' => 'Müller',
|
||||
'email' => 'anna@example.com',
|
||||
'phone' => '+49 (0) 171 / 123-456',
|
||||
'message' => 'Ich habe eine Frage zu euren Produkten.',
|
||||
'sales_partnership' => 'NEIN',
|
||||
'accepted_data_protection' => '1',
|
||||
'cf-turnstile-response' => 'token',
|
||||
FormSpamGuard::TIMESTAMP_FIELD => encrypt(now()->timestamp - 10),
|
||||
FormSpamGuard::HONEYPOT_FIELD => '',
|
||||
], $overrides);
|
||||
}
|
||||
|
||||
it('zeigt das Formular mit Turnstile statt reCAPTCHA', function () {
|
||||
$this->get('/kontakt')
|
||||
->assertOk()
|
||||
->assertSee('cf-turnstile', false)
|
||||
->assertSee('data-sitekey="site-key"', false)
|
||||
->assertSee(FormSpamGuard::HONEYPOT_FIELD, false)
|
||||
->assertDontSee('g-recaptcha', false);
|
||||
});
|
||||
|
||||
it('schickt die Anfrage nur an mivita, nie an die eingegebene Adresse', function () {
|
||||
$this->post('/kontakt', contactPayload())
|
||||
->assertOk()
|
||||
->assertSee(__('website.thank_you_for_your_request'));
|
||||
|
||||
Mail::assertSent(MailContact::class, 1);
|
||||
Mail::assertSent(MailContact::class, function (MailContact $mail) {
|
||||
return $mail->hasTo('kontakt@mivita.test')
|
||||
&& ! $mail->hasTo('anna@example.com')
|
||||
&& ! $mail->hasBcc('anna@example.com')
|
||||
&& $mail->hasReplyTo('anna@example.com', 'Anna Müller');
|
||||
});
|
||||
|
||||
Http::assertSent(fn ($request) => $request['secret'] === 'secret-key' && $request['response'] === 'token');
|
||||
});
|
||||
|
||||
it('schickt die Anfrage aus einem Berater-Shop zusätzlich an den Berater', function () {
|
||||
$consultant = User::forceCreate([
|
||||
'email' => 'berater@example.com',
|
||||
'password' => bcrypt('secret'),
|
||||
'lang' => 'de',
|
||||
]);
|
||||
$userShop = UserShop::create([
|
||||
'user_id' => $consultant->id,
|
||||
'name' => 'Freshzone',
|
||||
'slug' => 'freshzone',
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
$this->withoutMiddleware(SubdomainResolver::class)
|
||||
->withSession(['user_shop' => $userShop])
|
||||
->post('/kontakt', contactPayload())
|
||||
->assertOk();
|
||||
|
||||
Mail::assertSent(MailContact::class, function (MailContact $mail) {
|
||||
return $mail->hasTo('kontakt@mivita.test')
|
||||
&& $mail->hasBcc('berater@example.com')
|
||||
&& ! $mail->hasTo('anna@example.com')
|
||||
&& str_contains($mail->subject, 'freshzone.mivita.care');
|
||||
});
|
||||
});
|
||||
|
||||
it('lehnt die Spam-Anfrage aus dem Beispiel ab', function () {
|
||||
$spam = 'Приветствуем! Напоминаем, что на вашем бонусном счёте находится 926USD. '
|
||||
."Воспользоваться бонусом: https://share.google/O8hzS8W7vPuroPXdL\nВсего доброго, команда сайта";
|
||||
|
||||
$this->from('/kontakt')
|
||||
->post('/kontakt', contactPayload([
|
||||
'first_name' => $spam,
|
||||
'last_name' => $spam,
|
||||
'email' => 'igor.makhataev@icloud.com',
|
||||
'phone' => '88538273161',
|
||||
'message' => $spam,
|
||||
]))
|
||||
->assertRedirect('/kontakt')
|
||||
->assertSessionHasErrors(['first_name', 'last_name']);
|
||||
|
||||
Mail::assertNothingSent();
|
||||
});
|
||||
|
||||
it('lehnt Namen mit nicht-lateinischer Schrift, Links oder Ziffern ab', function (string $name) {
|
||||
$this->post('/kontakt', contactPayload(['first_name' => $name]))
|
||||
->assertSessionHasErrors('first_name');
|
||||
|
||||
Mail::assertNothingSent();
|
||||
})->with([
|
||||
'kyrillisch' => 'Игорь',
|
||||
'link' => 'https://share.google/abc',
|
||||
'ziffern' => 'Anna123',
|
||||
'zu lang' => str_repeat('a', 51),
|
||||
]);
|
||||
|
||||
it('akzeptiert Namen mit Umlauten, Akzenten, Bindestrich und Apostroph', function (string $name) {
|
||||
$this->post('/kontakt', contactPayload(['last_name' => $name]))
|
||||
->assertSessionHasNoErrors();
|
||||
|
||||
Mail::assertSent(MailContact::class);
|
||||
})->with(['Müller-Lüdenscheidt', 'García Nuñez', "O'Connor", 'Lefèvre', 'Dr. Weiß']);
|
||||
|
||||
it('lehnt Nachrichten mit zu vielen Links ab', function () {
|
||||
$this->post('/kontakt', contactPayload([
|
||||
'message' => 'Seht mal: https://a.example https://b.example www.c.example',
|
||||
]))->assertSessionHasErrors('message');
|
||||
|
||||
Mail::assertNothingSent();
|
||||
});
|
||||
|
||||
it('lehnt ungültige Telefonnummern ab', function () {
|
||||
$this->post('/kontakt', contactPayload(['phone' => 'ruf mich an']))
|
||||
->assertSessionHasErrors('phone');
|
||||
});
|
||||
|
||||
it('lehnt die Anfrage ab, wenn Turnstile das Token nicht bestätigt', function () {
|
||||
$this->turnstileResponse = fn () => Http::response(['success' => false, 'error-codes' => ['invalid-input-response']]);
|
||||
|
||||
$this->post('/kontakt', contactPayload())
|
||||
->assertSessionHasErrors('cf-turnstile-response');
|
||||
|
||||
Mail::assertNothingSent();
|
||||
});
|
||||
|
||||
it('lehnt die Anfrage ohne Turnstile-Token ab', function () {
|
||||
$this->post('/kontakt', contactPayload(['cf-turnstile-response' => '']))
|
||||
->assertSessionHasErrors('cf-turnstile-response');
|
||||
|
||||
Http::assertNothingSent();
|
||||
Mail::assertNothingSent();
|
||||
});
|
||||
|
||||
it('verwirft Bots still und zeigt trotzdem die Erfolgsseite', function (array $overrides, string $reason) {
|
||||
Log::spy();
|
||||
Log::shouldReceive('channel')->andReturnSelf();
|
||||
|
||||
$this->post('/kontakt', contactPayload($overrides))
|
||||
->assertOk()
|
||||
->assertSee(__('website.thank_you_for_your_request'));
|
||||
|
||||
Mail::assertNothingSent();
|
||||
Log::shouldHaveReceived('channel')->with('form_spam');
|
||||
Log::shouldHaveReceived('info')->withArgs(fn ($message, $context = []) => ($context['reason'] ?? null) === $reason);
|
||||
})->with([
|
||||
'Honeypot ausgefüllt' => [fn () => [FormSpamGuard::HONEYPOT_FIELD => 'https://spam.example'], 'honeypot'],
|
||||
'zu schnell abgeschickt' => [fn () => [FormSpamGuard::TIMESTAMP_FIELD => encrypt(now()->timestamp)], 'too_fast'],
|
||||
'Zeitstempel fehlt' => [fn () => [FormSpamGuard::TIMESTAMP_FIELD => ''], 'invalid_form_token'],
|
||||
'Zeitstempel manipuliert' => [fn () => [FormSpamGuard::TIMESTAMP_FIELD => '1700000000'], 'invalid_form_token'],
|
||||
]);
|
||||
|
||||
it('begrenzt die Anzahl der Anfragen pro IP', function () {
|
||||
foreach (range(1, 5) as $i) {
|
||||
$this->post('/kontakt', contactPayload(['email' => "anna{$i}@example.com"]))->assertOk();
|
||||
}
|
||||
|
||||
$this->from('/kontakt')
|
||||
->post('/kontakt', contactPayload(['email' => 'anna6@example.com']))
|
||||
->assertRedirect('/kontakt')
|
||||
->assertSessionHasErrors(['email' => __('form_protection.too_many_requests')]);
|
||||
|
||||
Mail::assertSent(MailContact::class, 5);
|
||||
});
|
||||
|
||||
it('begrenzt die Anzahl der Anfragen pro E-Mail-Adresse', function () {
|
||||
foreach (range(1, 5) as $i) {
|
||||
$this->withServerVariables(['REMOTE_ADDR' => "10.0.0.{$i}"])
|
||||
->post('/kontakt', contactPayload())->assertOk();
|
||||
}
|
||||
|
||||
$this->withServerVariables(['REMOTE_ADDR' => '10.0.0.99'])
|
||||
->post('/kontakt', contactPayload(['email' => 'ANNA@example.com']))
|
||||
->assertSessionHasErrors(['email' => __('form_protection.too_many_requests')]);
|
||||
|
||||
Mail::assertSent(MailContact::class, 5);
|
||||
});
|
||||
|
||||
it('gibt Eingaben in der Mail escaped aus', function () {
|
||||
$mail = new MailContact([
|
||||
'salutation' => 'ms',
|
||||
'first_name' => 'Anna',
|
||||
'last_name' => 'Müller',
|
||||
'email' => 'anna@example.com',
|
||||
'phone' => null,
|
||||
'message' => '<a href="https://evil.example">Klick</a>',
|
||||
]);
|
||||
|
||||
$html = $mail->render();
|
||||
|
||||
expect($html)
|
||||
->toContain('<a href="https://evil.example">Klick</a>')
|
||||
->not->toContain('<a href="https://evil.example">');
|
||||
});
|
||||
|
||||
it('wertet Turnstile als fehlgeschlagen, wenn Cloudflare nicht erreichbar ist', function () {
|
||||
$this->turnstileResponse = fn () => throw new ConnectionException('timeout');
|
||||
|
||||
expect(app(TurnstileVerifier::class)->verify('token', '127.0.0.1'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('wertet Turnstile als fehlgeschlagen, wenn kein Secret konfiguriert ist', function () {
|
||||
config(['services.turnstile.secret_key' => null]);
|
||||
|
||||
expect(app(TurnstileVerifier::class)->verify('token'))->toBeFalse();
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue