feat: Phase 3 - public registration pages and Mailwizz config
This commit is contained in:
@@ -4,6 +4,9 @@ APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://localhost
|
||||
|
||||
# Wall-clock times from the admin UI (datetime-local) are interpreted in this zone.
|
||||
APP_TIMEZONE=Europe/Amsterdam
|
||||
|
||||
APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
APP_FAKER_LOCALE=en_US
|
||||
|
||||
@@ -13,34 +13,143 @@ class MailwizzApiController extends Controller
|
||||
{
|
||||
public function lists(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate(['api_key' => 'required|string']);
|
||||
$request->validate(['api_key' => ['required', 'string']]);
|
||||
|
||||
$response = Http::withHeaders([
|
||||
'X-Api-Key' => $request->api_key,
|
||||
'X-Api-Key' => $request->string('api_key')->toString(),
|
||||
])->get('https://www.mailwizz.nl/api/lists');
|
||||
|
||||
if ($response->failed()) {
|
||||
return response()->json(['error' => 'Invalid API key or connection failed'], 422);
|
||||
return response()->json(['message' => __('Invalid API key or connection failed.')], 422);
|
||||
}
|
||||
|
||||
return response()->json($response->json());
|
||||
$json = $response->json();
|
||||
if (is_array($json) && ($json['status'] ?? null) === 'error') {
|
||||
return response()->json(['message' => __('Invalid API key or connection failed.')], 422);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'lists' => $this->normalizeListsPayload(is_array($json) ? $json : []),
|
||||
]);
|
||||
}
|
||||
|
||||
public function fields(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'api_key' => 'required|string',
|
||||
'list_uid' => 'required|string',
|
||||
'api_key' => ['required', 'string'],
|
||||
'list_uid' => ['required', 'string'],
|
||||
]);
|
||||
|
||||
$listUid = $request->string('list_uid')->toString();
|
||||
$response = Http::withHeaders([
|
||||
'X-Api-Key' => $request->api_key,
|
||||
])->get("https://www.mailwizz.nl/api/lists/{$request->list_uid}/fields");
|
||||
'X-Api-Key' => $request->string('api_key')->toString(),
|
||||
])->get("https://www.mailwizz.nl/api/lists/{$listUid}/fields");
|
||||
|
||||
if ($response->failed()) {
|
||||
return response()->json(['error' => 'Failed to fetch list fields'], 422);
|
||||
return response()->json(['message' => __('Failed to fetch list fields.')], 422);
|
||||
}
|
||||
|
||||
return response()->json($response->json());
|
||||
$json = $response->json();
|
||||
if (is_array($json) && ($json['status'] ?? null) === 'error') {
|
||||
return response()->json(['message' => __('Failed to fetch list fields.')], 422);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'fields' => $this->normalizeFieldsPayload(is_array($json) ? $json : []),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{list_uid: string, name: string}>
|
||||
*/
|
||||
private function normalizeListsPayload(array $json): array
|
||||
{
|
||||
$out = [];
|
||||
$records = data_get($json, 'data.records');
|
||||
if (! is_array($records)) {
|
||||
return $out;
|
||||
}
|
||||
|
||||
foreach ($records as $row) {
|
||||
if (! is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$uid = data_get($row, 'general.list_uid') ?? data_get($row, 'list_uid');
|
||||
$name = data_get($row, 'general.name') ?? data_get($row, 'name');
|
||||
if (is_string($uid) && $uid !== '' && is_string($name) && $name !== '') {
|
||||
$out[] = ['list_uid' => $uid, 'name' => $name];
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{tag: string, label: string, type_identifier: string, options: array<string, string>}>
|
||||
*/
|
||||
private function normalizeFieldsPayload(array $json): array
|
||||
{
|
||||
$out = [];
|
||||
$records = data_get($json, 'data.records');
|
||||
if (! is_array($records)) {
|
||||
return $out;
|
||||
}
|
||||
|
||||
foreach ($records as $row) {
|
||||
if (! is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$tag = data_get($row, 'tag');
|
||||
$label = data_get($row, 'label');
|
||||
$typeId = data_get($row, 'type.identifier');
|
||||
if (! is_string($tag) || $tag === '' || ! is_string($label)) {
|
||||
continue;
|
||||
}
|
||||
$typeIdentifier = is_string($typeId) ? $typeId : '';
|
||||
$rawOptions = data_get($row, 'options');
|
||||
$options = $this->normalizeFieldOptions($rawOptions);
|
||||
|
||||
$out[] = [
|
||||
'tag' => $tag,
|
||||
'label' => $label,
|
||||
'type_identifier' => $typeIdentifier,
|
||||
'options' => $options,
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function normalizeFieldOptions(mixed $rawOptions): array
|
||||
{
|
||||
if ($rawOptions === null || $rawOptions === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (is_string($rawOptions)) {
|
||||
$decoded = json_decode($rawOptions, true);
|
||||
if (is_array($decoded)) {
|
||||
$rawOptions = $decoded;
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
if (! is_array($rawOptions)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ($rawOptions as $key => $value) {
|
||||
if (is_string($key) || is_int($key)) {
|
||||
$k = (string) $key;
|
||||
$out[$k] = is_scalar($value) ? (string) $value : '';
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,23 +5,51 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\UpdateMailwizzConfigRequest;
|
||||
use App\Models\PreregistrationPage;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class MailwizzController extends Controller
|
||||
{
|
||||
public function edit(PreregistrationPage $page): \Illuminate\View\View
|
||||
public function edit(PreregistrationPage $page): View
|
||||
{
|
||||
$this->authorize('update', $page);
|
||||
|
||||
$page->load('mailwizzConfig');
|
||||
|
||||
return view('admin.mailwizz.edit', compact('page'));
|
||||
}
|
||||
|
||||
public function update(Request $request, PreregistrationPage $page): \Illuminate\Http\RedirectResponse
|
||||
public function update(UpdateMailwizzConfigRequest $request, PreregistrationPage $page): RedirectResponse
|
||||
{
|
||||
return redirect()->route('admin.pages.mailwizz.edit', $page);
|
||||
$validated = $request->validated();
|
||||
|
||||
if (($validated['api_key'] ?? '') === '' && $page->mailwizzConfig !== null) {
|
||||
unset($validated['api_key']);
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($page, $validated): void {
|
||||
$page->mailwizzConfig()->updateOrCreate(
|
||||
['preregistration_page_id' => $page->id],
|
||||
array_merge($validated, ['preregistration_page_id' => $page->id])
|
||||
);
|
||||
});
|
||||
|
||||
return redirect()
|
||||
->route('admin.pages.mailwizz.edit', $page)
|
||||
->with('status', __('Mailwizz configuration saved.'));
|
||||
}
|
||||
|
||||
public function destroy(PreregistrationPage $page): \Illuminate\Http\RedirectResponse
|
||||
public function destroy(PreregistrationPage $page): RedirectResponse
|
||||
{
|
||||
return redirect()->route('admin.pages.mailwizz.edit', $page);
|
||||
$this->authorize('update', $page);
|
||||
|
||||
$page->mailwizzConfig()?->delete();
|
||||
|
||||
return redirect()
|
||||
->route('admin.pages.mailwizz.edit', $page)
|
||||
->with('status', __('Mailwizz integration removed.'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,55 +4,45 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\SubscribePublicPageRequest;
|
||||
use App\Models\PreregistrationPage;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class PublicPageController extends Controller
|
||||
{
|
||||
public function show(string $slug): View
|
||||
public function show(PreregistrationPage $publicPage): View
|
||||
{
|
||||
$page = PreregistrationPage::where('slug', $slug)
|
||||
->where('is_active', true)
|
||||
->firstOrFail();
|
||||
|
||||
return view('public.page', compact('page'));
|
||||
return view('public.page', ['page' => $publicPage]);
|
||||
}
|
||||
|
||||
public function subscribe(Request $request, string $slug): JsonResponse
|
||||
public function subscribe(SubscribePublicPageRequest $request, PreregistrationPage $publicPage): JsonResponse
|
||||
{
|
||||
$page = PreregistrationPage::where('slug', $slug)
|
||||
->where('is_active', true)
|
||||
->firstOrFail();
|
||||
abort_if(now()->lt($publicPage->start_date) || now()->gt($publicPage->end_date), 403);
|
||||
|
||||
abort_if(now()->lt($page->start_date) || now()->gt($page->end_date), 403);
|
||||
$validated = $request->validated();
|
||||
|
||||
$validated = $request->validate([
|
||||
'first_name' => 'required|string|max:255',
|
||||
'last_name' => 'required|string|max:255',
|
||||
'email' => 'required|email|max:255',
|
||||
'phone' => $page->phone_enabled ? 'required|string|max:20' : 'nullable',
|
||||
]);
|
||||
$exists = $publicPage->subscribers()
|
||||
->where('email', $validated['email'])
|
||||
->exists();
|
||||
|
||||
$exists = $page->subscribers()->where('email', $validated['email'])->exists();
|
||||
if ($exists) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'This email address is already registered.',
|
||||
'message' => __('You are already registered for this event.'),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$subscriber = $page->subscribers()->create($validated);
|
||||
$publicPage->subscribers()->create($validated);
|
||||
|
||||
// Mailwizz sync will be wired up in Phase 4
|
||||
if ($page->mailwizzConfig) {
|
||||
if ($publicPage->mailwizzConfig) {
|
||||
// SyncSubscriberToMailwizz::dispatch($subscriber);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => $page->thank_you_message ?? 'Thank you for registering!',
|
||||
'message' => $publicPage->thank_you_message ?? __('Thank you for registering!'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
51
app/Http/Requests/Admin/UpdateMailwizzConfigRequest.php
Normal file
51
app/Http/Requests/Admin/UpdateMailwizzConfigRequest.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Admin;
|
||||
|
||||
use App\Models\PreregistrationPage;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateMailwizzConfigRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
$page = $this->route('page');
|
||||
if (! $page instanceof PreregistrationPage) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->user()?->can('update', $page) ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, ValidationRule|string>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
/** @var PreregistrationPage $page */
|
||||
$page = $this->route('page');
|
||||
|
||||
return [
|
||||
'api_key' => [
|
||||
Rule::requiredIf(fn (): bool => $page->mailwizzConfig === null),
|
||||
'nullable',
|
||||
'string',
|
||||
'max:512',
|
||||
],
|
||||
'list_uid' => ['required', 'string', 'max:255'],
|
||||
'list_name' => ['nullable', 'string', 'max:255'],
|
||||
'field_email' => ['required', 'string', 'max:255'],
|
||||
'field_first_name' => ['required', 'string', 'max:255'],
|
||||
'field_last_name' => ['required', 'string', 'max:255'],
|
||||
'field_phone' => $page->phone_enabled
|
||||
? ['required', 'string', 'max:255']
|
||||
: ['nullable', 'string', 'max:255'],
|
||||
'tag_field' => ['required', 'string', 'max:255'],
|
||||
'tag_value' => ['required', 'string', 'max:255'],
|
||||
];
|
||||
}
|
||||
}
|
||||
44
app/Http/Requests/SubscribePublicPageRequest.php
Normal file
44
app/Http/Requests/SubscribePublicPageRequest.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Models\PreregistrationPage;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class SubscribePublicPageRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
/** @var PreregistrationPage $page */
|
||||
$page = $this->route('publicPage');
|
||||
|
||||
return [
|
||||
'first_name' => ['required', 'string', 'max:255'],
|
||||
'last_name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'email', 'max:255'],
|
||||
'phone' => $page->phone_enabled
|
||||
? ['required', 'string', 'max:20']
|
||||
: ['nullable', 'string', 'max:20'],
|
||||
];
|
||||
}
|
||||
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$email = $this->input('email');
|
||||
if (is_string($email)) {
|
||||
$this->merge([
|
||||
'email' => strtolower(trim($email)),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Models\PreregistrationPage;
|
||||
use Illuminate\Pagination\Paginator;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
@@ -23,5 +25,12 @@ class AppServiceProvider extends ServiceProvider
|
||||
public function boot(): void
|
||||
{
|
||||
Paginator::useTailwind();
|
||||
|
||||
Route::bind('publicPage', function (string $value): PreregistrationPage {
|
||||
return PreregistrationPage::query()
|
||||
->where('slug', $value)
|
||||
->where('is_active', true)
|
||||
->firstOrFail();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'timezone' => 'UTC',
|
||||
'timezone' => env('APP_TIMEZONE', 'Europe/Amsterdam'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
@@ -2,6 +2,290 @@ import './bootstrap';
|
||||
|
||||
import Alpine from 'alpinejs';
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('publicPreregisterPage', (config) => ({
|
||||
phase: config.phase,
|
||||
startAtMs: config.startAtMs,
|
||||
phoneEnabled: config.phoneEnabled,
|
||||
subscribeUrl: config.subscribeUrl,
|
||||
csrfToken: config.csrfToken,
|
||||
genericError: config.genericError,
|
||||
days: 0,
|
||||
hours: 0,
|
||||
minutes: 0,
|
||||
seconds: 0,
|
||||
countdownTimer: null,
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
submitting: false,
|
||||
formError: '',
|
||||
fieldErrors: {},
|
||||
thankYouMessage: '',
|
||||
|
||||
init() {
|
||||
if (this.phase === 'before') {
|
||||
this.tickCountdown();
|
||||
this.countdownTimer = setInterval(() => this.tickCountdown(), 1000);
|
||||
}
|
||||
},
|
||||
|
||||
tickCountdown() {
|
||||
const start = this.startAtMs;
|
||||
const now = Date.now();
|
||||
const diff = start - now;
|
||||
if (diff <= 0) {
|
||||
if (this.countdownTimer !== null) {
|
||||
clearInterval(this.countdownTimer);
|
||||
this.countdownTimer = null;
|
||||
}
|
||||
this.phase = 'active';
|
||||
return;
|
||||
}
|
||||
const totalSeconds = Math.floor(diff / 1000);
|
||||
this.days = Math.floor(totalSeconds / 86400);
|
||||
this.hours = Math.floor((totalSeconds % 86400) / 3600);
|
||||
this.minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
this.seconds = totalSeconds % 60;
|
||||
},
|
||||
|
||||
pad(n) {
|
||||
return String(n).padStart(2, '0');
|
||||
},
|
||||
|
||||
async submitForm() {
|
||||
const form = this.$refs.form;
|
||||
if (form !== undefined && form !== null && !form.checkValidity()) {
|
||||
form.reportValidity();
|
||||
return;
|
||||
}
|
||||
|
||||
this.formError = '';
|
||||
this.fieldErrors = {};
|
||||
this.submitting = true;
|
||||
try {
|
||||
const body = {
|
||||
first_name: this.first_name,
|
||||
last_name: this.last_name,
|
||||
email: this.email,
|
||||
};
|
||||
if (this.phoneEnabled) {
|
||||
body.phone = this.phone;
|
||||
}
|
||||
const res = await fetch(this.subscribeUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'X-CSRF-TOKEN': this.csrfToken,
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (res.ok && data.success) {
|
||||
this.phase = 'thanks';
|
||||
this.thankYouMessage = data.message ?? '';
|
||||
return;
|
||||
}
|
||||
if (typeof data.message === 'string' && data.message !== '') {
|
||||
this.formError = data.message;
|
||||
}
|
||||
if (data.errors !== undefined && data.errors !== null && typeof data.errors === 'object') {
|
||||
this.fieldErrors = data.errors;
|
||||
}
|
||||
} catch {
|
||||
this.formError = this.genericError;
|
||||
} finally {
|
||||
this.submitting = false;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
Alpine.data('mailwizzWizard', (cfg) => ({
|
||||
listsUrl: cfg.listsUrl,
|
||||
fieldsUrl: cfg.fieldsUrl,
|
||||
phoneEnabled: cfg.phoneEnabled,
|
||||
hasExistingConfig: cfg.hasExistingConfig,
|
||||
existing: cfg.existing,
|
||||
csrf: cfg.csrf,
|
||||
step: 1,
|
||||
apiKey: '',
|
||||
lists: [],
|
||||
selectedListUid: '',
|
||||
selectedListName: '',
|
||||
allFields: [],
|
||||
fieldEmail: '',
|
||||
fieldFirstName: '',
|
||||
fieldLastName: '',
|
||||
fieldPhone: '',
|
||||
tagField: '',
|
||||
tagValue: '',
|
||||
loading: false,
|
||||
errorMessage: '',
|
||||
|
||||
init() {
|
||||
if (this.existing) {
|
||||
this.fieldEmail = this.existing.field_email ?? '';
|
||||
this.fieldFirstName = this.existing.field_first_name ?? '';
|
||||
this.fieldLastName = this.existing.field_last_name ?? '';
|
||||
this.fieldPhone = this.existing.field_phone ?? '';
|
||||
this.tagField = this.existing.tag_field ?? '';
|
||||
this.tagValue = this.existing.tag_value ?? '';
|
||||
this.selectedListUid = this.existing.list_uid ?? '';
|
||||
this.selectedListName = this.existing.list_name ?? '';
|
||||
}
|
||||
},
|
||||
|
||||
textFields() {
|
||||
return this.allFields.filter((f) => f.type_identifier === 'text');
|
||||
},
|
||||
|
||||
emailFieldChoices() {
|
||||
const texts = this.textFields();
|
||||
const tagged = texts.filter(
|
||||
(f) =>
|
||||
(f.tag && f.tag.toUpperCase().includes('EMAIL')) ||
|
||||
(f.label && f.label.toLowerCase().includes('email')),
|
||||
);
|
||||
return tagged.length > 0 ? tagged : texts;
|
||||
},
|
||||
|
||||
phoneFields() {
|
||||
return this.allFields.filter((f) => f.type_identifier === 'phonenumber');
|
||||
},
|
||||
|
||||
checkboxFields() {
|
||||
return this.allFields.filter((f) => f.type_identifier === 'checkboxlist');
|
||||
},
|
||||
|
||||
tagOptionsList() {
|
||||
const f = this.allFields.find((x) => x.tag === this.tagField);
|
||||
if (!f || !f.options) {
|
||||
return [];
|
||||
}
|
||||
return Object.entries(f.options).map(([key, label]) => ({
|
||||
key,
|
||||
label: String(label),
|
||||
}));
|
||||
},
|
||||
|
||||
async postJson(url, body) {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'X-CSRF-TOKEN': this.csrf,
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return { res, data };
|
||||
},
|
||||
|
||||
async connectLists() {
|
||||
this.errorMessage = '';
|
||||
if (!this.apiKey.trim()) {
|
||||
this.errorMessage = cfg.strings.apiKeyRequired;
|
||||
return;
|
||||
}
|
||||
this.loading = true;
|
||||
try {
|
||||
const { res, data } = await this.postJson(this.listsUrl, { api_key: this.apiKey });
|
||||
if (!res.ok) {
|
||||
this.errorMessage = data.message || data.error || cfg.strings.genericError;
|
||||
return;
|
||||
}
|
||||
this.lists = Array.isArray(data.lists) ? data.lists : [];
|
||||
if (this.lists.length === 0) {
|
||||
this.errorMessage = cfg.strings.noListsError;
|
||||
return;
|
||||
}
|
||||
if (this.existing?.list_uid) {
|
||||
const match = this.lists.find((l) => l.list_uid === this.existing.list_uid);
|
||||
if (match) {
|
||||
this.selectedListUid = match.list_uid;
|
||||
this.selectedListName = match.name;
|
||||
}
|
||||
}
|
||||
this.step = 2;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async loadFieldsAndGoStep3() {
|
||||
this.errorMessage = '';
|
||||
if (!this.selectedListUid) {
|
||||
this.errorMessage = cfg.strings.selectListError;
|
||||
return;
|
||||
}
|
||||
this.syncListNameFromSelection();
|
||||
this.loading = true;
|
||||
try {
|
||||
const { res, data } = await this.postJson(this.fieldsUrl, {
|
||||
api_key: this.apiKey,
|
||||
list_uid: this.selectedListUid,
|
||||
});
|
||||
if (!res.ok) {
|
||||
this.errorMessage = data.message || data.error || cfg.strings.genericError;
|
||||
return;
|
||||
}
|
||||
this.allFields = Array.isArray(data.fields) ? data.fields : [];
|
||||
if (this.existing) {
|
||||
this.fieldEmail = this.existing.field_email || this.fieldEmail;
|
||||
this.fieldFirstName = this.existing.field_first_name || this.fieldFirstName;
|
||||
this.fieldLastName = this.existing.field_last_name || this.fieldLastName;
|
||||
this.fieldPhone = this.existing.field_phone || this.fieldPhone;
|
||||
this.tagField = this.existing.tag_field || this.tagField;
|
||||
this.tagValue = this.existing.tag_value || this.tagValue;
|
||||
}
|
||||
this.step = 3;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
syncListNameFromSelection() {
|
||||
const l = this.lists.find((x) => x.list_uid === this.selectedListUid);
|
||||
this.selectedListName = l ? l.name : '';
|
||||
},
|
||||
|
||||
goStep4() {
|
||||
this.errorMessage = '';
|
||||
if (!this.fieldEmail || !this.fieldFirstName || !this.fieldLastName) {
|
||||
this.errorMessage = cfg.strings.mapFieldsError;
|
||||
return;
|
||||
}
|
||||
if (this.phoneEnabled && !this.fieldPhone) {
|
||||
this.errorMessage = cfg.strings.mapPhoneError;
|
||||
return;
|
||||
}
|
||||
if (!this.tagField) {
|
||||
this.errorMessage = cfg.strings.tagFieldError;
|
||||
return;
|
||||
}
|
||||
this.step = 4;
|
||||
},
|
||||
|
||||
submitSave() {
|
||||
this.errorMessage = '';
|
||||
if (!this.tagValue) {
|
||||
this.errorMessage = cfg.strings.tagValueError;
|
||||
return;
|
||||
}
|
||||
if (!this.hasExistingConfig && !this.apiKey.trim()) {
|
||||
this.errorMessage = cfg.strings.apiKeyRequired;
|
||||
return;
|
||||
}
|
||||
this.$refs.saveForm.requestSubmit();
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
window.Alpine = Alpine;
|
||||
|
||||
Alpine.start();
|
||||
|
||||
252
resources/views/admin/mailwizz/edit.blade.php
Normal file
252
resources/views/admin/mailwizz/edit.blade.php
Normal file
@@ -0,0 +1,252 @@
|
||||
@php
|
||||
$config = $page->mailwizzConfig;
|
||||
$existing = $config !== null
|
||||
? [
|
||||
'list_uid' => $config->list_uid,
|
||||
'list_name' => $config->list_name,
|
||||
'field_email' => $config->field_email,
|
||||
'field_first_name' => $config->field_first_name,
|
||||
'field_last_name' => $config->field_last_name,
|
||||
'field_phone' => $config->field_phone,
|
||||
'tag_field' => $config->tag_field,
|
||||
'tag_value' => $config->tag_value,
|
||||
]
|
||||
: null;
|
||||
@endphp
|
||||
|
||||
@extends('layouts.admin')
|
||||
|
||||
@section('title', __('Mailwizz') . ' — ' . $page->title)
|
||||
|
||||
@section('mobile_title', __('Mailwizz'))
|
||||
|
||||
@section('content')
|
||||
<div class="mx-auto max-w-3xl" x-data="mailwizzWizard(@js([
|
||||
'listsUrl' => route('admin.mailwizz.lists'),
|
||||
'fieldsUrl' => route('admin.mailwizz.fields'),
|
||||
'csrf' => csrf_token(),
|
||||
'phoneEnabled' => (bool) $page->phone_enabled,
|
||||
'hasExistingConfig' => $config !== null,
|
||||
'existing' => $existing,
|
||||
'strings' => [
|
||||
'apiKeyRequired' => __('Enter your Mailwizz API key to continue.'),
|
||||
'genericError' => __('Something went wrong. Please try again.'),
|
||||
'noListsError' => __('No mailing lists were returned. Check your API key or create a list in Mailwizz.'),
|
||||
'selectListError' => __('Select a mailing list.'),
|
||||
'mapFieldsError' => __('Map email, first name, and last name to Mailwizz fields.'),
|
||||
'mapPhoneError' => __('Map the phone field for this page.'),
|
||||
'tagFieldError' => __('Select a checkbox list field for source / tag tracking.'),
|
||||
'tagValueError' => __('Select the tag option that identifies this pre-registration.'),
|
||||
],
|
||||
]))">
|
||||
<div class="mb-8">
|
||||
<a href="{{ route('admin.pages.edit', $page) }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-500">← {{ __('Back to page') }}</a>
|
||||
<h1 class="mt-4 text-2xl font-semibold text-slate-900">{{ __('Mailwizz') }}</h1>
|
||||
<p class="mt-2 text-sm text-slate-600">{{ __('Page:') }} <span class="font-medium text-slate-800">{{ $page->title }}</span></p>
|
||||
</div>
|
||||
|
||||
@if ($config !== null)
|
||||
<div class="mb-8 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900">
|
||||
<p class="font-medium">{{ __('Integration active') }}</p>
|
||||
<p class="mt-1 text-emerald-800">
|
||||
{{ __('List:') }}
|
||||
<span class="font-mono text-xs">{{ $config->list_name ?: $config->list_uid }}</span>
|
||||
</p>
|
||||
<form action="{{ route('admin.pages.mailwizz.destroy', $page) }}" method="post" class="mt-3"
|
||||
onsubmit="return confirm(@js(__('Remove Mailwizz integration for this page? Subscribers will stay in the database but will no longer sync.')));">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="text-sm font-semibold text-red-700 underline hover:text-red-800">
|
||||
{{ __('Remove integration') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="mb-6 flex flex-wrap gap-2 text-xs font-medium text-slate-500">
|
||||
<span :class="step >= 1 ? 'text-indigo-600' : ''">1. {{ __('API key') }}</span>
|
||||
<span aria-hidden="true">→</span>
|
||||
<span :class="step >= 2 ? 'text-indigo-600' : ''">2. {{ __('List') }}</span>
|
||||
<span aria-hidden="true">→</span>
|
||||
<span :class="step >= 3 ? 'text-indigo-600' : ''">3. {{ __('Field mapping') }}</span>
|
||||
<span aria-hidden="true">→</span>
|
||||
<span :class="step >= 4 ? 'text-indigo-600' : ''">4. {{ __('Tag / source') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-6 shadow-sm sm:p-8">
|
||||
<div x-show="errorMessage !== ''" x-cloak class="mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800" x-text="errorMessage"></div>
|
||||
|
||||
{{-- Step 1 --}}
|
||||
<div x-show="step === 1" x-cloak class="space-y-4">
|
||||
<p class="text-sm leading-relaxed text-slate-600">
|
||||
{{ __('First, create a mailing list in Mailwizz with the required custom fields. Add a custom field of type Checkbox List with an option value you will use to track this pre-registration source.') }}
|
||||
</p>
|
||||
@if ($config !== null)
|
||||
<p class="text-sm text-amber-800">
|
||||
{{ __('Enter your API key and connect to load Mailwizz data (the same key as before is fine). If you clear the key field before saving, the previously stored key is kept.') }}
|
||||
</p>
|
||||
@endif
|
||||
<div>
|
||||
<label for="mailwizz_api_key" class="block text-sm font-medium text-slate-700">{{ __('Mailwizz API key') }}</label>
|
||||
<input
|
||||
id="mailwizz_api_key"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
x-model="apiKey"
|
||||
class="mt-1 block w-full rounded-lg border border-slate-300 px-3 py-2 text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
placeholder="{{ __('Paste API key') }}"
|
||||
>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 disabled:opacity-50"
|
||||
:disabled="loading"
|
||||
@click="connectLists()"
|
||||
>
|
||||
<span x-show="!loading">{{ __('Connect & load lists') }}</span>
|
||||
<span x-show="loading" x-cloak>{{ __('Connecting…') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- Step 2 --}}
|
||||
<div x-show="step === 2" x-cloak class="space-y-4">
|
||||
<div>
|
||||
<label for="mailwizz_list" class="block text-sm font-medium text-slate-700">{{ __('Mailing list') }}</label>
|
||||
<select
|
||||
id="mailwizz_list"
|
||||
x-model="selectedListUid"
|
||||
@change="syncListNameFromSelection()"
|
||||
class="mt-1 block w-full rounded-lg border border-slate-300 px-3 py-2 text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
>
|
||||
<option value="">{{ __('Select a list…') }}</option>
|
||||
<template x-for="list in lists" :key="list.list_uid">
|
||||
<option :value="list.list_uid" x-text="list.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<button type="button" 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" @click="step = 1">
|
||||
{{ __('Back') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 disabled:opacity-50"
|
||||
:disabled="loading"
|
||||
@click="loadFieldsAndGoStep3()"
|
||||
>
|
||||
<span x-show="!loading">{{ __('Load fields') }}</span>
|
||||
<span x-show="loading" x-cloak>{{ __('Loading…') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Step 3 --}}
|
||||
<div x-show="step === 3" x-cloak class="space-y-5">
|
||||
<p class="text-sm text-slate-600">{{ __('Map each local field to the matching Mailwizz custom field (by tag).') }}</p>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">{{ __('Email') }}</label>
|
||||
<select x-model="fieldEmail" class="mt-1 block w-full rounded-lg border border-slate-300 px-3 py-2 text-sm text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500">
|
||||
<option value="">{{ __('Select field…') }}</option>
|
||||
<template x-for="f in emailFieldChoices()" :key="f.tag">
|
||||
<option :value="f.tag" x-text="f.label + ' (' + f.tag + ')'"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">{{ __('First name') }}</label>
|
||||
<select x-model="fieldFirstName" class="mt-1 block w-full rounded-lg border border-slate-300 px-3 py-2 text-sm text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500">
|
||||
<option value="">{{ __('Select field…') }}</option>
|
||||
<template x-for="f in textFields()" :key="'fn-' + f.tag">
|
||||
<option :value="f.tag" x-text="f.label + ' (' + f.tag + ')'"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">{{ __('Last name') }}</label>
|
||||
<select x-model="fieldLastName" class="mt-1 block w-full rounded-lg border border-slate-300 px-3 py-2 text-sm text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500">
|
||||
<option value="">{{ __('Select field…') }}</option>
|
||||
<template x-for="f in textFields()" :key="'ln-' + f.tag">
|
||||
<option :value="f.tag" x-text="f.label + ' (' + f.tag + ')'"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<div x-show="phoneEnabled">
|
||||
<label class="block text-sm font-medium text-slate-700">{{ __('Phone') }}</label>
|
||||
<select x-model="fieldPhone" class="mt-1 block w-full rounded-lg border border-slate-300 px-3 py-2 text-sm text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500">
|
||||
<option value="">{{ __('Select field…') }}</option>
|
||||
<template x-for="f in phoneFields()" :key="'ph-' + f.tag">
|
||||
<option :value="f.tag" x-text="f.label + ' (' + f.tag + ')'"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">{{ __('Tag / source (checkbox list)') }}</label>
|
||||
<select
|
||||
x-model="tagField"
|
||||
@change="tagValue = ''"
|
||||
class="mt-1 block w-full rounded-lg border border-slate-300 px-3 py-2 text-sm text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
>
|
||||
<option value="">{{ __('Select checkbox list field…') }}</option>
|
||||
<template x-for="f in checkboxFields()" :key="'cb-' + f.tag">
|
||||
<option :value="f.tag" x-text="f.label + ' (' + f.tag + ')'"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<p x-show="checkboxFields().length === 0" class="text-sm text-amber-800">
|
||||
{{ __('No checkbox list fields were returned for this list. Add one in Mailwizz, then run “Load fields” again from step 2.') }}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<button type="button" 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" @click="step = 2">
|
||||
{{ __('Back') }}
|
||||
</button>
|
||||
<button type="button" class="rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500" @click="goStep4()">
|
||||
{{ __('Continue') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Step 4 --}}
|
||||
<div x-show="step === 4" x-cloak class="space-y-5">
|
||||
<p class="text-sm text-slate-600">{{ __('Choose the checkbox option that marks subscribers from this pre-registration page.') }}</p>
|
||||
|
||||
<fieldset class="space-y-2">
|
||||
<legend class="sr-only">{{ __('Tag value') }}</legend>
|
||||
<template x-for="opt in tagOptionsList()" :key="opt.key">
|
||||
<label class="flex cursor-pointer items-start gap-3 rounded-lg border border-slate-200 p-3 hover:bg-slate-50">
|
||||
<input type="radio" name="tag_value_choice" class="mt-1 text-indigo-600" :value="opt.key" x-model="tagValue">
|
||||
<span class="text-sm text-slate-800" x-text="opt.label"></span>
|
||||
</label>
|
||||
</template>
|
||||
</fieldset>
|
||||
<p x-show="tagField && tagOptionsList().length === 0" class="text-sm text-amber-800">
|
||||
{{ __('This field has no options defined in Mailwizz. Add options to the checkbox list field, then reload fields.') }}
|
||||
</p>
|
||||
|
||||
<form x-ref="saveForm" method="post" action="{{ route('admin.pages.mailwizz.update', $page) }}" class="space-y-4">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
<input type="hidden" name="api_key" x-bind:value="apiKey">
|
||||
<input type="hidden" name="list_uid" x-bind:value="selectedListUid">
|
||||
<input type="hidden" name="list_name" x-bind:value="selectedListName">
|
||||
<input type="hidden" name="field_email" x-bind:value="fieldEmail">
|
||||
<input type="hidden" name="field_first_name" x-bind:value="fieldFirstName">
|
||||
<input type="hidden" name="field_last_name" x-bind:value="fieldLastName">
|
||||
<input type="hidden" name="field_phone" x-bind:value="phoneEnabled ? fieldPhone : ''">
|
||||
<input type="hidden" name="tag_field" x-bind:value="tagField">
|
||||
<input type="hidden" name="tag_value" x-bind:value="tagValue">
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<button type="button" 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" @click="step = 3">
|
||||
{{ __('Back') }}
|
||||
</button>
|
||||
<button type="button" class="rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500" @click="submitSave()">
|
||||
{{ __('Save configuration') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -10,8 +10,13 @@
|
||||
<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>
|
||||
{{ __('Public URL') }}: <a href="{{ route('public.page', ['publicPage' => $page]) }}" class="text-indigo-600 hover:underline" target="_blank" rel="noopener">{{ url('/r/'.$page->slug) }}</a>
|
||||
</p>
|
||||
@can('update', $page)
|
||||
<p class="mt-3">
|
||||
<a href="{{ route('admin.pages.mailwizz.edit', $page) }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-500">{{ __('Mailwizz integration') }} →</a>
|
||||
</p>
|
||||
@endcan
|
||||
</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>
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
@forelse ($pages as $page)
|
||||
@php
|
||||
$publicUrl = route('public.page', ['slug' => $page->slug]);
|
||||
$publicUrl = route('public.page', ['publicPage' => $page]);
|
||||
$key = $page->statusKey();
|
||||
$statusClasses = match ($key) {
|
||||
'before_start' => 'bg-slate-100 text-slate-800',
|
||||
@@ -72,6 +72,9 @@
|
||||
@can('view', $page)
|
||||
<a href="{{ route('admin.pages.subscribers.index', $page) }}" class="text-indigo-600 hover:text-indigo-500">{{ __('Subscribers') }}</a>
|
||||
@endcan
|
||||
@can('update', $page)
|
||||
<a href="{{ route('admin.pages.mailwizz.edit', $page) }}" class="text-indigo-600 hover:text-indigo-500">{{ __('Mailwizz') }}</a>
|
||||
@endcan
|
||||
<button
|
||||
type="button"
|
||||
x-data="{ copied: false }"
|
||||
|
||||
18
resources/views/layouts/public.blade.php
Normal file
18
resources/views/layouts/public.blade.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
|
||||
<title>{{ $title ?? $page->heading }} — {{ config('app.name', 'PreRegister') }}</title>
|
||||
|
||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600,700&display=swap" rel="stylesheet">
|
||||
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
</head>
|
||||
<body class="font-sans antialiased">
|
||||
@yield('content')
|
||||
</body>
|
||||
</html>
|
||||
205
resources/views/public/page.blade.php
Normal file
205
resources/views/public/page.blade.php
Normal file
@@ -0,0 +1,205 @@
|
||||
@php
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
$bgUrl = $page->background_image !== null
|
||||
? Storage::disk('public')->url($page->background_image)
|
||||
: null;
|
||||
$logoUrl = $page->logo_image !== null
|
||||
? Storage::disk('public')->url($page->logo_image)
|
||||
: null;
|
||||
$phase = $page->isBeforeStart() ? 'before' : ($page->isExpired() ? 'expired' : 'active');
|
||||
@endphp
|
||||
|
||||
@extends('layouts.public')
|
||||
|
||||
@section('content')
|
||||
<div class="relative min-h-screen w-full overflow-x-hidden">
|
||||
@if ($bgUrl !== null)
|
||||
<div
|
||||
class="absolute inset-0 bg-cover bg-center bg-no-repeat"
|
||||
style="background-image: url('{{ e($bgUrl) }}')"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
@else
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
@endif
|
||||
|
||||
<div class="absolute inset-0 bg-black/50" aria-hidden="true"></div>
|
||||
|
||||
<div class="relative z-10 flex min-h-screen items-center justify-center px-4 py-10 sm:px-6 sm:py-12">
|
||||
<div
|
||||
class="w-full max-w-lg rounded-2xl border border-white/15 bg-white/10 px-6 py-8 shadow-2xl backdrop-blur-md sm:px-10 sm:py-10"
|
||||
x-cloak
|
||||
x-data="publicPreregisterPage(@js([
|
||||
'phase' => $phase,
|
||||
'startAtMs' => $page->start_date->getTimestamp() * 1000,
|
||||
'phoneEnabled' => (bool) $page->phone_enabled,
|
||||
'subscribeUrl' => route('public.subscribe', ['publicPage' => $page]),
|
||||
'csrfToken' => csrf_token(),
|
||||
'genericError' => __('Something went wrong. Please try again.'),
|
||||
]))"
|
||||
>
|
||||
@if ($logoUrl !== null)
|
||||
<div class="mb-6 flex justify-center">
|
||||
<img
|
||||
src="{{ e($logoUrl) }}"
|
||||
alt=""
|
||||
class="max-h-20 w-auto object-contain object-center"
|
||||
width="320"
|
||||
height="80"
|
||||
>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<h1 class="text-center text-3xl font-semibold leading-tight tracking-tight text-white sm:text-4xl">
|
||||
{{ $page->heading }}
|
||||
</h1>
|
||||
|
||||
{{-- Before start: intro + countdown --}}
|
||||
<div x-show="phase === 'before'" x-cloak class="mt-6 space-y-6">
|
||||
@if (filled($page->intro_text))
|
||||
<div class="whitespace-pre-line text-center text-base leading-relaxed text-white/90">
|
||||
{{ $page->intro_text }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div
|
||||
class="grid grid-cols-4 gap-2 rounded-xl border border-white/20 bg-black/25 px-3 py-4 text-center sm:gap-3 sm:px-4"
|
||||
role="timer"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div>
|
||||
<div class="font-mono text-2xl font-semibold tabular-nums text-white sm:text-3xl" x-text="pad(days)"></div>
|
||||
<div class="mt-1 text-xs uppercase tracking-wide text-white/60">{{ __('days') }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-mono text-2xl font-semibold tabular-nums text-white sm:text-3xl" x-text="pad(hours)"></div>
|
||||
<div class="mt-1 text-xs uppercase tracking-wide text-white/60">{{ __('hrs') }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-mono text-2xl font-semibold tabular-nums text-white sm:text-3xl" x-text="pad(minutes)"></div>
|
||||
<div class="mt-1 text-xs uppercase tracking-wide text-white/60">{{ __('mins') }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-mono text-2xl font-semibold tabular-nums text-white sm:text-3xl" x-text="pad(seconds)"></div>
|
||||
<div class="mt-1 text-xs uppercase tracking-wide text-white/60">{{ __('secs') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Active: registration form --}}
|
||||
<div x-show="phase === 'active'" x-cloak class="mt-8">
|
||||
<form x-ref="form" class="space-y-4" @submit.prevent="submitForm()">
|
||||
<div x-show="formError !== ''" x-cloak class="rounded-lg border border-amber-400/40 bg-amber-500/10 px-3 py-2 text-sm text-amber-100" x-text="formError"></div>
|
||||
|
||||
<div>
|
||||
<label for="first_name" class="mb-1 block text-sm font-medium text-white/90">{{ __('First name') }}</label>
|
||||
<input
|
||||
id="first_name"
|
||||
type="text"
|
||||
name="first_name"
|
||||
autocomplete="given-name"
|
||||
required
|
||||
maxlength="255"
|
||||
x-model="first_name"
|
||||
class="w-full rounded-lg border border-white/25 bg-white/10 px-3 py-2.5 text-white placeholder-white/40 shadow-sm backdrop-blur-sm focus:border-white/50 focus:outline-none focus:ring-2 focus:ring-white/30"
|
||||
placeholder="{{ __('First name') }}"
|
||||
>
|
||||
<p x-show="fieldErrors.first_name" x-cloak class="mt-1 text-sm text-red-200" x-text="fieldErrors.first_name ? fieldErrors.first_name[0] : ''"></p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="last_name" class="mb-1 block text-sm font-medium text-white/90">{{ __('Last name') }}</label>
|
||||
<input
|
||||
id="last_name"
|
||||
type="text"
|
||||
name="last_name"
|
||||
autocomplete="family-name"
|
||||
required
|
||||
maxlength="255"
|
||||
x-model="last_name"
|
||||
class="w-full rounded-lg border border-white/25 bg-white/10 px-3 py-2.5 text-white placeholder-white/40 shadow-sm backdrop-blur-sm focus:border-white/50 focus:outline-none focus:ring-2 focus:ring-white/30"
|
||||
placeholder="{{ __('Last name') }}"
|
||||
>
|
||||
<p x-show="fieldErrors.last_name" x-cloak class="mt-1 text-sm text-red-200" x-text="fieldErrors.last_name ? fieldErrors.last_name[0] : ''"></p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="email" class="mb-1 block text-sm font-medium text-white/90">{{ __('Email') }}</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
name="email"
|
||||
autocomplete="email"
|
||||
required
|
||||
maxlength="255"
|
||||
x-model="email"
|
||||
class="w-full rounded-lg border border-white/25 bg-white/10 px-3 py-2.5 text-white placeholder-white/40 shadow-sm backdrop-blur-sm focus:border-white/50 focus:outline-none focus:ring-2 focus:ring-white/30"
|
||||
placeholder="{{ __('Email') }}"
|
||||
>
|
||||
<p x-show="fieldErrors.email" x-cloak class="mt-1 text-sm text-red-200" x-text="fieldErrors.email ? fieldErrors.email[0] : ''"></p>
|
||||
</div>
|
||||
|
||||
<div x-show="phoneEnabled">
|
||||
<label for="phone" class="mb-1 block text-sm font-medium text-white/90">{{ __('Phone') }}</label>
|
||||
<input
|
||||
id="phone"
|
||||
type="tel"
|
||||
name="phone"
|
||||
autocomplete="tel"
|
||||
:required="phoneEnabled"
|
||||
maxlength="20"
|
||||
x-model="phone"
|
||||
class="w-full rounded-lg border border-white/25 bg-white/10 px-3 py-2.5 text-white placeholder-white/40 shadow-sm backdrop-blur-sm focus:border-white/50 focus:outline-none focus:ring-2 focus:ring-white/30"
|
||||
placeholder="{{ __('Phone') }}"
|
||||
>
|
||||
<p x-show="fieldErrors.phone" x-cloak class="mt-1 text-sm text-red-200" x-text="fieldErrors.phone ? fieldErrors.phone[0] : ''"></p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="mt-2 w-full rounded-lg bg-white px-4 py-3 text-sm font-semibold text-slate-900 shadow transition hover:bg-white/90 focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-slate-900 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
:disabled="submitting"
|
||||
>
|
||||
<span x-show="!submitting">{{ __('Register') }}</span>
|
||||
<span x-show="submitting" x-cloak>{{ __('Sending…') }}</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{{-- Thank you (after successful AJAX) --}}
|
||||
<div x-show="phase === 'thanks'" x-cloak class="mt-8">
|
||||
<p class="whitespace-pre-line text-center text-lg leading-relaxed text-white/95" x-text="thankYouMessage"></p>
|
||||
</div>
|
||||
|
||||
{{-- Expired --}}
|
||||
<div x-show="phase === 'expired'" x-cloak class="mt-8 space-y-6">
|
||||
@if (filled($page->expired_message))
|
||||
<div class="whitespace-pre-line text-center text-base leading-relaxed text-white/90">
|
||||
{{ $page->expired_message }}
|
||||
</div>
|
||||
@else
|
||||
<p class="text-center text-base text-white/90">{{ __('This pre-registration period has ended.') }}</p>
|
||||
@endif
|
||||
|
||||
@if (filled($page->ticketshop_url))
|
||||
<div class="text-center">
|
||||
<a
|
||||
href="{{ e($page->ticketshop_url) }}"
|
||||
class="inline-flex items-center justify-center rounded-lg bg-white px-5 py-2.5 text-sm font-semibold text-slate-900 shadow transition hover:bg-white/90 focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-slate-900"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{{ __('Visit ticket shop') }}
|
||||
<span class="ml-1" aria-hidden="true">→</span>
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -19,8 +19,8 @@ Route::get('/', function () {
|
||||
|
||||
// ─── Public (no auth) ────────────────────────────────────
|
||||
Route::middleware('throttle:10,1')->group(function () {
|
||||
Route::get('/r/{slug}', [PublicPageController::class, 'show'])->name('public.page');
|
||||
Route::post('/r/{slug}/subscribe', [PublicPageController::class, 'subscribe'])->name('public.subscribe');
|
||||
Route::get('/r/{publicPage:slug}', [PublicPageController::class, 'show'])->name('public.page');
|
||||
Route::post('/r/{publicPage:slug}/subscribe', [PublicPageController::class, 'subscribe'])->name('public.subscribe');
|
||||
});
|
||||
|
||||
// ─── Backend (auth required) ─────────────────────────────
|
||||
|
||||
56
tests/Feature/MailwizzConfigUiTest.php
Normal file
56
tests/Feature/MailwizzConfigUiTest.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\PreregistrationPage;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\TestCase;
|
||||
|
||||
class MailwizzConfigUiTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_owner_can_view_mailwizz_wizard(): void
|
||||
{
|
||||
$user = User::factory()->create(['role' => 'user']);
|
||||
$page = $this->makePageForUser($user);
|
||||
|
||||
$response = $this->actingAs($user)->get(route('admin.pages.mailwizz.edit', $page));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertSee('Mailwizz', escape: false);
|
||||
}
|
||||
|
||||
public function test_other_user_cannot_view_mailwizz_wizard(): void
|
||||
{
|
||||
$owner = User::factory()->create(['role' => 'user']);
|
||||
$intruder = User::factory()->create(['role' => 'user']);
|
||||
$page = $this->makePageForUser($owner);
|
||||
|
||||
$response = $this->actingAs($intruder)->get(route('admin.pages.mailwizz.edit', $page));
|
||||
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
private function makePageForUser(User $user): PreregistrationPage
|
||||
{
|
||||
return PreregistrationPage::query()->create([
|
||||
'slug' => (string) Str::uuid(),
|
||||
'user_id' => $user->id,
|
||||
'title' => 'Fest',
|
||||
'heading' => 'Join',
|
||||
'intro_text' => null,
|
||||
'thank_you_message' => null,
|
||||
'expired_message' => null,
|
||||
'ticketshop_url' => null,
|
||||
'start_date' => now()->addDay(),
|
||||
'end_date' => now()->addMonth(),
|
||||
'phone_enabled' => false,
|
||||
'is_active' => true,
|
||||
]);
|
||||
}
|
||||
}
|
||||
135
tests/Feature/PublicPageTest.php
Normal file
135
tests/Feature/PublicPageTest.php
Normal file
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\PreregistrationPage;
|
||||
use App\Models\Subscriber;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PublicPageTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_show_returns_ok_for_active_public_page(): void
|
||||
{
|
||||
$page = $this->makePage([
|
||||
'start_date' => now()->addDay(),
|
||||
'end_date' => now()->addMonth(),
|
||||
]);
|
||||
|
||||
$response = $this->get(route('public.page', ['publicPage' => $page->slug]));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertSee($page->heading, escape: false);
|
||||
}
|
||||
|
||||
public function test_show_returns_not_found_when_page_is_inactive(): void
|
||||
{
|
||||
$page = $this->makePage([
|
||||
'is_active' => false,
|
||||
'start_date' => now()->subHour(),
|
||||
'end_date' => now()->addMonth(),
|
||||
]);
|
||||
|
||||
$response = $this->get(route('public.page', ['publicPage' => $page->slug]));
|
||||
|
||||
$response->assertNotFound();
|
||||
}
|
||||
|
||||
public function test_subscribe_returns_json_success_and_creates_subscriber(): void
|
||||
{
|
||||
$page = $this->makePage([
|
||||
'thank_you_message' => 'Custom thanks',
|
||||
'start_date' => now()->subHour(),
|
||||
'end_date' => now()->addMonth(),
|
||||
'phone_enabled' => false,
|
||||
]);
|
||||
|
||||
$response = $this->postJson(route('public.subscribe', ['publicPage' => $page->slug]), [
|
||||
'first_name' => 'Ada',
|
||||
'last_name' => 'Lovelace',
|
||||
'email' => 'ada@example.com',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJson([
|
||||
'success' => true,
|
||||
'message' => 'Custom thanks',
|
||||
]);
|
||||
$this->assertDatabaseHas('subscribers', [
|
||||
'preregistration_page_id' => $page->id,
|
||||
'email' => 'ada@example.com',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_subscribe_returns_friendly_message_for_duplicate_email(): void
|
||||
{
|
||||
$page = $this->makePage([
|
||||
'start_date' => now()->subHour(),
|
||||
'end_date' => now()->addMonth(),
|
||||
]);
|
||||
|
||||
Subscriber::query()->create([
|
||||
'preregistration_page_id' => $page->id,
|
||||
'first_name' => 'A',
|
||||
'last_name' => 'B',
|
||||
'email' => 'dup@example.com',
|
||||
]);
|
||||
|
||||
$response = $this->postJson(route('public.subscribe', ['publicPage' => $page->slug]), [
|
||||
'first_name' => 'C',
|
||||
'last_name' => 'D',
|
||||
'email' => 'dup@example.com',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJson([
|
||||
'success' => false,
|
||||
'message' => 'You are already registered for this event.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_subscribe_forbidden_outside_registration_window(): void
|
||||
{
|
||||
$page = $this->makePage([
|
||||
'start_date' => now()->addDay(),
|
||||
'end_date' => now()->addMonth(),
|
||||
]);
|
||||
|
||||
$response = $this->postJson(route('public.subscribe', ['publicPage' => $page->slug]), [
|
||||
'first_name' => 'A',
|
||||
'last_name' => 'B',
|
||||
'email' => 'a@example.com',
|
||||
]);
|
||||
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $overrides
|
||||
*/
|
||||
private function makePage(array $overrides = []): PreregistrationPage
|
||||
{
|
||||
$user = User::factory()->create(['role' => 'user']);
|
||||
|
||||
return PreregistrationPage::query()->create(array_merge([
|
||||
'slug' => (string) Str::uuid(),
|
||||
'user_id' => $user->id,
|
||||
'title' => 'Test page',
|
||||
'heading' => 'Join us',
|
||||
'intro_text' => null,
|
||||
'thank_you_message' => null,
|
||||
'expired_message' => null,
|
||||
'ticketshop_url' => null,
|
||||
'start_date' => now()->subHour(),
|
||||
'end_date' => now()->addMonth(),
|
||||
'phone_enabled' => false,
|
||||
'is_active' => true,
|
||||
], $overrides));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user