feat: Phase 2 - page CRUD, subscriber management, user management
This commit is contained in:
3
Makefile
3
Makefile
@@ -28,9 +28,10 @@ queue:
|
|||||||
php artisan queue:work --queue=mailwizz --tries=3
|
php artisan queue:work --queue=mailwizz --tries=3
|
||||||
|
|
||||||
# Start Laravel dev server + Vite in parallel
|
# Start Laravel dev server + Vite in parallel
|
||||||
|
# PHP defaults (often post_max_size=2M) reject page forms with background (≤5MB) + logo (≤2MB).
|
||||||
dev:
|
dev:
|
||||||
npx concurrently --names "laravel,vite" --prefix-colors "green,blue" \
|
npx concurrently --names "laravel,vite" --prefix-colors "green,blue" \
|
||||||
"php artisan serve" \
|
"php -d post_max_size=64M -d upload_max_filesize=32M artisan serve" \
|
||||||
"npm run dev"
|
"npm run dev"
|
||||||
|
|
||||||
# ──────────────────────────────────────────
|
# ──────────────────────────────────────────
|
||||||
|
|||||||
@@ -5,43 +5,128 @@ declare(strict_types=1);
|
|||||||
namespace App\Http\Controllers\Admin;
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Admin\StorePreregistrationPageRequest;
|
||||||
|
use App\Http\Requests\Admin\UpdatePreregistrationPageRequest;
|
||||||
use App\Models\PreregistrationPage;
|
use App\Models\PreregistrationPage;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
class PageController extends Controller
|
class PageController extends Controller
|
||||||
{
|
{
|
||||||
public function index(): \Illuminate\View\View
|
public function __construct()
|
||||||
{
|
{
|
||||||
return view('admin.pages.index');
|
$this->authorizeResource(PreregistrationPage::class, 'page');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function create(): \Illuminate\View\View
|
public function index(Request $request): View
|
||||||
|
{
|
||||||
|
$query = PreregistrationPage::query()
|
||||||
|
->withCount('subscribers')
|
||||||
|
->orderByDesc('start_date');
|
||||||
|
|
||||||
|
if (! $request->user()?->isSuperadmin()) {
|
||||||
|
$query->where('user_id', $request->user()->id);
|
||||||
|
} else {
|
||||||
|
$query->with('user');
|
||||||
|
}
|
||||||
|
|
||||||
|
$pages = $query->get();
|
||||||
|
|
||||||
|
return view('admin.pages.index', compact('pages'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(): View
|
||||||
{
|
{
|
||||||
return view('admin.pages.create');
|
return view('admin.pages.create');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function store(Request $request): \Illuminate\Http\RedirectResponse
|
public function store(StorePreregistrationPageRequest $request): RedirectResponse
|
||||||
{
|
{
|
||||||
return redirect()->route('admin.pages.index');
|
$validated = $request->validated();
|
||||||
|
$background = $request->file('background_image');
|
||||||
|
$logo = $request->file('logo_image');
|
||||||
|
unset($validated['background_image'], $validated['logo_image']);
|
||||||
|
|
||||||
|
$validated['slug'] = (string) Str::uuid();
|
||||||
|
$validated['user_id'] = $request->user()->id;
|
||||||
|
|
||||||
|
$page = DB::transaction(function () use ($validated, $background, $logo): PreregistrationPage {
|
||||||
|
$page = PreregistrationPage::create($validated);
|
||||||
|
$paths = [];
|
||||||
|
if ($background !== null) {
|
||||||
|
$paths['background_image'] = $background->store("preregister/pages/{$page->id}", 'public');
|
||||||
|
}
|
||||||
|
if ($logo !== null) {
|
||||||
|
$paths['logo_image'] = $logo->store("preregister/pages/{$page->id}", 'public');
|
||||||
|
}
|
||||||
|
if ($paths !== []) {
|
||||||
|
$page->update($paths);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $page->fresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('admin.pages.index')
|
||||||
|
->with('status', __('Page created. Public URL: :url', ['url' => url('/r/'.$page->slug)]));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function show(PreregistrationPage $page): \Illuminate\View\View
|
public function show(PreregistrationPage $page): RedirectResponse
|
||||||
{
|
{
|
||||||
return view('admin.pages.show', compact('page'));
|
return redirect()->route('admin.pages.edit', $page);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function edit(PreregistrationPage $page): \Illuminate\View\View
|
public function edit(PreregistrationPage $page): View
|
||||||
{
|
{
|
||||||
return view('admin.pages.edit', compact('page'));
|
return view('admin.pages.edit', compact('page'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update(Request $request, PreregistrationPage $page): \Illuminate\Http\RedirectResponse
|
public function update(UpdatePreregistrationPageRequest $request, PreregistrationPage $page): RedirectResponse
|
||||||
{
|
{
|
||||||
return redirect()->route('admin.pages.index');
|
$validated = $request->validated();
|
||||||
|
$background = $request->file('background_image');
|
||||||
|
$logo = $request->file('logo_image');
|
||||||
|
unset($validated['background_image'], $validated['logo_image']);
|
||||||
|
|
||||||
|
DB::transaction(function () use ($validated, $background, $logo, $page): void {
|
||||||
|
if ($background !== null) {
|
||||||
|
if ($page->background_image !== null) {
|
||||||
|
Storage::disk('public')->delete($page->background_image);
|
||||||
|
}
|
||||||
|
$validated['background_image'] = $background->store("preregister/pages/{$page->id}", 'public');
|
||||||
|
}
|
||||||
|
if ($logo !== null) {
|
||||||
|
if ($page->logo_image !== null) {
|
||||||
|
Storage::disk('public')->delete($page->logo_image);
|
||||||
|
}
|
||||||
|
$validated['logo_image'] = $logo->store("preregister/pages/{$page->id}", 'public');
|
||||||
|
}
|
||||||
|
$page->update($validated);
|
||||||
|
});
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('admin.pages.index')
|
||||||
|
->with('status', __('Page updated.'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function destroy(PreregistrationPage $page): \Illuminate\Http\RedirectResponse
|
public function destroy(PreregistrationPage $page): RedirectResponse
|
||||||
{
|
{
|
||||||
return redirect()->route('admin.pages.index');
|
DB::transaction(function () use ($page): void {
|
||||||
|
if ($page->background_image !== null) {
|
||||||
|
Storage::disk('public')->delete($page->background_image);
|
||||||
|
}
|
||||||
|
if ($page->logo_image !== null) {
|
||||||
|
Storage::disk('public')->delete($page->logo_image);
|
||||||
|
}
|
||||||
|
$page->delete();
|
||||||
|
});
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('admin.pages.index')
|
||||||
|
->with('status', __('Page deleted.'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,34 +5,58 @@ declare(strict_types=1);
|
|||||||
namespace App\Http\Controllers\Admin;
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Admin\IndexSubscriberRequest;
|
||||||
use App\Models\PreregistrationPage;
|
use App\Models\PreregistrationPage;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||||
|
|
||||||
class SubscriberController extends Controller
|
class SubscriberController extends Controller
|
||||||
{
|
{
|
||||||
public function index(PreregistrationPage $page): \Illuminate\View\View
|
public function index(IndexSubscriberRequest $request, PreregistrationPage $page): View
|
||||||
{
|
{
|
||||||
return view('admin.subscribers.index', compact('page'));
|
$search = $request->validated('search');
|
||||||
|
$subscribers = $page->subscribers()
|
||||||
|
->search(is_string($search) ? $search : null)
|
||||||
|
->orderByDesc('created_at')
|
||||||
|
->paginate(25)
|
||||||
|
->withQueryString();
|
||||||
|
|
||||||
|
return view('admin.subscribers.index', compact('page', 'subscribers'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function export(PreregistrationPage $page): \Symfony\Component\HttpFoundation\StreamedResponse
|
public function export(IndexSubscriberRequest $request, PreregistrationPage $page): StreamedResponse
|
||||||
{
|
{
|
||||||
$this->authorize('view', $page);
|
$search = $request->validated('search');
|
||||||
|
$subscribers = $page->subscribers()
|
||||||
|
->search(is_string($search) ? $search : null)
|
||||||
|
->orderBy('created_at')
|
||||||
|
->get();
|
||||||
|
|
||||||
$subscribers = $page->subscribers()->orderBy('created_at')->get();
|
$phoneEnabled = $page->phone_enabled;
|
||||||
|
|
||||||
return response()->streamDownload(function () use ($subscribers, $page) {
|
return response()->streamDownload(function () use ($subscribers, $phoneEnabled): void {
|
||||||
$handle = fopen('php://output', 'w');
|
$handle = fopen('php://output', 'w');
|
||||||
fputcsv($handle, ['First Name', 'Last Name', 'Email', 'Phone', 'Registered At']);
|
$headers = ['First Name', 'Last Name', 'Email'];
|
||||||
foreach ($subscribers as $sub) {
|
if ($phoneEnabled) {
|
||||||
fputcsv($handle, [
|
$headers[] = 'Phone';
|
||||||
$sub->first_name,
|
|
||||||
$sub->last_name,
|
|
||||||
$sub->email,
|
|
||||||
$sub->phone,
|
|
||||||
$sub->created_at->toDateTimeString(),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
$headers = array_merge($headers, ['Registered At', 'Synced to Mailwizz', 'Synced At']);
|
||||||
|
fputcsv($handle, $headers);
|
||||||
|
|
||||||
|
foreach ($subscribers as $sub) {
|
||||||
|
$row = [$sub->first_name, $sub->last_name, $sub->email];
|
||||||
|
if ($phoneEnabled) {
|
||||||
|
$row[] = $sub->phone ?? '';
|
||||||
|
}
|
||||||
|
$row[] = $sub->created_at?->toDateTimeString() ?? '';
|
||||||
|
$row[] = $sub->synced_to_mailwizz ? 'Yes' : 'No';
|
||||||
|
$row[] = $sub->synced_at?->toDateTimeString() ?? '';
|
||||||
|
fputcsv($handle, $row);
|
||||||
|
}
|
||||||
|
|
||||||
fclose($handle);
|
fclose($handle);
|
||||||
}, "subscribers-{$page->slug}.csv");
|
}, 'subscribers-'.$page->slug.'.csv', [
|
||||||
|
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,38 +5,71 @@ declare(strict_types=1);
|
|||||||
namespace App\Http\Controllers\Admin;
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Admin\StoreUserRequest;
|
||||||
|
use App\Http\Requests\Admin\UpdateUserRequest;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
class UserController extends Controller
|
class UserController extends Controller
|
||||||
{
|
{
|
||||||
public function index(): \Illuminate\View\View
|
public function __construct()
|
||||||
{
|
{
|
||||||
return view('admin.users.index');
|
$this->authorizeResource(User::class, 'user', [
|
||||||
|
'except' => ['show'],
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function create(): \Illuminate\View\View
|
public function index(): View
|
||||||
|
{
|
||||||
|
$users = User::query()->orderBy('name')->paginate(25);
|
||||||
|
|
||||||
|
return view('admin.users.index', compact('users'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(): View
|
||||||
{
|
{
|
||||||
return view('admin.users.create');
|
return view('admin.users.create');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function store(Request $request): \Illuminate\Http\RedirectResponse
|
public function store(StoreUserRequest $request): RedirectResponse
|
||||||
{
|
{
|
||||||
return redirect()->route('admin.users.index');
|
$data = $request->validated();
|
||||||
|
$data['password'] = Hash::make($data['password']);
|
||||||
|
User::query()->create($data);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('admin.users.index')
|
||||||
|
->with('status', __('User created.'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function edit(User $user): \Illuminate\View\View
|
public function edit(User $user): View
|
||||||
{
|
{
|
||||||
return view('admin.users.edit', compact('user'));
|
return view('admin.users.edit', compact('user'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update(Request $request, User $user): \Illuminate\Http\RedirectResponse
|
public function update(UpdateUserRequest $request, User $user): RedirectResponse
|
||||||
{
|
{
|
||||||
return redirect()->route('admin.users.index');
|
$data = $request->validated();
|
||||||
|
if ($request->filled('password')) {
|
||||||
|
$data['password'] = Hash::make($data['password']);
|
||||||
|
} else {
|
||||||
|
unset($data['password']);
|
||||||
|
}
|
||||||
|
$user->update($data);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('admin.users.index')
|
||||||
|
->with('status', __('User updated.'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function destroy(User $user): \Illuminate\Http\RedirectResponse
|
public function destroy(User $user): RedirectResponse
|
||||||
{
|
{
|
||||||
return redirect()->route('admin.users.index');
|
$user->delete();
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('admin.users.index')
|
||||||
|
->with('status', __('User deleted.'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
abstract class Controller
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
|
use Illuminate\Foundation\Validation\ValidatesRequests;
|
||||||
|
use Illuminate\Routing\Controller as BaseController;
|
||||||
|
|
||||||
|
abstract class Controller extends BaseController
|
||||||
{
|
{
|
||||||
//
|
use AuthorizesRequests;
|
||||||
|
use ValidatesRequests;
|
||||||
}
|
}
|
||||||
|
|||||||
32
app/Http/Requests/Admin/IndexSubscriberRequest.php
Normal file
32
app/Http/Requests/Admin/IndexSubscriberRequest.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Admin;
|
||||||
|
|
||||||
|
use App\Models\PreregistrationPage;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class IndexSubscriberRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
$page = $this->route('page');
|
||||||
|
if (! $page instanceof PreregistrationPage) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->user()?->can('view', $page) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array<int, ValidationRule|string>>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'search' => ['nullable', 'string', 'max:255'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
32
app/Http/Requests/Admin/StorePreregistrationPageRequest.php
Normal file
32
app/Http/Requests/Admin/StorePreregistrationPageRequest.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Admin;
|
||||||
|
|
||||||
|
use App\Models\PreregistrationPage;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class StorePreregistrationPageRequest extends FormRequest
|
||||||
|
{
|
||||||
|
use ValidatesPreregistrationPageInput;
|
||||||
|
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return $this->user()?->can('create', PreregistrationPage::class) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
$this->preparePreregistrationPageFields();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array<int, ValidationRule|string>>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return $this->preregistrationPageRules();
|
||||||
|
}
|
||||||
|
}
|
||||||
32
app/Http/Requests/Admin/StoreUserRequest.php
Normal file
32
app/Http/Requests/Admin/StoreUserRequest.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Admin;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\Validation\Rules\Password;
|
||||||
|
|
||||||
|
class StoreUserRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return $this->user()?->can('create', User::class) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array<int, ValidationRule|string>>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', Rule::unique(User::class)],
|
||||||
|
'password' => ['required', 'confirmed', Password::defaults()],
|
||||||
|
'role' => ['required', Rule::in(['superadmin', 'user'])],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
36
app/Http/Requests/Admin/UpdatePreregistrationPageRequest.php
Normal file
36
app/Http/Requests/Admin/UpdatePreregistrationPageRequest.php
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Admin;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class UpdatePreregistrationPageRequest extends FormRequest
|
||||||
|
{
|
||||||
|
use ValidatesPreregistrationPageInput;
|
||||||
|
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
$page = $this->route('page');
|
||||||
|
if ($page === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->user()?->can('update', $page) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
$this->preparePreregistrationPageFields();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array<int, ValidationRule|string>>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return $this->preregistrationPageRules();
|
||||||
|
}
|
||||||
|
}
|
||||||
54
app/Http/Requests/Admin/UpdateUserRequest.php
Normal file
54
app/Http/Requests/Admin/UpdateUserRequest.php
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Admin;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\Validation\Rules\Password;
|
||||||
|
|
||||||
|
class UpdateUserRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
$user = $this->route('user');
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->user()?->can('update', $user) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
if ($this->input('password') === '' || $this->input('password') === null) {
|
||||||
|
$this->merge(['password' => null]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array<int, ValidationRule|string>>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
/** @var User $target */
|
||||||
|
$target = $this->route('user');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'email' => [
|
||||||
|
'required',
|
||||||
|
'string',
|
||||||
|
'lowercase',
|
||||||
|
'email',
|
||||||
|
'max:255',
|
||||||
|
Rule::unique(User::class)->ignore($target->id),
|
||||||
|
],
|
||||||
|
'password' => ['nullable', 'confirmed', Password::defaults()],
|
||||||
|
'role' => ['required', Rule::in(['superadmin', 'user'])],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Admin;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
|
||||||
|
trait ValidatesPreregistrationPageInput
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, array<int, ValidationRule|string>>
|
||||||
|
*/
|
||||||
|
protected function preregistrationPageRules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'title' => ['required', 'string', 'max:255'],
|
||||||
|
'heading' => ['required', 'string', 'max:255'],
|
||||||
|
'intro_text' => ['nullable', 'string'],
|
||||||
|
'thank_you_message' => ['nullable', 'string'],
|
||||||
|
'expired_message' => ['nullable', 'string'],
|
||||||
|
'ticketshop_url' => ['nullable', 'string', 'url:http,https', 'max:255'],
|
||||||
|
'start_date' => ['required', 'date'],
|
||||||
|
'end_date' => ['required', 'date', 'after:start_date'],
|
||||||
|
'phone_enabled' => ['sometimes', 'boolean'],
|
||||||
|
'is_active' => ['sometimes', 'boolean'],
|
||||||
|
'background_image' => ['nullable', 'image', 'mimes:jpeg,png,jpg,webp', 'max:5120'],
|
||||||
|
'logo_image' => ['nullable', 'file', 'mimes:jpeg,png,jpg,webp,svg', 'max:2048'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function preparePreregistrationPageFields(): void
|
||||||
|
{
|
||||||
|
$ticketshop = $this->input('ticketshop_url');
|
||||||
|
$ticketshopNormalized = null;
|
||||||
|
if (is_string($ticketshop) && trim($ticketshop) !== '') {
|
||||||
|
$ticketshopNormalized = trim($ticketshop);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->merge([
|
||||||
|
'phone_enabled' => $this->boolean('phone_enabled'),
|
||||||
|
'is_active' => $this->boolean('is_active'),
|
||||||
|
'ticketshop_url' => $ticketshopNormalized,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -74,6 +74,7 @@ class PreregistrationPage extends Model
|
|||||||
public function isActive(): bool
|
public function isActive(): bool
|
||||||
{
|
{
|
||||||
$now = Carbon::now();
|
$now = Carbon::now();
|
||||||
|
|
||||||
return $now->gte($this->start_date) && $now->lte($this->end_date);
|
return $now->gte($this->start_date) && $now->lte($this->end_date);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,4 +87,20 @@ class PreregistrationPage extends Model
|
|||||||
{
|
{
|
||||||
return $query->where('is_active', true);
|
return $query->where('is_active', true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lifecycle label for admin tables: before registration opens, open window, or after end.
|
||||||
|
*/
|
||||||
|
public function statusKey(): string
|
||||||
|
{
|
||||||
|
if ($this->isBeforeStart()) {
|
||||||
|
return 'before_start';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->isExpired()) {
|
||||||
|
return 'expired';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'active';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
@@ -34,4 +35,19 @@ class Subscriber extends Model
|
|||||||
{
|
{
|
||||||
return $this->belongsTo(PreregistrationPage::class);
|
return $this->belongsTo(PreregistrationPage::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function scopeSearch(Builder $query, ?string $term): Builder
|
||||||
|
{
|
||||||
|
if ($term === null || $term === '') {
|
||||||
|
return $query;
|
||||||
|
}
|
||||||
|
|
||||||
|
$like = '%'.$term.'%';
|
||||||
|
|
||||||
|
return $query->where(function (Builder $q) use ($like): void {
|
||||||
|
$q->where('first_name', 'like', $like)
|
||||||
|
->orWhere('last_name', 'like', $like)
|
||||||
|
->orWhere('email', 'like', $like);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
34
app/Policies/UserPolicy.php
Normal file
34
app/Policies/UserPolicy.php
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Policies;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
class UserPolicy
|
||||||
|
{
|
||||||
|
public function viewAny(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->isSuperadmin();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->isSuperadmin();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(User $user, User $target): bool
|
||||||
|
{
|
||||||
|
return $user->isSuperadmin();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(User $user, User $target): bool
|
||||||
|
{
|
||||||
|
if (! $user->isSuperadmin()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user->id !== $target->id;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use Illuminate\Pagination\Paginator;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
class AppServiceProvider extends ServiceProvider
|
class AppServiceProvider extends ServiceProvider
|
||||||
@@ -19,6 +22,6 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
*/
|
*/
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
//
|
Paginator::useTailwind();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Middleware\CheckRole;
|
||||||
use Illuminate\Foundation\Application;
|
use Illuminate\Foundation\Application;
|
||||||
use Illuminate\Foundation\Configuration\Exceptions;
|
use Illuminate\Foundation\Configuration\Exceptions;
|
||||||
use Illuminate\Foundation\Configuration\Middleware;
|
use Illuminate\Foundation\Configuration\Middleware;
|
||||||
|
use Illuminate\Http\Exceptions\PostTooLargeException;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
return Application::configure(basePath: dirname(__DIR__))
|
return Application::configure(basePath: dirname(__DIR__))
|
||||||
->withRouting(
|
->withRouting(
|
||||||
@@ -12,9 +15,33 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
)
|
)
|
||||||
->withMiddleware(function (Middleware $middleware): void {
|
->withMiddleware(function (Middleware $middleware): void {
|
||||||
$middleware->alias([
|
$middleware->alias([
|
||||||
'role' => \App\Http\Middleware\CheckRole::class,
|
'role' => CheckRole::class,
|
||||||
]);
|
]);
|
||||||
})
|
})
|
||||||
->withExceptions(function (Exceptions $exceptions): void {
|
->withExceptions(function (Exceptions $exceptions): void {
|
||||||
//
|
$exceptions->renderable(function (PostTooLargeException $e, Request $request) {
|
||||||
|
$contentLength = (int) $request->server('CONTENT_LENGTH', 0);
|
||||||
|
$contentLengthMb = $contentLength > 0 ? $contentLength / 1048576 : 0;
|
||||||
|
$postMaxSize = ini_get('post_max_size') ?: 'unknown';
|
||||||
|
$uploadMaxSize = ini_get('upload_max_filesize') ?: 'unknown';
|
||||||
|
$backUrl = url()->previous() ?: url('/');
|
||||||
|
|
||||||
|
if ($request->expectsJson()) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => __('The request body is too large. Increase PHP post_max_size and upload_max_filesize (e.g. `composer run dev` or php.ini).'),
|
||||||
|
'content_length_bytes' => $contentLength,
|
||||||
|
'post_max_size' => $postMaxSize,
|
||||||
|
'upload_max_filesize' => $uploadMaxSize,
|
||||||
|
], 413);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidatePostSize runs before the web middleware group (no session yet), so a flash on redirect is often lost.
|
||||||
|
return response()
|
||||||
|
->view('errors.post-too-large', [
|
||||||
|
'contentLengthMb' => $contentLengthMb,
|
||||||
|
'postMaxSize' => $postMaxSize,
|
||||||
|
'uploadMaxSize' => $uploadMaxSize,
|
||||||
|
'backUrl' => $backUrl,
|
||||||
|
], 413);
|
||||||
|
});
|
||||||
})->create();
|
})->create();
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
],
|
],
|
||||||
"dev": [
|
"dev": [
|
||||||
"Composer\\Config::disableProcessTimeout",
|
"Composer\\Config::disableProcessTimeout",
|
||||||
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
|
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php -d post_max_size=64M -d upload_max_filesize=32M artisan serve\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
|
||||||
],
|
],
|
||||||
"test": [
|
"test": [
|
||||||
"@php artisan config:clear --ansi",
|
"@php artisan config:clear --ansi",
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class DatabaseSeeder extends Seeder
|
|||||||
*/
|
*/
|
||||||
public function run(): void
|
public function run(): void
|
||||||
{
|
{
|
||||||
// User::factory(10)->create();
|
$this->call(SuperadminSeeder::class);
|
||||||
|
|
||||||
User::factory()->create([
|
User::factory()->create([
|
||||||
'name' => 'Test User',
|
'name' => 'Test User',
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
[x-cloak] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|||||||
121
resources/views/admin/pages/_form.blade.php
Normal file
121
resources/views/admin/pages/_form.blade.php
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
@php
|
||||||
|
/** @var \App\Models\PreregistrationPage|null $page */
|
||||||
|
$page = $page ?? null;
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="grid max-w-3xl gap-6">
|
||||||
|
<div>
|
||||||
|
<label for="title" class="block text-sm font-medium text-slate-700">{{ __('Title') }}</label>
|
||||||
|
<input type="text" name="title" id="title" value="{{ old('title', $page?->title) }}" required maxlength="255"
|
||||||
|
class="mt-1 block w-full rounded-lg border-slate-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" />
|
||||||
|
@error('title')
|
||||||
|
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="heading" class="block text-sm font-medium text-slate-700">{{ __('Heading') }}</label>
|
||||||
|
<input type="text" name="heading" id="heading" value="{{ old('heading', $page?->heading) }}" required maxlength="255"
|
||||||
|
class="mt-1 block w-full rounded-lg border-slate-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" />
|
||||||
|
@error('heading')
|
||||||
|
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="intro_text" class="block text-sm font-medium text-slate-700">{{ __('Intro text') }}</label>
|
||||||
|
<textarea name="intro_text" id="intro_text" rows="4"
|
||||||
|
class="mt-1 block w-full rounded-lg border-slate-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">{{ old('intro_text', $page?->intro_text) }}</textarea>
|
||||||
|
@error('intro_text')
|
||||||
|
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="thank_you_message" class="block text-sm font-medium text-slate-700">{{ __('Thank you message') }}</label>
|
||||||
|
<textarea name="thank_you_message" id="thank_you_message" rows="3"
|
||||||
|
class="mt-1 block w-full rounded-lg border-slate-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">{{ old('thank_you_message', $page?->thank_you_message) }}</textarea>
|
||||||
|
@error('thank_you_message')
|
||||||
|
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="expired_message" class="block text-sm font-medium text-slate-700">{{ __('Expired message') }}</label>
|
||||||
|
<textarea name="expired_message" id="expired_message" rows="3"
|
||||||
|
class="mt-1 block w-full rounded-lg border-slate-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">{{ old('expired_message', $page?->expired_message) }}</textarea>
|
||||||
|
@error('expired_message')
|
||||||
|
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="ticketshop_url" class="block text-sm font-medium text-slate-700">{{ __('Ticket shop URL') }}</label>
|
||||||
|
<input type="text" name="ticketshop_url" id="ticketshop_url" inputmode="url" autocomplete="url"
|
||||||
|
value="{{ old('ticketshop_url', $page?->ticketshop_url) }}"
|
||||||
|
class="mt-1 block w-full rounded-lg border-slate-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" placeholder="https://…" />
|
||||||
|
@error('ticketshop_url')
|
||||||
|
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-6 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label for="start_date" class="block text-sm font-medium text-slate-700">{{ __('Start date') }}</label>
|
||||||
|
<input type="datetime-local" name="start_date" id="start_date" required
|
||||||
|
value="{{ old('start_date', $page?->start_date?->format('Y-m-d\TH:i')) }}"
|
||||||
|
class="mt-1 block w-full rounded-lg border-slate-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" />
|
||||||
|
@error('start_date')
|
||||||
|
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="end_date" class="block text-sm font-medium text-slate-700">{{ __('End date') }}</label>
|
||||||
|
<input type="datetime-local" name="end_date" id="end_date" required
|
||||||
|
value="{{ old('end_date', $page?->end_date?->format('Y-m-d\TH:i')) }}"
|
||||||
|
class="mt-1 block w-full rounded-lg border-slate-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" />
|
||||||
|
@error('end_date')
|
||||||
|
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-6">
|
||||||
|
<label class="inline-flex items-center gap-2">
|
||||||
|
<input type="checkbox" name="phone_enabled" value="1" class="rounded border-slate-300 text-indigo-600 focus:ring-indigo-500"
|
||||||
|
@checked(old('phone_enabled', $page?->phone_enabled ?? false)) />
|
||||||
|
<span class="text-sm font-medium text-slate-700">{{ __('Phone enabled') }}</span>
|
||||||
|
</label>
|
||||||
|
<label class="inline-flex items-center gap-2">
|
||||||
|
<input type="checkbox" name="is_active" value="1" class="rounded border-slate-300 text-indigo-600 focus:ring-indigo-500"
|
||||||
|
@checked(old('is_active', $page?->is_active ?? true)) />
|
||||||
|
<span class="text-sm font-medium text-slate-700">{{ __('Active') }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="background_image" class="block text-sm font-medium text-slate-700">{{ __('Background image') }}</label>
|
||||||
|
<p class="mt-0.5 text-xs text-slate-500">{{ __('JPG, PNG or WebP. Max 5 MB.') }}</p>
|
||||||
|
<input type="file" name="background_image" id="background_image" accept="image/jpeg,image/png,image/webp"
|
||||||
|
class="mt-1 block w-full text-sm text-slate-600 file:mr-4 file:rounded-lg file:border-0 file:bg-slate-100 file:px-4 file:py-2 file:text-sm file:font-semibold file:text-slate-700 hover:file:bg-slate-200" />
|
||||||
|
@error('background_image')
|
||||||
|
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
@if ($page?->background_image)
|
||||||
|
<p class="mt-2 text-xs text-slate-600">{{ __('Current file:') }} <a href="/storage/{{ $page->background_image }}" class="text-indigo-600 hover:underline" target="_blank" rel="noopener">{{ __('View') }}</a></p>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="logo_image" class="block text-sm font-medium text-slate-700">{{ __('Logo image') }}</label>
|
||||||
|
<p class="mt-0.5 text-xs text-slate-500">{{ __('JPG, PNG, WebP or SVG. Max 2 MB.') }}</p>
|
||||||
|
<input type="file" name="logo_image" id="logo_image" accept="image/jpeg,image/png,image/webp,image/svg+xml,.svg"
|
||||||
|
class="mt-1 block w-full text-sm text-slate-600 file:mr-4 file:rounded-lg file:border-0 file:bg-slate-100 file:px-4 file:py-2 file:text-sm file:font-semibold file:text-slate-700 hover:file:bg-slate-200" />
|
||||||
|
@error('logo_image')
|
||||||
|
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
@if ($page?->logo_image)
|
||||||
|
<p class="mt-2 text-xs text-slate-600">{{ __('Current file:') }} <a href="/storage/{{ $page->logo_image }}" class="text-indigo-600 hover:underline" target="_blank" rel="noopener">{{ __('View') }}</a></p>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -5,8 +5,32 @@
|
|||||||
@section('mobile_title', __('New page'))
|
@section('mobile_title', __('New page'))
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
<div class="mx-auto max-w-6xl">
|
<div class="mx-auto max-w-3xl">
|
||||||
<h1 class="text-2xl font-semibold text-slate-900">{{ __('Create page') }}</h1>
|
<div class="mb-8">
|
||||||
<p class="mt-2 text-sm text-slate-600">{{ __('Form will be added in Step 8.') }}</p>
|
<a href="{{ route('admin.pages.index') }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-500">← {{ __('Back to pages') }}</a>
|
||||||
|
<h1 class="mt-4 text-2xl font-semibold text-slate-900">{{ __('Create pre-registration page') }}</h1>
|
||||||
|
<p class="mt-1 text-sm text-slate-600">{{ __('After saving, use the pages list to copy the public URL.') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form action="{{ route('admin.pages.store') }}" method="post" enctype="multipart/form-data" class="rounded-xl border border-slate-200 bg-white p-6 shadow-sm sm:p-8" novalidate>
|
||||||
|
@csrf
|
||||||
|
@if ($errors->any())
|
||||||
|
<div class="mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800" role="alert">
|
||||||
|
<p class="font-medium">{{ __('Please fix the following:') }}</p>
|
||||||
|
<ul class="mt-2 list-inside list-disc space-y-1">
|
||||||
|
@foreach ($errors->all() as $message)
|
||||||
|
<li>{{ $message }}</li>
|
||||||
|
@endforeach
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@include('admin.pages._form', ['page' => null])
|
||||||
|
<div class="mt-8 flex gap-3">
|
||||||
|
<button type="submit" class="rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500">
|
||||||
|
{{ __('Create page') }}
|
||||||
|
</button>
|
||||||
|
<a href="{{ route('admin.pages.index') }}" class="rounded-lg border border-slate-300 bg-white px-4 py-2.5 text-sm font-semibold text-slate-700 hover:bg-slate-50">{{ __('Cancel') }}</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
@@ -2,11 +2,38 @@
|
|||||||
|
|
||||||
@section('title', __('Edit') . ' — ' . $page->title)
|
@section('title', __('Edit') . ' — ' . $page->title)
|
||||||
|
|
||||||
@section('mobile_title', __('Edit'))
|
@section('mobile_title', __('Edit page'))
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
<div class="mx-auto max-w-6xl">
|
<div class="mx-auto max-w-3xl">
|
||||||
<h1 class="text-2xl font-semibold text-slate-900">{{ __('Edit page') }}</h1>
|
<div class="mb-8">
|
||||||
<p class="mt-2 text-sm text-slate-600">{{ __('Form will be added in Step 8.') }}</p>
|
<a href="{{ route('admin.pages.index') }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-500">← {{ __('Back to pages') }}</a>
|
||||||
|
<h1 class="mt-4 text-2xl font-semibold text-slate-900">{{ __('Edit page') }}</h1>
|
||||||
|
<p class="mt-2 rounded-lg bg-slate-50 px-3 py-2 font-mono text-xs text-slate-700">
|
||||||
|
{{ __('Public URL') }}: <a href="{{ route('public.page', ['slug' => $page->slug]) }}" class="text-indigo-600 hover:underline" target="_blank" rel="noopener">{{ url('/r/'.$page->slug) }}</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form action="{{ route('admin.pages.update', $page) }}" method="post" enctype="multipart/form-data" class="rounded-xl border border-slate-200 bg-white p-6 shadow-sm sm:p-8" novalidate>
|
||||||
|
@csrf
|
||||||
|
@method('PUT')
|
||||||
|
@if ($errors->any())
|
||||||
|
<div class="mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800" role="alert">
|
||||||
|
<p class="font-medium">{{ __('Please fix the following:') }}</p>
|
||||||
|
<ul class="mt-2 list-inside list-disc space-y-1">
|
||||||
|
@foreach ($errors->all() as $message)
|
||||||
|
<li>{{ $message }}</li>
|
||||||
|
@endforeach
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@include('admin.pages._form', ['page' => $page])
|
||||||
|
<div class="mt-8 flex gap-3">
|
||||||
|
<button type="submit" class="rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500">
|
||||||
|
{{ __('Save changes') }}
|
||||||
|
</button>
|
||||||
|
<a href="{{ route('admin.pages.index') }}" class="rounded-lg border border-slate-300 bg-white px-4 py-2.5 text-sm font-semibold text-slate-700 hover:bg-slate-50">{{ __('Cancel') }}</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
@@ -5,8 +5,106 @@
|
|||||||
@section('mobile_title', __('Pages'))
|
@section('mobile_title', __('Pages'))
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
<div class="mx-auto max-w-6xl">
|
<div class="mx-auto max-w-7xl">
|
||||||
<h1 class="text-2xl font-semibold text-slate-900">{{ __('Pre-registration pages') }}</h1>
|
<div class="mb-8 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<p class="mt-2 text-sm text-slate-600">{{ __('Page list and CRUD will be added in the next step.') }}</p>
|
<div>
|
||||||
|
<h1 class="text-2xl font-semibold text-slate-900">{{ __('Pre-registration pages') }}</h1>
|
||||||
|
<p class="mt-1 text-sm text-slate-600">{{ __('Manage landing pages and subscriber lists.') }}</p>
|
||||||
|
</div>
|
||||||
|
@can('create', \App\Models\PreregistrationPage::class)
|
||||||
|
<a href="{{ route('admin.pages.create') }}"
|
||||||
|
class="inline-flex items-center justify-center rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500">
|
||||||
|
{{ __('New page') }}
|
||||||
|
</a>
|
||||||
|
@endcan
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-slate-200 text-left text-sm">
|
||||||
|
<thead class="bg-slate-50">
|
||||||
|
<tr>
|
||||||
|
@if (auth()->user()->isSuperadmin())
|
||||||
|
<th scope="col" class="px-4 py-3 font-semibold text-slate-700">{{ __('Owner') }}</th>
|
||||||
|
@endif
|
||||||
|
<th scope="col" class="px-4 py-3 font-semibold text-slate-700">{{ __('Title') }}</th>
|
||||||
|
<th scope="col" class="px-4 py-3 font-semibold text-slate-700">{{ __('Status') }}</th>
|
||||||
|
<th scope="col" class="px-4 py-3 font-semibold text-slate-700">{{ __('Start') }}</th>
|
||||||
|
<th scope="col" class="px-4 py-3 font-semibold text-slate-700">{{ __('End') }}</th>
|
||||||
|
<th scope="col" class="px-4 py-3 font-semibold text-slate-700">{{ __('Subscribers') }}</th>
|
||||||
|
<th scope="col" class="px-4 py-3 text-right font-semibold text-slate-700">{{ __('Actions') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-100">
|
||||||
|
@forelse ($pages as $page)
|
||||||
|
@php
|
||||||
|
$publicUrl = route('public.page', ['slug' => $page->slug]);
|
||||||
|
$key = $page->statusKey();
|
||||||
|
$statusClasses = match ($key) {
|
||||||
|
'before_start' => 'bg-slate-100 text-slate-800',
|
||||||
|
'active' => 'bg-emerald-100 text-emerald-800',
|
||||||
|
'expired' => 'bg-rose-100 text-rose-800',
|
||||||
|
default => 'bg-slate-100 text-slate-800',
|
||||||
|
};
|
||||||
|
$statusLabel = match ($key) {
|
||||||
|
'before_start' => __('Before start'),
|
||||||
|
'active' => __('Active'),
|
||||||
|
'expired' => __('Expired'),
|
||||||
|
default => $key,
|
||||||
|
};
|
||||||
|
@endphp
|
||||||
|
<tr class="hover:bg-slate-50/80">
|
||||||
|
@if (auth()->user()->isSuperadmin())
|
||||||
|
<td class="whitespace-nowrap px-4 py-3 text-slate-600">{{ $page->user?->email ?? '—' }}</td>
|
||||||
|
@endif
|
||||||
|
<td class="px-4 py-3 font-medium text-slate-900">{{ $page->title }}</td>
|
||||||
|
<td class="whitespace-nowrap px-4 py-3">
|
||||||
|
<span class="inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium {{ $statusClasses }}">{{ $statusLabel }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="whitespace-nowrap px-4 py-3 text-slate-600">{{ $page->start_date->timezone(config('app.timezone'))->format('Y-m-d H:i') }}</td>
|
||||||
|
<td class="whitespace-nowrap px-4 py-3 text-slate-600">{{ $page->end_date->timezone(config('app.timezone'))->format('Y-m-d H:i') }}</td>
|
||||||
|
<td class="whitespace-nowrap px-4 py-3 tabular-nums text-slate-600">{{ number_format($page->subscribers_count) }}</td>
|
||||||
|
<td class="whitespace-nowrap px-4 py-3 text-right">
|
||||||
|
<div class="flex flex-wrap items-center justify-end gap-2">
|
||||||
|
@can('update', $page)
|
||||||
|
<a href="{{ route('admin.pages.edit', $page) }}" class="text-indigo-600 hover:text-indigo-500">{{ __('Edit') }}</a>
|
||||||
|
@endcan
|
||||||
|
@can('view', $page)
|
||||||
|
<a href="{{ route('admin.pages.subscribers.index', $page) }}" class="text-indigo-600 hover:text-indigo-500">{{ __('Subscribers') }}</a>
|
||||||
|
@endcan
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
x-data="{ copied: false }"
|
||||||
|
x-on:click="navigator.clipboard.writeText({{ json_encode($publicUrl) }}); copied = true; setTimeout(() => copied = false, 2000)"
|
||||||
|
class="text-sm text-slate-600 hover:text-slate-900"
|
||||||
|
>
|
||||||
|
<span x-show="!copied">{{ __('Copy URL') }}</span>
|
||||||
|
<span x-show="copied" x-cloak class="text-emerald-600">{{ __('Copied!') }}</span>
|
||||||
|
</button>
|
||||||
|
@can('delete', $page)
|
||||||
|
<form action="{{ route('admin.pages.destroy', $page) }}" method="post" class="inline"
|
||||||
|
onsubmit="return confirm(@js(__('Delete this page and all subscribers? This cannot be undone.')));">
|
||||||
|
@csrf
|
||||||
|
@method('DELETE')
|
||||||
|
<button type="submit" class="text-red-600 hover:text-red-500">{{ __('Delete') }}</button>
|
||||||
|
</form>
|
||||||
|
@endcan
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr>
|
||||||
|
<td colspan="{{ auth()->user()->isSuperadmin() ? 7 : 6 }}" class="px-4 py-12 text-center text-slate-500">
|
||||||
|
{{ __('No pages yet.') }}
|
||||||
|
@can('create', \App\Models\PreregistrationPage::class)
|
||||||
|
<a href="{{ route('admin.pages.create') }}" class="font-medium text-indigo-600 hover:text-indigo-500">{{ __('Create one') }}</a>
|
||||||
|
@endcan
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
@extends('layouts.admin')
|
|
||||||
|
|
||||||
@section('title', $page->title)
|
|
||||||
|
|
||||||
@section('mobile_title', $page->title)
|
|
||||||
|
|
||||||
@section('content')
|
|
||||||
<div class="mx-auto max-w-6xl">
|
|
||||||
<h1 class="text-2xl font-semibold text-slate-900">{{ $page->title }}</h1>
|
|
||||||
<p class="mt-2 text-sm text-slate-600">{{ __('Detail view placeholder.') }}</p>
|
|
||||||
</div>
|
|
||||||
@endsection
|
|
||||||
87
resources/views/admin/subscribers/index.blade.php
Normal file
87
resources/views/admin/subscribers/index.blade.php
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
@extends('layouts.admin')
|
||||||
|
|
||||||
|
@section('title', __('Subscribers') . ' — ' . $page->title)
|
||||||
|
|
||||||
|
@section('mobile_title', __('Subscribers'))
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<div class="mx-auto max-w-7xl">
|
||||||
|
<div class="mb-8">
|
||||||
|
<a href="{{ route('admin.pages.index') }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-500">← {{ __('Back to pages') }}</a>
|
||||||
|
<h1 class="mt-4 text-2xl font-semibold text-slate-900">{{ __('Subscribers') }}</h1>
|
||||||
|
<p class="mt-1 text-sm text-slate-600">{{ $page->title }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||||
|
<form method="get" action="{{ route('admin.pages.subscribers.index', $page) }}" class="flex flex-1 flex-col gap-2 sm:max-w-md">
|
||||||
|
<label for="search" class="text-sm font-medium text-slate-700">{{ __('Search by name or email') }}</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input type="search" name="search" id="search" value="{{ request('search') }}"
|
||||||
|
class="block w-full rounded-lg border-slate-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" placeholder="{{ __('Search…') }}" />
|
||||||
|
<button type="submit" class="rounded-lg bg-slate-800 px-4 py-2 text-sm font-semibold text-white hover:bg-slate-700">{{ __('Filter') }}</button>
|
||||||
|
</div>
|
||||||
|
@error('search')
|
||||||
|
<p class="text-sm text-red-600">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
</form>
|
||||||
|
<a href="{{ route('admin.pages.subscribers.export', $page) }}{{ request()->filled('search') ? '?'.http_build_query(['search' => request('search')]) : '' }}"
|
||||||
|
class="inline-flex items-center justify-center rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 shadow-sm hover:bg-slate-50">
|
||||||
|
{{ __('Export CSV') }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-slate-200 text-left text-sm">
|
||||||
|
<thead class="bg-slate-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 font-semibold text-slate-700">{{ __('First name') }}</th>
|
||||||
|
<th class="px-4 py-3 font-semibold text-slate-700">{{ __('Last name') }}</th>
|
||||||
|
<th class="px-4 py-3 font-semibold text-slate-700">{{ __('Email') }}</th>
|
||||||
|
@if ($page->phone_enabled)
|
||||||
|
<th class="px-4 py-3 font-semibold text-slate-700">{{ __('Phone') }}</th>
|
||||||
|
@endif
|
||||||
|
<th class="px-4 py-3 font-semibold text-slate-700">{{ __('Registered at') }}</th>
|
||||||
|
<th class="px-4 py-3 font-semibold text-slate-700">{{ __('Mailwizz') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-100">
|
||||||
|
@forelse ($subscribers as $subscriber)
|
||||||
|
<tr class="hover:bg-slate-50/80">
|
||||||
|
<td class="px-4 py-3 text-slate-900">{{ $subscriber->first_name }}</td>
|
||||||
|
<td class="px-4 py-3 text-slate-900">{{ $subscriber->last_name }}</td>
|
||||||
|
<td class="px-4 py-3 text-slate-600">{{ $subscriber->email }}</td>
|
||||||
|
@if ($page->phone_enabled)
|
||||||
|
<td class="px-4 py-3 text-slate-600">{{ $subscriber->phone ?? '—' }}</td>
|
||||||
|
@endif
|
||||||
|
<td class="whitespace-nowrap px-4 py-3 text-slate-600">{{ $subscriber->created_at->timezone(config('app.timezone'))->format('Y-m-d H:i') }}</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
@if ($subscriber->synced_to_mailwizz)
|
||||||
|
<span class="inline-flex h-8 w-8 items-center justify-center rounded-full bg-emerald-100 text-emerald-700" title="{{ __('Synced') }}">
|
||||||
|
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
||||||
|
</span>
|
||||||
|
@else
|
||||||
|
<span class="inline-flex h-8 w-8 items-center justify-center rounded-full bg-slate-100 text-slate-500" title="{{ __('Not synced') }}">
|
||||||
|
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr>
|
||||||
|
<td colspan="{{ $page->phone_enabled ? 6 : 5 }}" class="px-4 py-12 text-center text-slate-500">
|
||||||
|
{{ __('No subscribers match your criteria.') }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
@if ($subscribers->hasPages())
|
||||||
|
<div class="border-t border-slate-200 px-4 py-3">
|
||||||
|
{{ $subscribers->links() }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
55
resources/views/admin/users/_form.blade.php
Normal file
55
resources/views/admin/users/_form.blade.php
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
@php
|
||||||
|
/** @var \App\Models\User|null $user */
|
||||||
|
$user = $user ?? null;
|
||||||
|
$isEdit = $user !== null;
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="grid max-w-xl gap-6">
|
||||||
|
<div>
|
||||||
|
<label for="name" class="block text-sm font-medium text-slate-700">{{ __('Name') }}</label>
|
||||||
|
<input type="text" name="name" id="name" value="{{ old('name', $user?->name) }}" required maxlength="255"
|
||||||
|
class="mt-1 block w-full rounded-lg border-slate-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" />
|
||||||
|
@error('name')
|
||||||
|
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="email" class="block text-sm font-medium text-slate-700">{{ __('Email') }}</label>
|
||||||
|
<input type="email" name="email" id="email" value="{{ old('email', $user?->email) }}" required
|
||||||
|
class="mt-1 block w-full rounded-lg border-slate-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" />
|
||||||
|
@error('email')
|
||||||
|
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="password" class="block text-sm font-medium text-slate-700">{{ __('Password') }}</label>
|
||||||
|
@if ($isEdit)
|
||||||
|
<p class="mt-0.5 text-xs text-slate-500">{{ __('Leave blank to keep the current password.') }}</p>
|
||||||
|
@endif
|
||||||
|
<input type="password" name="password" id="password" @if(! $isEdit) required @endif autocomplete="new-password"
|
||||||
|
class="mt-1 block w-full rounded-lg border-slate-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" />
|
||||||
|
@error('password')
|
||||||
|
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="password_confirmation" class="block text-sm font-medium text-slate-700">{{ __('Confirm password') }}</label>
|
||||||
|
<input type="password" name="password_confirmation" id="password_confirmation" @if(! $isEdit) required @endif autocomplete="new-password"
|
||||||
|
class="mt-1 block w-full rounded-lg border-slate-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="role" class="block text-sm font-medium text-slate-700">{{ __('Role') }}</label>
|
||||||
|
<select name="role" id="role" required
|
||||||
|
class="mt-1 block w-full rounded-lg border-slate-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
|
||||||
|
<option value="user" @selected(old('role', $user?->role) === 'user')>{{ __('User') }}</option>
|
||||||
|
<option value="superadmin" @selected(old('role', $user?->role) === 'superadmin')>{{ __('Superadmin') }}</option>
|
||||||
|
</select>
|
||||||
|
@error('role')
|
||||||
|
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -5,8 +5,21 @@
|
|||||||
@section('mobile_title', __('New user'))
|
@section('mobile_title', __('New user'))
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
<div class="mx-auto max-w-6xl">
|
<div class="mx-auto max-w-xl">
|
||||||
<h1 class="text-2xl font-semibold text-slate-900">{{ __('Create user') }}</h1>
|
<div class="mb-8">
|
||||||
<p class="mt-2 text-sm text-slate-600">{{ __('Form will be added in Step 10.') }}</p>
|
<a href="{{ route('admin.users.index') }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-500">← {{ __('Back to users') }}</a>
|
||||||
|
<h1 class="mt-4 text-2xl font-semibold text-slate-900">{{ __('Create user') }}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form action="{{ route('admin.users.store') }}" method="post" class="rounded-xl border border-slate-200 bg-white p-6 shadow-sm sm:p-8">
|
||||||
|
@csrf
|
||||||
|
@include('admin.users._form', ['user' => null])
|
||||||
|
<div class="mt-8 flex gap-3">
|
||||||
|
<button type="submit" class="rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500">
|
||||||
|
{{ __('Create user') }}
|
||||||
|
</button>
|
||||||
|
<a href="{{ route('admin.users.index') }}" class="rounded-lg border border-slate-300 bg-white px-4 py-2.5 text-sm font-semibold text-slate-700 hover:bg-slate-50">{{ __('Cancel') }}</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
@@ -5,9 +5,23 @@
|
|||||||
@section('mobile_title', __('Edit user'))
|
@section('mobile_title', __('Edit user'))
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
<div class="mx-auto max-w-6xl">
|
<div class="mx-auto max-w-xl">
|
||||||
<h1 class="text-2xl font-semibold text-slate-900">{{ __('Edit user') }}</h1>
|
<div class="mb-8">
|
||||||
<p class="mt-2 text-sm text-slate-600">{{ $user->email }}</p>
|
<a href="{{ route('admin.users.index') }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-500">← {{ __('Back to users') }}</a>
|
||||||
<p class="mt-2 text-sm text-slate-600">{{ __('Form will be added in Step 10.') }}</p>
|
<h1 class="mt-4 text-2xl font-semibold text-slate-900">{{ __('Edit user') }}</h1>
|
||||||
|
<p class="mt-1 text-sm text-slate-600">{{ $user->email }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form action="{{ route('admin.users.update', $user) }}" method="post" class="rounded-xl border border-slate-200 bg-white p-6 shadow-sm sm:p-8">
|
||||||
|
@csrf
|
||||||
|
@method('PUT')
|
||||||
|
@include('admin.users._form', ['user' => $user])
|
||||||
|
<div class="mt-8 flex gap-3">
|
||||||
|
<button type="submit" class="rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500">
|
||||||
|
{{ __('Save changes') }}
|
||||||
|
</button>
|
||||||
|
<a href="{{ route('admin.users.index') }}" class="rounded-lg border border-slate-300 bg-white px-4 py-2.5 text-sm font-semibold text-slate-700 hover:bg-slate-50">{{ __('Cancel') }}</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
@@ -5,8 +5,64 @@
|
|||||||
@section('mobile_title', __('Users'))
|
@section('mobile_title', __('Users'))
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
<div class="mx-auto max-w-6xl">
|
<div class="mx-auto max-w-5xl">
|
||||||
<h1 class="text-2xl font-semibold text-slate-900">{{ __('Users') }}</h1>
|
<div class="mb-8 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<p class="mt-2 text-sm text-slate-600">{{ __('User management will be completed in a later step.') }}</p>
|
<div>
|
||||||
|
<h1 class="text-2xl font-semibold text-slate-900">{{ __('Users') }}</h1>
|
||||||
|
<p class="mt-1 text-sm text-slate-600">{{ __('Backend accounts and roles.') }}</p>
|
||||||
|
</div>
|
||||||
|
@can('create', \App\Models\User::class)
|
||||||
|
<a href="{{ route('admin.users.create') }}"
|
||||||
|
class="inline-flex items-center justify-center rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500">
|
||||||
|
{{ __('New user') }}
|
||||||
|
</a>
|
||||||
|
@endcan
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-slate-200 text-left text-sm">
|
||||||
|
<thead class="bg-slate-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 font-semibold text-slate-700">{{ __('Name') }}</th>
|
||||||
|
<th class="px-4 py-3 font-semibold text-slate-700">{{ __('Email') }}</th>
|
||||||
|
<th class="px-4 py-3 font-semibold text-slate-700">{{ __('Role') }}</th>
|
||||||
|
<th class="px-4 py-3 text-right font-semibold text-slate-700">{{ __('Actions') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-100">
|
||||||
|
@foreach ($users as $user)
|
||||||
|
<tr class="hover:bg-slate-50/80">
|
||||||
|
<td class="px-4 py-3 font-medium text-slate-900">{{ $user->name }}</td>
|
||||||
|
<td class="px-4 py-3 text-slate-600">{{ $user->email }}</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<span class="inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium {{ $user->role === 'superadmin' ? 'bg-violet-100 text-violet-800' : 'bg-slate-100 text-slate-700' }}">
|
||||||
|
{{ $user->role === 'superadmin' ? __('Superadmin') : __('User') }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="whitespace-nowrap px-4 py-3 text-right">
|
||||||
|
@can('update', $user)
|
||||||
|
<a href="{{ route('admin.users.edit', $user) }}" class="text-indigo-600 hover:text-indigo-500">{{ __('Edit') }}</a>
|
||||||
|
@endcan
|
||||||
|
@can('delete', $user)
|
||||||
|
<form action="{{ route('admin.users.destroy', $user) }}" method="post" class="ml-3 inline"
|
||||||
|
onsubmit="return confirm({{ json_encode(__('Delete this user? This cannot be undone.')) }});">
|
||||||
|
@csrf
|
||||||
|
@method('DELETE')
|
||||||
|
<button type="submit" class="text-red-600 hover:text-red-500">{{ __('Delete') }}</button>
|
||||||
|
</form>
|
||||||
|
@endcan
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
@if ($users->hasPages())
|
||||||
|
<div class="border-t border-slate-200 px-4 py-3">
|
||||||
|
{{ $users->links() }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
29
resources/views/errors/post-too-large.blade.php
Normal file
29
resources/views/errors/post-too-large.blade.php
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{{ __('Upload too large') }} — {{ config('app.name') }}</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: system-ui, sans-serif; max-width: 40rem; margin: 2rem auto; padding: 0 1rem; line-height: 1.5; color: #1e293b; }
|
||||||
|
h1 { font-size: 1.25rem; }
|
||||||
|
code { background: #f1f5f9; padding: 0.15rem 0.4rem; border-radius: 0.25rem; font-size: 0.9em; }
|
||||||
|
.box { background: #fef2f2; border: 1px solid #fecaca; border-radius: 0.5rem; padding: 1rem 1.25rem; margin: 1rem 0; }
|
||||||
|
a { color: #4f46e5; }
|
||||||
|
ul { margin: 0.5rem 0; padding-left: 1.25rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>{{ __('Upload too large for your PHP settings') }}</h1>
|
||||||
|
<div class="box">
|
||||||
|
<p>{{ __('This request was about :size MB. Your server limit post_max_size is :post (and upload_max_filesize is :upload). Both must be larger than your form plus files (background up to 5 MB + logo up to 2 MB).', ['size' => number_format($contentLengthMb, 2), 'post' => $postMaxSize, 'upload' => $uploadMaxSize]) }}</p>
|
||||||
|
</div>
|
||||||
|
<p><strong>{{ __('Fix (local development)') }}</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li>{{ __('Run') }} <code>composer run dev</code> {{ __('or') }} <code>make dev</code> — {{ __('they start PHP with higher limits.') }}</li>
|
||||||
|
<li>{{ __('Or run:') }} <code>php -d post_max_size=64M -d upload_max_filesize=32M artisan serve</code></li>
|
||||||
|
<li>{{ __('Or edit php.ini (see php --ini) and restart.') }}</li>
|
||||||
|
</ul>
|
||||||
|
<p><a href="{{ $backUrl }}">{{ __('← Back to previous page') }}</a></p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -120,6 +120,12 @@
|
|||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
@if (session('error'))
|
||||||
|
<div class="mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800" role="alert">
|
||||||
|
{{ session('error') }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
@yield('content')
|
@yield('content')
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ use App\Http\Controllers\Admin\MailwizzController;
|
|||||||
use App\Http\Controllers\Admin\PageController;
|
use App\Http\Controllers\Admin\PageController;
|
||||||
use App\Http\Controllers\Admin\SubscriberController;
|
use App\Http\Controllers\Admin\SubscriberController;
|
||||||
use App\Http\Controllers\Admin\UserController;
|
use App\Http\Controllers\Admin\UserController;
|
||||||
use App\Http\Controllers\Auth\AuthenticatedSessionController;
|
|
||||||
use App\Http\Controllers\ProfileController;
|
use App\Http\Controllers\ProfileController;
|
||||||
use App\Http\Controllers\PublicPageController;
|
use App\Http\Controllers\PublicPageController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
@@ -32,9 +31,9 @@ Route::middleware(['auth', 'verified'])->prefix('admin')->name('admin.')->group(
|
|||||||
// Pre-registration pages (CRUD)
|
// Pre-registration pages (CRUD)
|
||||||
Route::resource('pages', PageController::class);
|
Route::resource('pages', PageController::class);
|
||||||
|
|
||||||
// Subscribers (nested under pages)
|
// Subscribers (nested under pages) — export before index so the path is unambiguous
|
||||||
Route::get('pages/{page}/subscribers', [SubscriberController::class, 'index'])->name('pages.subscribers.index');
|
|
||||||
Route::get('pages/{page}/subscribers/export', [SubscriberController::class, 'export'])->name('pages.subscribers.export');
|
Route::get('pages/{page}/subscribers/export', [SubscriberController::class, 'export'])->name('pages.subscribers.export');
|
||||||
|
Route::get('pages/{page}/subscribers', [SubscriberController::class, 'index'])->name('pages.subscribers.index');
|
||||||
|
|
||||||
// Mailwizz configuration (nested under pages)
|
// Mailwizz configuration (nested under pages)
|
||||||
Route::get('pages/{page}/mailwizz', [MailwizzController::class, 'edit'])->name('pages.mailwizz.edit');
|
Route::get('pages/{page}/mailwizz', [MailwizzController::class, 'edit'])->name('pages.mailwizz.edit');
|
||||||
|
|||||||
53
tests/Feature/StorePreregistrationPageTest.php
Normal file
53
tests/Feature/StorePreregistrationPageTest.php
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\PreregistrationPage;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class StorePreregistrationPageTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_authenticated_user_can_store_page_without_files(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create(['role' => 'user']);
|
||||||
|
|
||||||
|
$response = $this->actingAs($user)->post(route('admin.pages.store'), [
|
||||||
|
'title' => 'Summer Fest',
|
||||||
|
'heading' => 'Register',
|
||||||
|
'intro_text' => null,
|
||||||
|
'thank_you_message' => null,
|
||||||
|
'expired_message' => null,
|
||||||
|
'ticketshop_url' => null,
|
||||||
|
'start_date' => '2026-06-01T10:00',
|
||||||
|
'end_date' => '2026-06-30T18:00',
|
||||||
|
'phone_enabled' => false,
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertRedirect(route('admin.pages.index'));
|
||||||
|
$this->assertDatabaseCount('preregistration_pages', 1);
|
||||||
|
$this->assertSame('Summer Fest', PreregistrationPage::query()->first()?->title);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_validation_failure_redirects_back_with_input(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create(['role' => 'user']);
|
||||||
|
|
||||||
|
$response = $this->actingAs($user)->from(route('admin.pages.create'))->post(route('admin.pages.store'), [
|
||||||
|
'title' => '',
|
||||||
|
'heading' => 'H',
|
||||||
|
'start_date' => '2026-06-30T10:00',
|
||||||
|
'end_date' => '2026-06-01T10:00',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertRedirect(route('admin.pages.create'));
|
||||||
|
$response->assertSessionHasErrors('title');
|
||||||
|
$response->assertSessionHasInput('heading', 'H');
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user