Compare commits
13 Commits
977e09d8ac
...
91caa16e70
| Author | SHA1 | Date | |
|---|---|---|---|
| 91caa16e70 | |||
| 1e7ee14540 | |||
| 627edbbb83 | |||
| 7eda51f52a | |||
| de83a6fb76 | |||
| d802ce2a7c | |||
| 7ed660ec55 | |||
| 9f8052f683 | |||
| 217e1d9afb | |||
| 89931b817d | |||
| e0de8a05fa | |||
| 55434ce086 | |||
| 6561bda30d |
@@ -45,6 +45,10 @@ SESSION_DOMAIN=null
|
||||
|
||||
BROADCAST_CONNECTION=log
|
||||
FILESYSTEM_DISK=local
|
||||
|
||||
# Use "database" or "redis" in production and run `php artisan queue:work` (see documentation/DEPLOYMENT-STRATEGY.md).
|
||||
# Avoid "sync" in production: Mailwizz (and Weeztix coupon) jobs would run inside the HTTP request; a thrown error
|
||||
# can return 5xx to the visitor even though the subscriber row is already saved — confusing UX.
|
||||
QUEUE_CONNECTION=database
|
||||
|
||||
CACHE_STORE=database
|
||||
|
||||
@@ -8,18 +8,29 @@ use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\UpdateMailwizzConfigRequest;
|
||||
use App\Models\PreregistrationPage;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class MailwizzController extends Controller
|
||||
{
|
||||
public function edit(PreregistrationPage $page): View
|
||||
public function edit(Request $request, PreregistrationPage $page): View|RedirectResponse
|
||||
{
|
||||
$this->authorize('update', $page);
|
||||
|
||||
$page->load('mailwizzConfig');
|
||||
$page->load(['mailwizzConfig', 'weeztixConfig']);
|
||||
|
||||
return view('admin.mailwizz.edit', compact('page'));
|
||||
$config = $page->mailwizzConfig;
|
||||
$showWizard = $config === null || $request->boolean('wizard');
|
||||
if ($showWizard && $config === null) {
|
||||
$requestedStep = min(4, max(1, (int) $request->query('step', 1)));
|
||||
if ($requestedStep !== 1) {
|
||||
return redirect()
|
||||
->route('admin.pages.mailwizz.edit', ['page' => $page, 'wizard' => 1, 'step' => 1]);
|
||||
}
|
||||
}
|
||||
|
||||
return view('admin.mailwizz.edit', compact('page', 'showWizard'));
|
||||
}
|
||||
|
||||
public function update(UpdateMailwizzConfigRequest $request, PreregistrationPage $page): RedirectResponse
|
||||
|
||||
@@ -28,7 +28,7 @@ class PageController extends Controller
|
||||
{
|
||||
$query = PreregistrationPage::query()
|
||||
->withCount('subscribers')
|
||||
->with('weeztixConfig')
|
||||
->with(['weeztixConfig', 'mailwizzConfig'])
|
||||
->orderByDesc('start_date');
|
||||
|
||||
if (! $request->user()?->isSuperadmin()) {
|
||||
@@ -86,7 +86,11 @@ class PageController extends Controller
|
||||
|
||||
public function edit(PreregistrationPage $page): View
|
||||
{
|
||||
$page->load(['blocks' => fn ($q) => $q->orderBy('sort_order')]);
|
||||
$page->load([
|
||||
'blocks' => fn ($q) => $q->orderBy('sort_order'),
|
||||
'mailwizzConfig',
|
||||
'weeztixConfig',
|
||||
]);
|
||||
|
||||
return view('admin.pages.edit', compact('page'));
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ use App\Http\Requests\Admin\IndexSubscriberRequest;
|
||||
use App\Http\Requests\Admin\QueueMailwizzSyncRequest;
|
||||
use App\Models\PreregistrationPage;
|
||||
use App\Models\Subscriber;
|
||||
use App\Services\CleanupSubscriberIntegrationsService;
|
||||
use App\Services\DispatchUnsyncedMailwizzSyncJobsService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\View\View;
|
||||
@@ -34,8 +35,13 @@ class SubscriberController extends Controller
|
||||
return view('admin.subscribers.index', compact('page', 'subscribers', 'unsyncedMailwizzCount'));
|
||||
}
|
||||
|
||||
public function destroy(DestroySubscriberRequest $request, PreregistrationPage $page, Subscriber $subscriber): RedirectResponse
|
||||
{
|
||||
public function destroy(
|
||||
DestroySubscriberRequest $request,
|
||||
PreregistrationPage $page,
|
||||
Subscriber $subscriber,
|
||||
CleanupSubscriberIntegrationsService $cleanupIntegrations
|
||||
): RedirectResponse {
|
||||
$cleanupIntegrations->runBeforeDelete($subscriber);
|
||||
$subscriber->delete();
|
||||
|
||||
return redirect()
|
||||
|
||||
@@ -13,38 +13,10 @@ use RuntimeException;
|
||||
|
||||
class WeeztixApiController extends Controller
|
||||
{
|
||||
public function companies(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'page_id' => ['required', 'integer', 'exists:preregistration_pages,id'],
|
||||
]);
|
||||
|
||||
$page = PreregistrationPage::query()->findOrFail($request->integer('page_id'));
|
||||
$this->authorize('update', $page);
|
||||
|
||||
$config = $page->weeztixConfig;
|
||||
if ($config === null || ! $config->is_connected) {
|
||||
return response()->json([
|
||||
'message' => __('Niet verbonden met Weeztix.'),
|
||||
], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$companies = (new WeeztixService($config))->getCompanies();
|
||||
|
||||
return response()->json(['companies' => $companies]);
|
||||
} catch (RuntimeException) {
|
||||
return response()->json([
|
||||
'message' => __('Kon bedrijven niet laden. Vernieuw de verbinding indien nodig.'),
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
|
||||
public function coupons(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'page_id' => ['required', 'integer', 'exists:preregistration_pages,id'],
|
||||
'company_guid' => ['required', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
$page = PreregistrationPage::query()->findOrFail($request->integer('page_id'));
|
||||
@@ -57,9 +29,12 @@ class WeeztixApiController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
$companyGuid = $request->string('company_guid')->toString();
|
||||
$previousGuid = $config->company_guid;
|
||||
$config->setAttribute('company_guid', $companyGuid);
|
||||
$companyGuid = $config->company_guid;
|
||||
if (! is_string($companyGuid) || $companyGuid === '') {
|
||||
return response()->json([
|
||||
'message' => __('Geen Weeztix-bedrijf gekoppeld. Verbind opnieuw met Weeztix.'),
|
||||
], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$raw = (new WeeztixService($config))->getCoupons();
|
||||
@@ -70,8 +45,6 @@ class WeeztixApiController extends Controller
|
||||
return response()->json([
|
||||
'message' => __('Kon kortingsbonnen niet laden.'),
|
||||
], 422);
|
||||
} finally {
|
||||
$config->setAttribute('company_guid', $previousGuid);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +68,9 @@ class WeeztixApiController extends Controller
|
||||
if (! is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
if (! $this->couponRowHasEnabledStatus($row)) {
|
||||
continue;
|
||||
}
|
||||
$guid = data_get($row, 'guid') ?? data_get($row, 'id');
|
||||
if (! is_string($guid) || $guid === '') {
|
||||
continue;
|
||||
@@ -108,4 +84,16 @@ class WeeztixApiController extends Controller
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Weeztix coupon list items expose a string status; only "enabled" should appear in the admin picker.
|
||||
*
|
||||
* @param array<string, mixed> $row
|
||||
*/
|
||||
private function couponRowHasEnabledStatus(array $row): bool
|
||||
{
|
||||
$status = data_get($row, 'status');
|
||||
|
||||
return is_string($status) && strcasecmp(trim($status), 'enabled') === 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,18 +8,34 @@ use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\UpdateWeeztixConfigRequest;
|
||||
use App\Models\PreregistrationPage;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class WeeztixController extends Controller
|
||||
{
|
||||
public function edit(PreregistrationPage $page): View
|
||||
public function edit(Request $request, PreregistrationPage $page): View
|
||||
{
|
||||
$this->authorize('update', $page);
|
||||
|
||||
$page->load('weeztixConfig');
|
||||
|
||||
return view('admin.weeztix.edit', compact('page'));
|
||||
$showWizard = $page->weeztixConfig === null || $request->boolean('wizard');
|
||||
$wizardStep = $showWizard ? min(3, max(1, (int) $request->query('step', 1))) : 1;
|
||||
if ($showWizard && $page->weeztixConfig === null && $wizardStep !== 1) {
|
||||
return redirect()
|
||||
->route('admin.pages.weeztix.edit', ['page' => $page, 'wizard' => 1, 'step' => 1]);
|
||||
}
|
||||
$hasStoredCredentials = $page->weeztixConfig !== null
|
||||
&& is_string($page->weeztixConfig->client_id)
|
||||
&& $page->weeztixConfig->client_id !== '';
|
||||
|
||||
return view('admin.weeztix.edit', compact(
|
||||
'page',
|
||||
'showWizard',
|
||||
'wizardStep',
|
||||
'hasStoredCredentials'
|
||||
));
|
||||
}
|
||||
|
||||
public function update(UpdateWeeztixConfigRequest $request, PreregistrationPage $page): RedirectResponse
|
||||
@@ -41,8 +57,26 @@ class WeeztixController extends Controller
|
||||
);
|
||||
});
|
||||
|
||||
$page->load('weeztixConfig');
|
||||
|
||||
if ($request->boolean('wizard')) {
|
||||
if ($request->boolean('wizard_coupon_save')) {
|
||||
return redirect()
|
||||
->route('admin.pages.weeztix.edit', ['page' => $page])
|
||||
->with('status', __('Weeztix-configuratie opgeslagen.'));
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('admin.pages.weeztix.edit', [
|
||||
'page' => $page,
|
||||
'wizard' => 1,
|
||||
'step' => 2,
|
||||
])
|
||||
->with('status', __('Weeztix-configuratie opgeslagen.'));
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('admin.pages.weeztix.edit', $page)
|
||||
->route('admin.pages.weeztix.edit', ['page' => $page])
|
||||
->with('status', __('Weeztix-configuratie opgeslagen.'));
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ use RuntimeException;
|
||||
|
||||
class WeeztixOAuthController extends Controller
|
||||
{
|
||||
public function redirect(PreregistrationPage $page): RedirectResponse
|
||||
public function redirect(Request $request, PreregistrationPage $page): RedirectResponse
|
||||
{
|
||||
$this->authorize('update', $page);
|
||||
|
||||
@@ -38,6 +38,7 @@ class WeeztixOAuthController extends Controller
|
||||
session([
|
||||
'weeztix_oauth_state' => $state,
|
||||
'weeztix_page_id' => $page->id,
|
||||
'weeztix_oauth_resume_wizard' => $request->boolean('wizard'),
|
||||
]);
|
||||
|
||||
$redirectUri = $config->redirect_uri;
|
||||
@@ -90,7 +91,7 @@ class WeeztixOAuthController extends Controller
|
||||
|
||||
$config = $page->weeztixConfig;
|
||||
if ($config === null) {
|
||||
session()->forget(['weeztix_oauth_state', 'weeztix_page_id']);
|
||||
$this->forgetOauthSession();
|
||||
|
||||
return redirect()
|
||||
->route('admin.pages.weeztix.edit', $page)
|
||||
@@ -100,36 +101,62 @@ class WeeztixOAuthController extends Controller
|
||||
try {
|
||||
$service = new WeeztixService($config);
|
||||
$service->exchangeAuthorizationCode($request->string('code')->toString());
|
||||
$config = $config->fresh();
|
||||
if ($config !== null) {
|
||||
(new WeeztixService($config))->ensureCompanyStoredFromWeeztix();
|
||||
}
|
||||
} catch (RuntimeException $e) {
|
||||
Log::error('Weeztix OAuth callback failed', [
|
||||
'page_id' => $page->id,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
session()->forget(['weeztix_oauth_state', 'weeztix_page_id']);
|
||||
$resumeWizard = $this->forgetOauthSession();
|
||||
|
||||
return redirect()
|
||||
->route('admin.pages.weeztix.edit', $page)
|
||||
->route('admin.pages.weeztix.edit', $this->weeztixEditParams($page, $resumeWizard, 2))
|
||||
->with('error', __('Verbinden met Weeztix is mislukt. Controleer je gegevens en probeer opnieuw.'));
|
||||
}
|
||||
|
||||
session()->forget(['weeztix_oauth_state', 'weeztix_page_id']);
|
||||
$resumeWizard = $this->forgetOauthSession();
|
||||
|
||||
return redirect()
|
||||
->route('admin.pages.weeztix.edit', $page)
|
||||
->route('admin.pages.weeztix.edit', $this->weeztixEditParams($page, $resumeWizard, 3))
|
||||
->with('status', __('Succesvol verbonden met Weeztix.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{page: PreregistrationPage, wizard?: int, step?: int}
|
||||
*/
|
||||
private function weeztixEditParams(PreregistrationPage $page, bool $resumeWizard, int $step): array
|
||||
{
|
||||
$params = ['page' => $page];
|
||||
if ($resumeWizard) {
|
||||
$params['wizard'] = 1;
|
||||
$params['step'] = $step;
|
||||
}
|
||||
|
||||
return $params;
|
||||
}
|
||||
|
||||
private function forgetOauthSession(): bool
|
||||
{
|
||||
$resumeWizard = (bool) session()->pull('weeztix_oauth_resume_wizard', false);
|
||||
session()->forget(['weeztix_oauth_state', 'weeztix_page_id']);
|
||||
|
||||
return $resumeWizard;
|
||||
}
|
||||
|
||||
private function redirectToWeeztixEditWithSessionPage(string $message): RedirectResponse
|
||||
{
|
||||
$pageId = session('weeztix_page_id');
|
||||
session()->forget(['weeztix_oauth_state', 'weeztix_page_id']);
|
||||
$resumeWizard = $this->forgetOauthSession();
|
||||
|
||||
if (is_int($pageId) || is_numeric($pageId)) {
|
||||
$page = PreregistrationPage::query()->find((int) $pageId);
|
||||
if ($page !== null) {
|
||||
return redirect()
|
||||
->route('admin.pages.weeztix.edit', $page)
|
||||
->route('admin.pages.weeztix.edit', $this->weeztixEditParams($page, $resumeWizard, 2))
|
||||
->with('error', $message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,22 +4,20 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Exceptions\WeeztixCouponCodeConflictException;
|
||||
use App\Http\Requests\SubscribePublicPageRequest;
|
||||
use App\Jobs\SyncSubscriberToMailwizz;
|
||||
use App\Models\PageBlock;
|
||||
use App\Models\PreregistrationPage;
|
||||
use App\Models\Subscriber;
|
||||
use App\Models\WeeztixConfig;
|
||||
use App\Services\WeeztixService;
|
||||
use App\Services\RegisterSubscriberOnPage;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\View\View;
|
||||
use Throwable;
|
||||
|
||||
class PublicPageController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly RegisterSubscriberOnPage $registerSubscriberOnPage
|
||||
) {}
|
||||
|
||||
public function show(PreregistrationPage $publicPage): View
|
||||
{
|
||||
$publicPage->load(['blocks' => fn ($q) => $q->orderBy('sort_order')]);
|
||||
@@ -56,72 +54,11 @@ class PublicPageController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
$subscriber = $publicPage->subscribers()->create($validated);
|
||||
|
||||
$publicPage->loadMissing('weeztixConfig');
|
||||
$weeztix = $publicPage->weeztixConfig;
|
||||
if ($this->weeztixCanIssueCodes($weeztix)) {
|
||||
$this->tryAttachWeeztixCouponCode($subscriber, $weeztix);
|
||||
}
|
||||
|
||||
if ($publicPage->mailwizzConfig !== null) {
|
||||
SyncSubscriberToMailwizz::dispatch($subscriber->fresh());
|
||||
}
|
||||
$this->registerSubscriberOnPage->storeAndQueueIntegrations($publicPage, $validated);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => $publicPage->thank_you_message ?? __('Thank you for registering!'),
|
||||
'coupon_code' => $subscriber->fresh()?->coupon_code,
|
||||
]);
|
||||
}
|
||||
|
||||
private function weeztixCanIssueCodes(?WeeztixConfig $config): bool
|
||||
{
|
||||
if ($config === null || ! $config->is_connected) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$company = $config->company_guid;
|
||||
$coupon = $config->coupon_guid;
|
||||
|
||||
return is_string($company) && $company !== '' && is_string($coupon) && $coupon !== '';
|
||||
}
|
||||
|
||||
private function tryAttachWeeztixCouponCode(Subscriber $subscriber, WeeztixConfig $config): void
|
||||
{
|
||||
$freshConfig = $config->fresh();
|
||||
if ($freshConfig === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$service = new WeeztixService($freshConfig);
|
||||
$maxAttempts = 5;
|
||||
|
||||
for ($attempt = 0; $attempt < $maxAttempts; $attempt++) {
|
||||
try {
|
||||
$code = WeeztixService::generateUniqueCode(
|
||||
is_string($freshConfig->code_prefix) && $freshConfig->code_prefix !== ''
|
||||
? $freshConfig->code_prefix
|
||||
: 'PREREG'
|
||||
);
|
||||
$service->createCouponCode($code);
|
||||
$subscriber->update(['coupon_code' => $code]);
|
||||
|
||||
return;
|
||||
} catch (WeeztixCouponCodeConflictException) {
|
||||
continue;
|
||||
} catch (Throwable $e) {
|
||||
Log::error('Weeztix coupon creation failed', [
|
||||
'subscriber_id' => $subscriber->id,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Log::warning('Weeztix coupon: exhausted duplicate retries', [
|
||||
'subscriber_id' => $subscriber->id,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -44,8 +44,6 @@ class UpdateWeeztixConfigRequest extends FormRequest
|
||||
'string',
|
||||
'max:2048',
|
||||
],
|
||||
'company_guid' => ['nullable', 'string', 'max:255'],
|
||||
'company_name' => ['nullable', 'string', 'max:255'],
|
||||
'coupon_guid' => ['nullable', 'string', 'max:255'],
|
||||
'coupon_name' => ['nullable', 'string', 'max:255'],
|
||||
'code_prefix' => ['nullable', 'string', 'max:32'],
|
||||
|
||||
175
app/Jobs/IssueWeeztixCouponForSubscriber.php
Normal file
175
app/Jobs/IssueWeeztixCouponForSubscriber.php
Normal file
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Exceptions\WeeztixCouponCodeConflictException;
|
||||
use App\Models\Subscriber;
|
||||
use App\Models\WeeztixConfig;
|
||||
use App\Services\WeeztixService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeUnique;
|
||||
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Creates the Weeztix coupon code after the subscriber row exists, then queues Mailwizz sync
|
||||
* so the external APIs never block the public HTTP response and Mailwizz runs after coupon_code is set when possible.
|
||||
*/
|
||||
final class IssueWeeztixCouponForSubscriber implements ShouldBeUnique, ShouldQueueAfterCommit
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
/**
|
||||
* @var list<int>
|
||||
*/
|
||||
public array $backoff = [5, 15, 45];
|
||||
|
||||
/**
|
||||
* Seconds before the unique lock expires if the worker dies before releasing it.
|
||||
*/
|
||||
public int $uniqueFor = 300;
|
||||
|
||||
public function __construct(public Subscriber $subscriber)
|
||||
{
|
||||
$this->onQueue('weeztix');
|
||||
}
|
||||
|
||||
public function uniqueId(): string
|
||||
{
|
||||
return 'weeztix-coupon-subscriber-'.$this->subscriber->getKey();
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
try {
|
||||
$subscriber = Subscriber::query()
|
||||
->with(['preregistrationPage.weeztixConfig', 'preregistrationPage.mailwizzConfig'])
|
||||
->find($this->subscriber->id);
|
||||
|
||||
if ($subscriber === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$page = $subscriber->preregistrationPage;
|
||||
if ($page === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$config = $page->weeztixConfig;
|
||||
$couponMissing = ! is_string($subscriber->coupon_code) || $subscriber->coupon_code === '';
|
||||
|
||||
if ($couponMissing && $this->weeztixCanIssueCodes($config)) {
|
||||
$this->tryAttachWeeztixCouponCode($subscriber, $config);
|
||||
}
|
||||
|
||||
$this->dispatchMailwizzIfNeeded($subscriber);
|
||||
} catch (Throwable $e) {
|
||||
Log::error('IssueWeeztixCouponForSubscriber: handle failed', [
|
||||
'subscriber_id' => $this->subscriber->id,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
$subscriber = Subscriber::query()
|
||||
->with(['preregistrationPage.mailwizzConfig'])
|
||||
->find($this->subscriber->id);
|
||||
|
||||
if ($subscriber !== null) {
|
||||
$this->dispatchMailwizzIfNeeded($subscriber);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function failed(?Throwable $exception): void
|
||||
{
|
||||
Log::error('IssueWeeztixCouponForSubscriber failed', [
|
||||
'subscriber_id' => $this->subscriber->id,
|
||||
'message' => $exception?->getMessage(),
|
||||
]);
|
||||
|
||||
$subscriber = Subscriber::query()
|
||||
->with('preregistrationPage.mailwizzConfig')
|
||||
->find($this->subscriber->id);
|
||||
|
||||
if ($subscriber !== null) {
|
||||
$this->dispatchMailwizzIfNeeded($subscriber);
|
||||
}
|
||||
}
|
||||
|
||||
private function dispatchMailwizzIfNeeded(Subscriber $subscriber): void
|
||||
{
|
||||
$page = $subscriber->preregistrationPage;
|
||||
$page?->loadMissing('mailwizzConfig');
|
||||
|
||||
if ($page?->mailwizzConfig !== null) {
|
||||
try {
|
||||
SyncSubscriberToMailwizz::dispatch($subscriber->fresh());
|
||||
} catch (Throwable $e) {
|
||||
Log::error('IssueWeeztixCouponForSubscriber: could not queue Mailwizz sync', [
|
||||
'subscriber_id' => $subscriber->id,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function weeztixCanIssueCodes(?WeeztixConfig $config): bool
|
||||
{
|
||||
if ($config === null || ! $config->is_connected) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$company = $config->company_guid;
|
||||
$coupon = $config->coupon_guid;
|
||||
|
||||
return is_string($company) && $company !== '' && is_string($coupon) && $coupon !== '';
|
||||
}
|
||||
|
||||
private function tryAttachWeeztixCouponCode(Subscriber $subscriber, WeeztixConfig $config): void
|
||||
{
|
||||
$freshConfig = $config->fresh();
|
||||
if ($freshConfig === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$service = new WeeztixService($freshConfig);
|
||||
$maxAttempts = 5;
|
||||
|
||||
for ($attempt = 0; $attempt < $maxAttempts; $attempt++) {
|
||||
try {
|
||||
$code = WeeztixService::generateUniqueCode(
|
||||
is_string($freshConfig->code_prefix) && $freshConfig->code_prefix !== ''
|
||||
? $freshConfig->code_prefix
|
||||
: 'PREREG'
|
||||
);
|
||||
$service->createCouponCode($code);
|
||||
$subscriber->update(['coupon_code' => $code]);
|
||||
|
||||
return;
|
||||
} catch (WeeztixCouponCodeConflictException) {
|
||||
continue;
|
||||
} catch (Throwable $e) {
|
||||
Log::error('Weeztix coupon creation failed', [
|
||||
'subscriber_id' => $subscriber->id,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Log::warning('Weeztix coupon: exhausted duplicate retries', [
|
||||
'subscriber_id' => $subscriber->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -6,23 +6,23 @@ namespace App\Jobs;
|
||||
|
||||
use App\Models\MailwizzConfig;
|
||||
use App\Models\Subscriber;
|
||||
use App\Services\MailwizzCheckboxlistTags;
|
||||
use App\Services\MailwizzService;
|
||||
use App\Services\MailwizzSubscriberFormPayload;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeUnique;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueue
|
||||
class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueueAfterCommit
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
/**
|
||||
* Seconds before the unique lock expires if the worker dies before releasing it.
|
||||
@@ -36,21 +36,52 @@ class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueue
|
||||
*/
|
||||
public array $backoff = [10, 30, 60];
|
||||
|
||||
public function __construct(public Subscriber $subscriber)
|
||||
/**
|
||||
* Set in the constructor for new jobs. Remains null when an old queue payload (pre–subscriber-id refactor) is unserialized.
|
||||
*/
|
||||
public ?int $subscriberId = null;
|
||||
|
||||
/**
|
||||
* @param Subscriber|int $subscriber Model is accepted when dispatching; only the id is serialized for the queue.
|
||||
*/
|
||||
public function __construct(Subscriber|int $subscriber)
|
||||
{
|
||||
$this->subscriberId = $subscriber instanceof Subscriber
|
||||
? (int) $subscriber->getKey()
|
||||
: $subscriber;
|
||||
$this->onQueue('mailwizz');
|
||||
}
|
||||
|
||||
public function uniqueId(): string
|
||||
{
|
||||
return (string) $this->subscriber->getKey();
|
||||
return $this->subscriberId !== null
|
||||
? (string) $this->subscriberId
|
||||
: 'stale-mailwizz-sync-payload';
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
if ($this->subscriberId === null) {
|
||||
Log::notice('SyncSubscriberToMailwizz: skipped job with missing subscriber id (stale queue payload). Clear the queue or re-dispatch sync jobs.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->runSync();
|
||||
} catch (Throwable $e) {
|
||||
Log::error('SyncSubscriberToMailwizz: integration failed; subscriber remains local (use admin resync if needed)', [
|
||||
'subscriber_id' => $this->subscriberId,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function runSync(): void
|
||||
{
|
||||
$subscriber = Subscriber::query()
|
||||
->with(['preregistrationPage.mailwizzConfig'])
|
||||
->find($this->subscriber->id);
|
||||
->find($this->subscriberId);
|
||||
|
||||
if ($subscriber === null) {
|
||||
return;
|
||||
@@ -101,7 +132,7 @@ class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueue
|
||||
public function failed(?Throwable $exception): void
|
||||
{
|
||||
Log::error('SyncSubscriberToMailwizz failed', [
|
||||
'subscriber_id' => $this->subscriber->id,
|
||||
'subscriber_id' => $this->subscriberId,
|
||||
'message' => $exception?->getMessage(),
|
||||
]);
|
||||
}
|
||||
@@ -119,29 +150,6 @@ class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueue
|
||||
return true;
|
||||
}
|
||||
|
||||
private function buildBasePayload(Subscriber $subscriber, MailwizzConfig $config, bool $phoneEnabled): array
|
||||
{
|
||||
$data = [
|
||||
$config->field_email => $subscriber->email,
|
||||
$config->field_first_name => $subscriber->first_name,
|
||||
$config->field_last_name => $subscriber->last_name,
|
||||
];
|
||||
|
||||
if ($phoneEnabled && $config->field_phone !== null && $config->field_phone !== '') {
|
||||
$phone = $subscriber->phoneDisplay();
|
||||
if ($phone !== null && $phone !== '') {
|
||||
$data[$config->field_phone] = $phone;
|
||||
}
|
||||
}
|
||||
|
||||
$couponField = $config->field_coupon_code;
|
||||
if (is_string($couponField) && $couponField !== '' && $subscriber->coupon_code !== null && $subscriber->coupon_code !== '') {
|
||||
$data[$couponField] = $subscriber->coupon_code;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function createInMailwizz(
|
||||
MailwizzService $service,
|
||||
Subscriber $subscriber,
|
||||
@@ -149,7 +157,7 @@ class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueue
|
||||
string $listUid
|
||||
): void {
|
||||
$page = $subscriber->preregistrationPage;
|
||||
$data = $this->buildBasePayload($subscriber, $config, $page->isPhoneFieldEnabledForSubscribers());
|
||||
$data = MailwizzSubscriberFormPayload::baseFields($subscriber, $config, $page->isPhoneFieldEnabledForSubscribers());
|
||||
$tagField = $config->tag_field;
|
||||
$tagValue = $config->tag_value;
|
||||
if ($tagField !== null && $tagField !== '' && $tagValue !== null && $tagValue !== '') {
|
||||
@@ -167,7 +175,7 @@ class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueue
|
||||
string $subscriberUid
|
||||
): void {
|
||||
$page = $subscriber->preregistrationPage;
|
||||
$data = $this->buildBasePayload($subscriber, $config, $page->isPhoneFieldEnabledForSubscribers());
|
||||
$data = MailwizzSubscriberFormPayload::baseFields($subscriber, $config, $page->isPhoneFieldEnabledForSubscribers());
|
||||
|
||||
$tagField = $config->tag_field;
|
||||
$tagValue = $config->tag_value;
|
||||
@@ -176,46 +184,11 @@ class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueue
|
||||
if ($full === null) {
|
||||
throw new RuntimeException('Mailwizz getSubscriber returned an empty payload.');
|
||||
}
|
||||
$existingCsv = $this->extractTagCsvFromResponse($full, $tagField);
|
||||
$merged = $this->mergeCheckboxlistTags($existingCsv, $tagValue);
|
||||
$existingCsv = MailwizzCheckboxlistTags::extractCsvFromSubscriberResponse($full, $tagField);
|
||||
$merged = MailwizzCheckboxlistTags::mergeValueIntoCsv($existingCsv, $tagValue);
|
||||
$data[$tagField] = $merged;
|
||||
}
|
||||
|
||||
$service->updateSubscriber($listUid, $subscriberUid, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $apiResponse
|
||||
*/
|
||||
private function extractTagCsvFromResponse(array $apiResponse, string $tagField): string
|
||||
{
|
||||
$record = data_get($apiResponse, 'data.record');
|
||||
if (! is_array($record)) {
|
||||
$record = data_get($apiResponse, 'data');
|
||||
}
|
||||
if (! is_array($record)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$raw = $record[$tagField] ?? data_get($record, "fields.{$tagField}");
|
||||
|
||||
if (is_array($raw)) {
|
||||
return implode(',', array_map(static fn (mixed $v): string => is_scalar($v) ? (string) $v : '', $raw));
|
||||
}
|
||||
|
||||
return is_string($raw) ? $raw : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function mergeCheckboxlistTags(string $existingCsv, string $newValue): array
|
||||
{
|
||||
$parts = array_filter(array_map('trim', explode(',', $existingCsv)), static fn (string $s): bool => $s !== '');
|
||||
if (! in_array($newValue, $parts, true)) {
|
||||
$parts[] = $newValue;
|
||||
}
|
||||
|
||||
return array_values($parts);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,4 +204,56 @@ class PreregistrationPage extends Model
|
||||
|
||||
return 'active';
|
||||
}
|
||||
|
||||
/**
|
||||
* Mailwizz setup depth for admin UI (API key + list + email field = ready to sync).
|
||||
*
|
||||
* @return 'none'|'partial'|'ready'
|
||||
*/
|
||||
public function mailwizzIntegrationStatus(): string
|
||||
{
|
||||
$c = $this->mailwizzConfig;
|
||||
if ($c === null) {
|
||||
return 'none';
|
||||
}
|
||||
|
||||
$key = $c->api_key;
|
||||
if (! is_string($key) || $key === '') {
|
||||
return 'partial';
|
||||
}
|
||||
|
||||
if (! is_string($c->list_uid) || $c->list_uid === '' || ! is_string($c->field_email) || $c->field_email === '') {
|
||||
return 'partial';
|
||||
}
|
||||
|
||||
return 'ready';
|
||||
}
|
||||
|
||||
/**
|
||||
* Weeztix setup depth for admin UI.
|
||||
*
|
||||
* @return 'none'|'credentials'|'connected'|'ready'
|
||||
*/
|
||||
public function weeztixIntegrationStatus(): string
|
||||
{
|
||||
$c = $this->weeztixConfig;
|
||||
if ($c === null) {
|
||||
return 'none';
|
||||
}
|
||||
|
||||
$hasClient = is_string($c->client_id) && $c->client_id !== '';
|
||||
if (! $hasClient) {
|
||||
return 'none';
|
||||
}
|
||||
|
||||
if (! $c->is_connected) {
|
||||
return 'credentials';
|
||||
}
|
||||
|
||||
if (! is_string($c->coupon_guid) || $c->coupon_guid === '') {
|
||||
return 'connected';
|
||||
}
|
||||
|
||||
return 'ready';
|
||||
}
|
||||
}
|
||||
|
||||
145
app/Services/CleanupSubscriberIntegrationsService.php
Normal file
145
app/Services/CleanupSubscriberIntegrationsService.php
Normal file
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\MailwizzConfig;
|
||||
use App\Models\PreregistrationPage;
|
||||
use App\Models\Subscriber;
|
||||
use App\Models\WeeztixConfig;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Before a subscriber row is deleted, best-effort cleanup in Weeztix (coupon code) and Mailwizz (strip source tag, clear coupon field).
|
||||
* Failures are logged only; local delete must still proceed.
|
||||
*/
|
||||
final class CleanupSubscriberIntegrationsService
|
||||
{
|
||||
public function runBeforeDelete(Subscriber $subscriber): void
|
||||
{
|
||||
$subscriber->loadMissing(['preregistrationPage.mailwizzConfig', 'preregistrationPage.weeztixConfig']);
|
||||
$page = $subscriber->preregistrationPage;
|
||||
if ($page === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->cleanupWeeztixIfApplicable($subscriber, $page);
|
||||
} catch (Throwable $e) {
|
||||
Log::error('CleanupSubscriberIntegrations: Weeztix cleanup failed', [
|
||||
'subscriber_id' => $subscriber->id,
|
||||
'preregistration_page_id' => $page->id,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->cleanupMailwizzIfApplicable($subscriber, $page);
|
||||
} catch (Throwable $e) {
|
||||
Log::error('CleanupSubscriberIntegrations: Mailwizz cleanup failed', [
|
||||
'subscriber_id' => $subscriber->id,
|
||||
'preregistration_page_id' => $page->id,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function cleanupWeeztixIfApplicable(Subscriber $subscriber, PreregistrationPage $page): void
|
||||
{
|
||||
$config = $page->weeztixConfig;
|
||||
if ($config === null || ! $config->is_connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
$code = $subscriber->coupon_code;
|
||||
if (! is_string($code) || trim($code) === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $this->weeztixCanManageCodes($config)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$fresh = $config->fresh();
|
||||
if ($fresh === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
(new WeeztixService($fresh))->deleteCouponCodeByCodeString($code);
|
||||
}
|
||||
|
||||
private function weeztixCanManageCodes(WeeztixConfig $config): bool
|
||||
{
|
||||
$company = $config->company_guid;
|
||||
$coupon = $config->coupon_guid;
|
||||
|
||||
return is_string($company) && $company !== '' && is_string($coupon) && $coupon !== '';
|
||||
}
|
||||
|
||||
private function cleanupMailwizzIfApplicable(Subscriber $subscriber, PreregistrationPage $page): void
|
||||
{
|
||||
$config = $page->mailwizzConfig;
|
||||
if ($config === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $this->mailwizzConfigAllowsUpdate($config)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$apiKey = $config->api_key;
|
||||
if (! is_string($apiKey) || $apiKey === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$service = new MailwizzService($apiKey);
|
||||
$listUid = $config->list_uid;
|
||||
|
||||
$search = $service->searchSubscriber($listUid, $subscriber->email);
|
||||
if ($search === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$subscriberUid = $search['subscriber_uid'];
|
||||
/** @var array<string, mixed> $data */
|
||||
$data = [];
|
||||
|
||||
$couponField = $config->field_coupon_code;
|
||||
if (is_string($couponField) && $couponField !== '') {
|
||||
$data[$couponField] = '';
|
||||
}
|
||||
|
||||
$tagField = $config->tag_field;
|
||||
$tagValue = $config->tag_value;
|
||||
if ($tagField !== null && $tagField !== '' && $tagValue !== null && $tagValue !== '') {
|
||||
$full = $service->getSubscriber($listUid, $subscriberUid);
|
||||
if ($full === null) {
|
||||
throw new RuntimeException('Mailwizz getSubscriber returned an empty payload.');
|
||||
}
|
||||
$existingCsv = MailwizzCheckboxlistTags::extractCsvFromSubscriberResponse($full, $tagField);
|
||||
$data[$tagField] = MailwizzCheckboxlistTags::removeValueFromCsv($existingCsv, $tagValue);
|
||||
}
|
||||
|
||||
if ($data === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$service->updateSubscriber($listUid, $subscriberUid, $data);
|
||||
}
|
||||
|
||||
private function mailwizzConfigAllowsUpdate(MailwizzConfig $config): bool
|
||||
{
|
||||
if ($config->list_uid === '' || $config->field_email === '' || $config->field_first_name === '' || $config->field_last_name === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$hasTagWork = $config->tag_field !== null && $config->tag_field !== ''
|
||||
&& $config->tag_value !== null && $config->tag_value !== '';
|
||||
$hasCouponField = is_string($config->field_coupon_code) && $config->field_coupon_code !== '';
|
||||
|
||||
return $hasTagWork || $hasCouponField;
|
||||
}
|
||||
}
|
||||
64
app/Services/MailwizzCheckboxlistTags.php
Normal file
64
app/Services/MailwizzCheckboxlistTags.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
/**
|
||||
* Parses and mutates Mailwizz checkboxlist-style tag fields (comma-separated in API, array on write).
|
||||
*/
|
||||
final class MailwizzCheckboxlistTags
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $apiResponse getSubscriber JSON payload
|
||||
*/
|
||||
public static function extractCsvFromSubscriberResponse(array $apiResponse, string $tagField): string
|
||||
{
|
||||
$record = data_get($apiResponse, 'data.record');
|
||||
if (! is_array($record)) {
|
||||
$record = data_get($apiResponse, 'data');
|
||||
}
|
||||
if (! is_array($record)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$raw = $record[$tagField] ?? data_get($record, "fields.{$tagField}");
|
||||
|
||||
if (is_array($raw)) {
|
||||
return implode(',', array_map(static fn (mixed $v): string => is_scalar($v) ? (string) $v : '', $raw));
|
||||
}
|
||||
|
||||
return is_string($raw) ? $raw : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function mergeValueIntoCsv(string $existingCsv, string $newValue): array
|
||||
{
|
||||
$parts = array_filter(array_map('trim', explode(',', $existingCsv)), static fn (string $s): bool => $s !== '');
|
||||
if (! in_array($newValue, $parts, true)) {
|
||||
$parts[] = $newValue;
|
||||
}
|
||||
|
||||
return array_values($parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function removeValueFromCsv(string $existingCsv, string $valueToRemove): array
|
||||
{
|
||||
$needle = trim($valueToRemove);
|
||||
$parts = array_filter(array_map('trim', explode(',', $existingCsv)), static fn (string $s): bool => $s !== '');
|
||||
|
||||
if ($needle === '') {
|
||||
return array_values($parts);
|
||||
}
|
||||
|
||||
return array_values(array_filter(
|
||||
$parts,
|
||||
static fn (string $s): bool => $s !== $needle
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -202,7 +202,8 @@ final class MailwizzService
|
||||
$out = [];
|
||||
foreach ($data as $key => $value) {
|
||||
if (is_array($value)) {
|
||||
$out[$key] = $value;
|
||||
// Empty arrays are omitted by Laravel's multipart encoder (no KEY[] parts), so Mailwizz never clears checkboxlist fields.
|
||||
$out[$key] = $value === [] ? '' : $value;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
40
app/Services/MailwizzSubscriberFormPayload.php
Normal file
40
app/Services/MailwizzSubscriberFormPayload.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\MailwizzConfig;
|
||||
use App\Models\Subscriber;
|
||||
|
||||
/**
|
||||
* Shared Mailwizz list subscriber field payload for create/update (excluding tag merge logic).
|
||||
*/
|
||||
final class MailwizzSubscriberFormPayload
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function baseFields(Subscriber $subscriber, MailwizzConfig $config, bool $phoneEnabled): array
|
||||
{
|
||||
$data = [
|
||||
$config->field_email => $subscriber->email,
|
||||
$config->field_first_name => $subscriber->first_name,
|
||||
$config->field_last_name => $subscriber->last_name,
|
||||
];
|
||||
|
||||
if ($phoneEnabled && $config->field_phone !== null && $config->field_phone !== '') {
|
||||
$phone = $subscriber->phoneDisplay();
|
||||
if ($phone !== null && $phone !== '') {
|
||||
$data[$config->field_phone] = $phone;
|
||||
}
|
||||
}
|
||||
|
||||
$couponField = $config->field_coupon_code;
|
||||
if (is_string($couponField) && $couponField !== '' && $subscriber->coupon_code !== null && $subscriber->coupon_code !== '') {
|
||||
$data[$couponField] = $subscriber->coupon_code;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
59
app/Services/RegisterSubscriberOnPage.php
Normal file
59
app/Services/RegisterSubscriberOnPage.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Jobs\IssueWeeztixCouponForSubscriber;
|
||||
use App\Jobs\SyncSubscriberToMailwizz;
|
||||
use App\Models\PreregistrationPage;
|
||||
use App\Models\Subscriber;
|
||||
use App\Models\WeeztixConfig;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Orchestrates public registration: local persist first, then queue external integrations
|
||||
* so Weeztix/Mailwizz failures never prevent a subscriber row from being stored.
|
||||
*/
|
||||
final class RegisterSubscriberOnPage
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $validated
|
||||
*/
|
||||
public function storeAndQueueIntegrations(PreregistrationPage $page, array $validated): Subscriber
|
||||
{
|
||||
$subscriber = $page->subscribers()->create($validated);
|
||||
|
||||
$page->loadMissing('weeztixConfig', 'mailwizzConfig');
|
||||
$weeztix = $page->weeztixConfig;
|
||||
|
||||
try {
|
||||
if ($this->weeztixCanIssueCodes($weeztix)) {
|
||||
IssueWeeztixCouponForSubscriber::dispatch($subscriber)->afterResponse();
|
||||
} elseif ($page->mailwizzConfig !== null) {
|
||||
SyncSubscriberToMailwizz::dispatch($subscriber->fresh())->afterResponse();
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
Log::error('RegisterSubscriberOnPage: could not queue integration jobs', [
|
||||
'subscriber_id' => $subscriber->id,
|
||||
'preregistration_page_id' => $page->id,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
return $subscriber;
|
||||
}
|
||||
|
||||
private function weeztixCanIssueCodes(?WeeztixConfig $config): bool
|
||||
{
|
||||
if ($config === null || ! $config->is_connected) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$company = $config->company_guid;
|
||||
$coupon = $config->coupon_guid;
|
||||
|
||||
return is_string($company) && $company !== '' && is_string($coupon) && $coupon !== '';
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ use Illuminate\Support\Facades\Log;
|
||||
use InvalidArgumentException;
|
||||
use LogicException;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
final class WeeztixService
|
||||
{
|
||||
@@ -84,6 +85,39 @@ final class WeeztixService
|
||||
return $this->normalizeCompaniesFromProfile($json);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync company_guid/name from the Weeztix user profile for the current access token (after OAuth).
|
||||
* Runs on every successful connect or reconnect so a different company chosen in Weeztix is stored here.
|
||||
* Uses the first company returned from the profile when several are present.
|
||||
*/
|
||||
public function ensureCompanyStoredFromWeeztix(): void
|
||||
{
|
||||
$this->config->refresh();
|
||||
|
||||
try {
|
||||
$companies = $this->getCompanies();
|
||||
if ($companies === []) {
|
||||
Log::warning('Weeztix: geen bedrijf uit profiel voor automatische koppeling.', [
|
||||
'weeztix_config_id' => $this->config->id,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$row = $companies[0];
|
||||
$this->config->update([
|
||||
'company_guid' => $row['guid'],
|
||||
'company_name' => $row['name'],
|
||||
]);
|
||||
$this->config->refresh();
|
||||
} catch (Throwable $e) {
|
||||
Log::warning('Weeztix: automatisch bedrijf vastleggen mislukt', [
|
||||
'weeztix_config_id' => $this->config->id,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange OAuth authorization code for tokens (admin callback).
|
||||
*/
|
||||
@@ -216,6 +250,159 @@ final class WeeztixService
|
||||
throw new RuntimeException('Weeztix API rate limited after retries.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists coupon codes for the coupon selected in config (GET /coupon/{guid}/codes).
|
||||
*
|
||||
* @return list<array{guid: string, code: string}>
|
||||
*/
|
||||
public function listCouponCodesForConfiguredCoupon(): array
|
||||
{
|
||||
$this->assertCompanyGuid();
|
||||
$couponGuid = $this->config->coupon_guid;
|
||||
if (! is_string($couponGuid) || $couponGuid === '') {
|
||||
throw new LogicException('Weeztix coupon is not configured.');
|
||||
}
|
||||
|
||||
$url = config('weeztix.api_base_url').'/coupon/'.$couponGuid.'/codes';
|
||||
$json = $this->apiRequest('get', $url, []);
|
||||
|
||||
return $this->normalizeCouponCodeListResponse($json);
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-deletes a coupon code in Weeztix by matching the human-readable code string.
|
||||
*/
|
||||
public function deleteCouponCodeByCodeString(string $code): void
|
||||
{
|
||||
$trimmed = trim($code);
|
||||
if ($trimmed === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->assertCompanyGuid();
|
||||
$couponGuid = $this->config->coupon_guid;
|
||||
if (! is_string($couponGuid) || $couponGuid === '') {
|
||||
throw new LogicException('Weeztix coupon is not configured.');
|
||||
}
|
||||
|
||||
$rows = $this->listCouponCodesForConfiguredCoupon();
|
||||
$codeGuid = null;
|
||||
foreach ($rows as $row) {
|
||||
if (strcasecmp($row['code'], $trimmed) === 0) {
|
||||
$codeGuid = $row['guid'];
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($codeGuid === null) {
|
||||
Log::info('Weeztix: coupon code not found when deleting (already removed or unknown)', [
|
||||
'code' => $trimmed,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$url = config('weeztix.api_base_url').'/coupon/'.$couponGuid.'/codes/'.$codeGuid;
|
||||
$token = $this->getValidAccessToken();
|
||||
$response = $this->sendApiRequest('delete', $url, [], $token);
|
||||
|
||||
if ($response->status() === 401) {
|
||||
$this->refreshAccessToken();
|
||||
$this->config->refresh();
|
||||
$response = $this->sendApiRequest('delete', $url, [], (string) $this->config->access_token);
|
||||
}
|
||||
|
||||
if ($response->status() === 404) {
|
||||
Log::info('Weeztix: coupon code already deleted remotely', [
|
||||
'code' => $trimmed,
|
||||
'code_guid' => $codeGuid,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($response->failed()) {
|
||||
$this->logFailedResponse('deleteCouponCodeByCodeString', $url, $response);
|
||||
|
||||
throw new RuntimeException('Weeztix API delete coupon code failed: '.$response->status());
|
||||
}
|
||||
|
||||
Log::debug('Weeztix API', [
|
||||
'action' => 'deleteCouponCodeByCodeString',
|
||||
'url' => $url,
|
||||
'http_status' => $response->status(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $json
|
||||
* @return list<array{guid: string, code: string}>
|
||||
*/
|
||||
private function normalizeCouponCodeListResponse(array $json): array
|
||||
{
|
||||
$candidates = [
|
||||
data_get($json, 'data'),
|
||||
data_get($json, 'data.codes'),
|
||||
data_get($json, 'data.records'),
|
||||
data_get($json, 'codes'),
|
||||
$json,
|
||||
];
|
||||
|
||||
foreach ($candidates as $raw) {
|
||||
if (! is_array($raw)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->isListArray($raw)) {
|
||||
$normalized = $this->normalizeCouponCodeRows($raw);
|
||||
if ($normalized !== []) {
|
||||
return $normalized;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $rows
|
||||
* @return list<array{guid: string, code: string}>
|
||||
*/
|
||||
private function normalizeCouponCodeRows(array $rows): array
|
||||
{
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
if (! is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$guid = data_get($row, 'guid')
|
||||
?? data_get($row, 'id')
|
||||
?? data_get($row, 'coupon_code_guid');
|
||||
$code = data_get($row, 'code')
|
||||
?? data_get($row, 'coupon_code')
|
||||
?? data_get($row, 'name');
|
||||
if (! is_string($guid) || $guid === '' || ! is_string($code) || $code === '') {
|
||||
continue;
|
||||
}
|
||||
$out[] = ['guid' => $guid, 'code' => $code];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $arr
|
||||
*/
|
||||
private function isListArray(array $arr): bool
|
||||
{
|
||||
if ($arr === []) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return array_keys($arr) === range(0, count($arr) - 1);
|
||||
}
|
||||
|
||||
public static function generateUniqueCode(string $prefix = 'PREREG', int $length = 6): string
|
||||
{
|
||||
$chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
|
||||
@@ -269,6 +269,12 @@ Laravel ships with a `public/.htaccess` that works with Apache. Verify `mod_rewr
|
||||
|
||||
### 4.7 Set up cron for queue worker + scheduler
|
||||
|
||||
Public registration saves the subscriber in the database first, then queues **Weeztix** (coupon code) and **Mailwizz** sync jobs. Those jobs must be processed by a worker.
|
||||
|
||||
- **Visitor-facing behaviour:** the public subscribe endpoint returns **HTTP 200 with `success: true`** as soon as the subscriber row is stored. Failures in Mailwizz or Weeztix are **logged** (and visible via failed jobs when using a real queue); they do **not** change the JSON shown to the visitor. Use logs and admin resync to diagnose integration issues.
|
||||
- **Production:** set `QUEUE_CONNECTION=database` (or `redis`) so retries and `queue:failed` work as intended. `sync` is acceptable for small installs but runs integration work in-process; still, the visitor should not see 5xx from a broken Mailwizz/Weeztix API after subscribe.
|
||||
- **Queues:** coupon jobs use `weeztix`; Mailwizz uses `mailwizz`. The worker should listen to both (order below prioritises `weeztix` so coupon creation tends to run before sync when both are pending).
|
||||
|
||||
In DirectAdmin → Cron Jobs, add:
|
||||
|
||||
```
|
||||
@@ -276,9 +282,11 @@ In DirectAdmin → Cron Jobs, add:
|
||||
* * * * * cd /home/username/preregister && php artisan schedule:run >> /dev/null 2>&1
|
||||
|
||||
# Queue worker - process one job per run (every minute)
|
||||
* * * * * cd /home/username/preregister && php artisan queue:work --once --queue=mailwizz >> /dev/null 2>&1
|
||||
* * * * * cd /home/username/preregister && php artisan queue:work --once --queue=weeztix,mailwizz,default >> /dev/null 2>&1
|
||||
```
|
||||
|
||||
For higher throughput, use a persistent supervisor-managed worker instead of `--once` in cron; keep the same `--queue=weeztix,mailwizz,default` order.
|
||||
|
||||
### 4.8 Directory permissions
|
||||
|
||||
```bash
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"Sending…": "Bezig met verzenden…",
|
||||
"Something went wrong. Please try again.": "Er ging iets mis. Probeer het opnieuw.",
|
||||
"This pre-registration period has ended.": "Deze preregistratieperiode is afgelopen.",
|
||||
"You will be redirected in :seconds s…": "Je wordt over :seconds seconden doorgestuurd…",
|
||||
"Visit ticket shop": "Ga naar de ticketshop",
|
||||
"Thank you for registering!": "Bedankt voor je registratie!",
|
||||
"You are already registered for this event.": "Je bent al geregistreerd voor dit evenement.",
|
||||
|
||||
@@ -420,7 +420,6 @@ document.addEventListener('alpine:init', () => {
|
||||
redirectSecondsLeft: null,
|
||||
redirectTimer: null,
|
||||
strings: config.strings || {},
|
||||
couponCode: '',
|
||||
|
||||
copyPageLink() {
|
||||
const url = this.pageShareUrl;
|
||||
@@ -435,19 +434,6 @@ document.addEventListener('alpine:init', () => {
|
||||
});
|
||||
},
|
||||
|
||||
copyCouponCode() {
|
||||
const code = this.couponCode;
|
||||
if (!code) {
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(code).then(() => {
|
||||
this.copyFeedback = this.strings?.couponCopied || '';
|
||||
setTimeout(() => {
|
||||
this.copyFeedback = '';
|
||||
}, 2500);
|
||||
});
|
||||
},
|
||||
|
||||
init() {
|
||||
if (this.phase === 'before') {
|
||||
this.tickCountdown();
|
||||
@@ -566,17 +552,24 @@ document.addEventListener('alpine:init', () => {
|
||||
if (res.ok && data.success) {
|
||||
this.phase = 'thanks';
|
||||
this.thankYouMessage = data.message ?? '';
|
||||
this.couponCode =
|
||||
typeof data.coupon_code === 'string' && data.coupon_code !== '' ? data.coupon_code : '';
|
||||
this.startRedirectCountdownIfNeeded();
|
||||
return;
|
||||
}
|
||||
if (typeof data.message === 'string' && data.message !== '') {
|
||||
const hasServerMessage = typeof data.message === 'string' && data.message !== '';
|
||||
const hasFieldErrors =
|
||||
data.errors !== undefined &&
|
||||
data.errors !== null &&
|
||||
typeof data.errors === 'object' &&
|
||||
Object.keys(data.errors).length > 0;
|
||||
if (hasServerMessage) {
|
||||
this.formError = data.message;
|
||||
}
|
||||
if (data.errors !== undefined && data.errors !== null && typeof data.errors === 'object') {
|
||||
if (hasFieldErrors) {
|
||||
this.fieldErrors = data.errors;
|
||||
}
|
||||
if (!res.ok && !hasServerMessage && !hasFieldErrors) {
|
||||
this.formError = this.genericError;
|
||||
}
|
||||
} catch {
|
||||
this.formError = this.genericError;
|
||||
} finally {
|
||||
@@ -769,20 +762,17 @@ document.addEventListener('alpine:init', () => {
|
||||
|
||||
Alpine.data('weeztixSetup', (cfg) => ({
|
||||
pageId: cfg.pageId,
|
||||
companiesUrl: cfg.companiesUrl,
|
||||
couponsUrl: cfg.couponsUrl,
|
||||
csrf: cfg.csrf,
|
||||
isConnected: cfg.isConnected === true,
|
||||
callbackUrl: cfg.callbackUrl,
|
||||
errorMessage: '',
|
||||
companies: [],
|
||||
coupons: [],
|
||||
companyGuid: '',
|
||||
companyName: '',
|
||||
couponGuid: '',
|
||||
couponName: '',
|
||||
codePrefix: 'PREREG',
|
||||
usageCount: 1,
|
||||
couponsRefreshing: false,
|
||||
strings: cfg.strings || {},
|
||||
|
||||
async init() {
|
||||
@@ -797,19 +787,12 @@ document.addEventListener('alpine:init', () => {
|
||||
} else {
|
||||
this.usageCount = 1;
|
||||
}
|
||||
this.companyGuid = cfg.existing.company_guid || '';
|
||||
this.companyName = cfg.existing.company_name || '';
|
||||
this.couponGuid = cfg.existing.coupon_guid || '';
|
||||
this.couponName = cfg.existing.coupon_name || '';
|
||||
}
|
||||
if (this.isConnected) {
|
||||
await this.loadCompanies();
|
||||
if (this.companyGuid) {
|
||||
await this.loadCouponsForGuid(this.companyGuid);
|
||||
}
|
||||
} else if (cfg.existing && (cfg.existing.company_guid || cfg.existing.coupon_guid)) {
|
||||
// Show saved choices even when not connected (e.g. expired refresh); lists are from DB only.
|
||||
this.ensureSelectedCompanyInList();
|
||||
await this.loadCoupons();
|
||||
} else if (cfg.existing && cfg.existing.coupon_guid) {
|
||||
this.ensureSelectedCouponInList();
|
||||
}
|
||||
},
|
||||
@@ -829,18 +812,6 @@ document.addEventListener('alpine:init', () => {
|
||||
return { res, data };
|
||||
},
|
||||
|
||||
syncCompanyNameFromSelection() {
|
||||
if (!this.companyGuid) {
|
||||
this.companyName = '';
|
||||
return;
|
||||
}
|
||||
const c = this.companies.find((x) => x.guid === this.companyGuid);
|
||||
if (c && typeof c.name === 'string' && c.name.trim() !== '') {
|
||||
this.companyName = c.name.trim();
|
||||
}
|
||||
// If API row has no name or list is still loading, keep companyName from server (DB).
|
||||
},
|
||||
|
||||
syncCouponName() {
|
||||
if (!this.couponGuid) {
|
||||
this.couponName = '';
|
||||
@@ -852,18 +823,6 @@ document.addEventListener('alpine:init', () => {
|
||||
}
|
||||
},
|
||||
|
||||
ensureSelectedCompanyInList() {
|
||||
const guid = this.companyGuid;
|
||||
if (!guid || this.companies.some((x) => x.guid === guid)) {
|
||||
return;
|
||||
}
|
||||
const label =
|
||||
typeof this.companyName === 'string' && this.companyName.trim() !== ''
|
||||
? this.companyName.trim()
|
||||
: guid;
|
||||
this.companies = [{ guid, name: label }, ...this.companies];
|
||||
},
|
||||
|
||||
ensureSelectedCouponInList() {
|
||||
const guid = this.couponGuid;
|
||||
if (!guid || this.coupons.some((x) => x.guid === guid)) {
|
||||
@@ -876,36 +835,9 @@ document.addEventListener('alpine:init', () => {
|
||||
this.coupons = [{ guid, name: label }, ...this.coupons];
|
||||
},
|
||||
|
||||
async loadCompanies() {
|
||||
async loadCoupons() {
|
||||
this.errorMessage = '';
|
||||
const { res, data } = await this.postJson(this.companiesUrl, { page_id: this.pageId });
|
||||
if (!res.ok) {
|
||||
this.errorMessage = data.message || this.strings.genericError;
|
||||
this.ensureSelectedCompanyInList();
|
||||
return;
|
||||
}
|
||||
this.companies = Array.isArray(data.companies) ? data.companies : [];
|
||||
this.ensureSelectedCompanyInList();
|
||||
this.syncCompanyNameFromSelection();
|
||||
},
|
||||
|
||||
async onCompanyChange() {
|
||||
this.syncCompanyNameFromSelection();
|
||||
this.couponGuid = '';
|
||||
this.couponName = '';
|
||||
this.coupons = [];
|
||||
if (!this.companyGuid) {
|
||||
return;
|
||||
}
|
||||
await this.loadCouponsForGuid(this.companyGuid);
|
||||
},
|
||||
|
||||
async loadCouponsForGuid(guid) {
|
||||
this.errorMessage = '';
|
||||
const { res, data } = await this.postJson(this.couponsUrl, {
|
||||
page_id: this.pageId,
|
||||
company_guid: guid,
|
||||
});
|
||||
const { res, data } = await this.postJson(this.couponsUrl, { page_id: this.pageId });
|
||||
if (!res.ok) {
|
||||
this.errorMessage = data.message || this.strings.loadCouponsError;
|
||||
this.ensureSelectedCouponInList();
|
||||
@@ -916,35 +848,16 @@ document.addEventListener('alpine:init', () => {
|
||||
this.syncCouponName();
|
||||
},
|
||||
|
||||
/** Prefer human-readable label; skip API "names" that are just the GUID / UUID. */
|
||||
companyLabel(c) {
|
||||
if (!c || typeof c.guid !== 'string') {
|
||||
return '';
|
||||
async refreshCoupons() {
|
||||
if (!this.isConnected) {
|
||||
return;
|
||||
}
|
||||
const g = c.guid;
|
||||
const isBadLabel = (s) => {
|
||||
const t = typeof s === 'string' ? s.trim() : '';
|
||||
return (
|
||||
t === '' ||
|
||||
t.toLowerCase() === g.toLowerCase() ||
|
||||
this.stringLooksLikeUuid(t)
|
||||
);
|
||||
};
|
||||
const fromApi = typeof c.name === 'string' ? c.name : '';
|
||||
if (!isBadLabel(fromApi)) {
|
||||
return fromApi.trim();
|
||||
this.couponsRefreshing = true;
|
||||
try {
|
||||
await this.loadCoupons();
|
||||
} finally {
|
||||
this.couponsRefreshing = false;
|
||||
}
|
||||
if (this.companyGuid === g && !isBadLabel(this.companyName)) {
|
||||
return String(this.companyName).trim();
|
||||
}
|
||||
|
||||
return g;
|
||||
},
|
||||
|
||||
stringLooksLikeUuid(s) {
|
||||
return /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/.test(
|
||||
String(s),
|
||||
);
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
$config = $page->mailwizzConfig;
|
||||
$page->loadMissing('weeztixConfig');
|
||||
$hasWeeztixForCouponMap = $page->weeztixConfig !== null && $page->weeztixConfig->is_connected;
|
||||
$mailwizzStatus = $page->mailwizzIntegrationStatus();
|
||||
$existing = $config !== null
|
||||
? [
|
||||
'list_uid' => $config->list_uid,
|
||||
@@ -24,30 +25,15 @@
|
||||
@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' => $page->isPhoneFieldEnabledForSubscribers(),
|
||||
'hasExistingConfig' => $config !== null,
|
||||
'hasWeeztixIntegration' => $hasWeeztixForCouponMap,
|
||||
'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.'),
|
||||
'tagFieldError' => __('Select a checkbox list field for source / tag tracking.'),
|
||||
'tagValueError' => __('Select the tag option that identifies this pre-registration.'),
|
||||
],
|
||||
]))">
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<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>
|
||||
|
||||
@include('admin.pages._save_flash')
|
||||
|
||||
@if ($errors->any())
|
||||
<div class="mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-900" role="alert">
|
||||
<p class="font-medium">{{ __('Please fix the following:') }}</p>
|
||||
@@ -59,219 +45,347 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@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"
|
||||
@if (! $showWizard && $config !== null)
|
||||
@if ($mailwizzStatus !== 'ready')
|
||||
<div class="mb-6 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950">
|
||||
<p class="font-medium">{{ __('Setup incomplete') }}</p>
|
||||
<p class="mt-1 text-amber-900">{{ __('Run the wizard again to finish Mailwizz (API key, list, and field mapping).') }}</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="mb-6 flex flex-wrap items-center gap-3">
|
||||
<a
|
||||
href="{{ route('admin.pages.mailwizz.edit', ['page' => $page, 'wizard' => 1, 'step' => 1]) }}"
|
||||
class="inline-flex rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500"
|
||||
>
|
||||
{{ __('Change settings (wizard)') }}
|
||||
</a>
|
||||
<form action="{{ route('admin.pages.mailwizz.destroy', $page) }}" method="post" class="inline"
|
||||
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 type="submit" class="rounded-lg border border-red-200 bg-white px-4 py-2.5 text-sm font-semibold text-red-700 shadow-sm hover:bg-red-50">
|
||||
{{ __('Disconnect Mailwizz') }}
|
||||
</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">
|
||||
<h2 class="text-lg font-semibold text-slate-900">{{ __('Current configuration') }}</h2>
|
||||
<p class="mt-1 text-sm text-slate-600">{{ __('The API key is stored encrypted and is not shown here.') }}</p>
|
||||
|
||||
<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>
|
||||
<dl class="mt-6 space-y-4 border-t border-slate-100 pt-6 text-sm">
|
||||
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
|
||||
<dt class="font-medium text-slate-700">{{ __('Connection') }}</dt>
|
||||
<dd>
|
||||
@if ($mailwizzStatus === 'ready')
|
||||
<span class="inline-flex rounded-full bg-emerald-100 px-2.5 py-0.5 text-xs font-medium text-emerald-800">{{ __('Ready to sync') }}</span>
|
||||
@else
|
||||
<span class="inline-flex rounded-full bg-amber-100 px-2.5 py-0.5 text-xs font-medium text-amber-900">{{ __('Incomplete') }}</span>
|
||||
@endif
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
|
||||
<dt class="font-medium text-slate-700">{{ __('Mailing list') }}</dt>
|
||||
<dd class="text-slate-800">{{ $config->list_name ?: '—' }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
|
||||
<dt class="font-medium text-slate-700">{{ __('List UID') }}</dt>
|
||||
<dd class="break-all font-mono text-xs text-slate-600">{{ $config->list_uid ?: '—' }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{{-- 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>
|
||||
<h3 class="mt-8 border-t border-slate-100 pt-6 text-sm font-semibold text-slate-900">{{ __('Field mapping') }}</h3>
|
||||
<p class="mt-1 text-xs text-slate-500">{{ __('Mailwizz custom fields are matched by tag.') }}</p>
|
||||
<dl class="mt-4 space-y-4 text-sm">
|
||||
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
|
||||
<dt class="font-medium text-slate-700">{{ __('Email') }}</dt>
|
||||
<dd class="font-mono text-xs text-slate-800">{{ $config->field_email ?: '—' }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
|
||||
<dt class="font-medium text-slate-700">{{ __('First name') }}</dt>
|
||||
<dd class="font-mono text-xs text-slate-800">{{ $config->field_first_name ?: '—' }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
|
||||
<dt class="font-medium text-slate-700">{{ __('Last name') }}</dt>
|
||||
<dd class="font-mono text-xs text-slate-800">{{ $config->field_last_name ?: '—' }}</dd>
|
||||
</div>
|
||||
@if ($page->isPhoneFieldEnabledForSubscribers())
|
||||
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
|
||||
<dt class="font-medium text-slate-700">{{ __('Phone') }}</dt>
|
||||
<dd class="font-mono text-xs text-slate-800">{{ filled($config->field_phone) ? $config->field_phone : '—' }}</dd>
|
||||
</div>
|
||||
@endif
|
||||
@if ($hasWeeztixForCouponMap)
|
||||
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
|
||||
<dt class="font-medium text-slate-700">{{ __('Kortingscode (Weeztix)') }}</dt>
|
||||
<dd class="font-mono text-xs text-slate-800">{{ filled($config->field_coupon_code) ? $config->field_coupon_code : '—' }}</dd>
|
||||
</div>
|
||||
@endif
|
||||
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
|
||||
<dt class="font-medium text-slate-700">{{ __('Tag / source (checkbox list)') }}</dt>
|
||||
<dd class="font-mono text-xs text-slate-800">{{ filled($config->tag_field) ? $config->tag_field : '—' }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
|
||||
<dt class="font-medium text-slate-700">{{ __('Source tag option') }}</dt>
|
||||
<dd class="font-mono text-xs text-slate-800">{{ filled($config->tag_value) ? $config->tag_value : '—' }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
@else
|
||||
<div
|
||||
x-data="mailwizzWizard(@js([
|
||||
'listsUrl' => route('admin.mailwizz.lists'),
|
||||
'fieldsUrl' => route('admin.mailwizz.fields'),
|
||||
'csrf' => csrf_token(),
|
||||
'phoneEnabled' => $page->isPhoneFieldEnabledForSubscribers(),
|
||||
'hasExistingConfig' => $config !== null,
|
||||
'hasWeeztixIntegration' => $hasWeeztixForCouponMap,
|
||||
'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.'),
|
||||
'tagFieldError' => __('Select a checkbox list field for source / tag tracking.'),
|
||||
'tagValueError' => __('Select the tag option that identifies this pre-registration.'),
|
||||
],
|
||||
]))"
|
||||
>
|
||||
@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>
|
||||
<div class="mb-6 flex flex-wrap items-center gap-3">
|
||||
<a href="{{ route('admin.pages.mailwizz.edit', $page) }}" class="text-sm font-medium text-slate-600 hover:text-slate-900">
|
||||
{{ __('Cancel and return to overview') }}
|
||||
</a>
|
||||
</div>
|
||||
@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 class="mb-8 flex flex-wrap items-center gap-2" aria-label="{{ __('Wizard steps') }}">
|
||||
<span
|
||||
class="inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-medium"
|
||||
:class="step === 1 ? 'border-indigo-500 bg-indigo-50 text-indigo-900' : (step > 1 ? 'border-emerald-200 bg-emerald-50 text-emerald-900' : 'border-slate-200 bg-slate-50 text-slate-500')"
|
||||
>
|
||||
</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"
|
||||
<span class="tabular-nums">1</span>
|
||||
{{ __('API key') }}
|
||||
</span>
|
||||
<span class="text-slate-300" aria-hidden="true">→</span>
|
||||
<span
|
||||
class="inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-medium"
|
||||
:class="step === 2 ? 'border-indigo-500 bg-indigo-50 text-indigo-900' : (step > 2 ? 'border-emerald-200 bg-emerald-50 text-emerald-900' : 'border-slate-200 bg-slate-50 text-slate-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 class="tabular-nums">2</span>
|
||||
{{ __('List') }}
|
||||
</span>
|
||||
<span class="text-slate-300" aria-hidden="true">→</span>
|
||||
<span
|
||||
class="inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-medium"
|
||||
:class="step === 3 ? 'border-indigo-500 bg-indigo-50 text-indigo-900' : (step > 3 ? 'border-emerald-200 bg-emerald-50 text-emerald-900' : 'border-slate-200 bg-slate-50 text-slate-500')"
|
||||
>
|
||||
<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') }} <span class="font-normal text-slate-500">({{ __('optional') }})</span></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 x-show="hasWeeztixIntegration">
|
||||
<label class="block text-sm font-medium text-slate-700">{{ __('Kortingscode (Weeztix)') }} <span class="font-normal text-slate-500">({{ __('optional') }})</span></label>
|
||||
<p class="mt-1 text-xs text-slate-500">{{ __('Koppel aan een tekstveld in Mailwizz om de persoonlijke code in e-mails te tonen.') }}</p>
|
||||
<select x-model="fieldCouponCode" 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="'cp-' + 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"
|
||||
<span class="tabular-nums">3</span>
|
||||
{{ __('Field mapping') }}
|
||||
</span>
|
||||
<span class="text-slate-300" aria-hidden="true">→</span>
|
||||
<span
|
||||
class="inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-medium"
|
||||
:class="step === 4 ? 'border-indigo-500 bg-indigo-50 text-indigo-900' : 'border-slate-200 bg-slate-50 text-slate-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>
|
||||
<span class="tabular-nums">4</span>
|
||||
{{ __('Tag / source') }}
|
||||
</span>
|
||||
</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>
|
||||
<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 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="field_coupon_code" x-bind:value="hasWeeztixIntegration ? fieldCouponCode : ''">
|
||||
<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') }}
|
||||
{{-- Step 1 --}}
|
||||
<div x-show="step === 1" x-cloak class="space-y-4">
|
||||
<h2 class="text-lg font-semibold text-slate-900">{{ __('Step 1: API key') }}</h2>
|
||||
<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>
|
||||
</form>
|
||||
|
||||
{{-- Step 2 --}}
|
||||
<div x-show="step === 2" x-cloak class="space-y-4">
|
||||
<h2 class="text-lg font-semibold text-slate-900">{{ __('Step 2: Mailing list') }}</h2>
|
||||
<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">
|
||||
<h2 class="text-lg font-semibold text-slate-900">{{ __('Step 3: Field mapping') }}</h2>
|
||||
<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') }} <span class="font-normal text-slate-500">({{ __('optional') }})</span></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 x-show="hasWeeztixIntegration">
|
||||
<label class="block text-sm font-medium text-slate-700">{{ __('Kortingscode (Weeztix)') }} <span class="font-normal text-slate-500">({{ __('optional') }})</span></label>
|
||||
<p class="mt-1 text-xs text-slate-500">{{ __('Koppel aan een tekstveld in Mailwizz om de persoonlijke code in e-mails te tonen.') }}</p>
|
||||
<select x-model="fieldCouponCode" 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="'cp-' + 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">
|
||||
<h2 class="text-lg font-semibold text-slate-900">{{ __('Step 4: Tag / source') }}</h2>
|
||||
<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="field_coupon_code" x-bind:value="hasWeeztixIntegration ? fieldCouponCode : ''">
|
||||
<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>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
55
resources/views/admin/pages/_integration_badges.blade.php
Normal file
55
resources/views/admin/pages/_integration_badges.blade.php
Normal file
@@ -0,0 +1,55 @@
|
||||
@php
|
||||
$only = $only ?? null;
|
||||
$integrationBadgeClass = $integrationBadgeClass ?? '';
|
||||
if (! in_array($only, [null, 'mailwizz', 'weeztix'], true)) {
|
||||
$only = null;
|
||||
}
|
||||
|
||||
$mailwizz = $page->mailwizzIntegrationStatus();
|
||||
$weeztix = $page->weeztixIntegrationStatus();
|
||||
|
||||
$mailwizzClasses = match ($mailwizz) {
|
||||
'ready' => 'border-emerald-200 bg-emerald-50 text-emerald-900',
|
||||
'partial' => 'border-amber-200 bg-amber-50 text-amber-950',
|
||||
default => 'border-slate-200 bg-slate-50 text-slate-600',
|
||||
};
|
||||
$mailwizzLabel = match ($mailwizz) {
|
||||
'ready' => __('Ready'),
|
||||
'partial' => __('Incomplete'),
|
||||
default => __('Off'),
|
||||
};
|
||||
|
||||
$weeztixClasses = match ($weeztix) {
|
||||
'ready' => 'border-emerald-200 bg-emerald-50 text-emerald-900',
|
||||
'connected' => 'border-sky-200 bg-sky-50 text-sky-950',
|
||||
'credentials' => 'border-amber-200 bg-amber-50 text-amber-950',
|
||||
default => 'border-slate-200 bg-slate-50 text-slate-600',
|
||||
};
|
||||
$weeztixLabel = match ($weeztix) {
|
||||
'ready' => __('Ready'),
|
||||
'connected' => __('Connected'),
|
||||
'credentials' => __('OAuth only'),
|
||||
default => __('Off'),
|
||||
};
|
||||
|
||||
$showMailwizz = $only === null || $only === 'mailwizz';
|
||||
$showWeeztix = $only === null || $only === 'weeztix';
|
||||
@endphp
|
||||
<div class="flex flex-wrap items-center gap-1.5 {{ $integrationBadgeClass }}">
|
||||
@if ($showMailwizz)
|
||||
<span
|
||||
class="inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium {{ $mailwizzClasses }}"
|
||||
title="{{ __('Mailwizz: :state', ['state' => $mailwizzLabel]) }}"
|
||||
>
|
||||
{{ __('MW') }} · {{ $mailwizzLabel }}
|
||||
</span>
|
||||
@endif
|
||||
@if ($showWeeztix)
|
||||
<span
|
||||
class="inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium {{ $weeztixClasses }}"
|
||||
title="{{ __('Weeztix: :state', ['state' => $weeztixLabel]) }}"
|
||||
>
|
||||
{{ __('WZ') }} · {{ $weeztixLabel }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
@@ -13,10 +13,30 @@
|
||||
{{ __('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 flex flex-wrap gap-x-4 gap-y-1">
|
||||
<a href="{{ route('admin.pages.mailwizz.edit', $page) }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-500">{{ __('Mailwizz integration') }} →</a>
|
||||
<a href="{{ route('admin.pages.weeztix.edit', $page) }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-500">{{ __('Weeztix integration') }} →</a>
|
||||
</p>
|
||||
<div class="mt-6 grid gap-4 sm:grid-cols-2">
|
||||
<a
|
||||
href="{{ route('admin.pages.mailwizz.edit', $page) }}"
|
||||
class="group flex flex-col rounded-xl border border-slate-200 bg-white p-4 shadow-sm transition hover:border-indigo-200 hover:shadow-md"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<h2 class="text-sm font-semibold text-slate-900">{{ __('Mailwizz') }}</h2>
|
||||
@include('admin.pages._integration_badges', ['page' => $page, 'only' => 'mailwizz', 'integrationBadgeClass' => 'justify-end'])
|
||||
</div>
|
||||
<p class="mt-2 flex-1 text-xs text-slate-600">{{ __('Sync subscribers to your Mailwizz list and map fields.') }}</p>
|
||||
<span class="mt-3 text-sm font-medium text-indigo-600 group-hover:text-indigo-500">{{ __('Open Mailwizz') }} →</span>
|
||||
</a>
|
||||
<a
|
||||
href="{{ route('admin.pages.weeztix.edit', $page) }}"
|
||||
class="group flex flex-col rounded-xl border border-slate-200 bg-white p-4 shadow-sm transition hover:border-indigo-200 hover:shadow-md"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<h2 class="text-sm font-semibold text-slate-900">{{ __('Weeztix') }}</h2>
|
||||
@include('admin.pages._integration_badges', ['page' => $page, 'only' => 'weeztix', 'integrationBadgeClass' => 'justify-end'])
|
||||
</div>
|
||||
<p class="mt-2 flex-1 text-xs text-slate-600">{{ __('Issue unique discount codes via Weeztix when visitors sign up.') }}</p>
|
||||
<span class="mt-3 text-sm font-medium text-indigo-600 group-hover:text-indigo-500">{{ __('Open Weeztix') }} →</span>
|
||||
</a>
|
||||
</div>
|
||||
@endcan
|
||||
</div>
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
<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 font-semibold text-slate-700">{{ __('Integrations') }}</th>
|
||||
<th scope="col" class="px-4 py-3 text-right font-semibold text-slate-700">{{ __('Actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -64,6 +65,9 @@
|
||||
<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="px-4 py-3">
|
||||
@include('admin.pages._integration_badges', ['page' => $page])
|
||||
</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)
|
||||
@@ -77,9 +81,6 @@
|
||||
@endcan
|
||||
@can('update', $page)
|
||||
<a href="{{ route('admin.pages.weeztix.edit', $page) }}" class="text-indigo-600 hover:text-indigo-500">{{ __('Weeztix') }}</a>
|
||||
@if ($page->weeztixConfig?->is_connected)
|
||||
<span class="text-xs font-medium text-emerald-600" title="{{ __('Weeztix verbonden') }}">●</span>
|
||||
@endif
|
||||
@endcan
|
||||
<button
|
||||
type="button"
|
||||
@@ -103,7 +104,7 @@
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="{{ auth()->user()->isSuperadmin() ? 7 : 6 }}" class="px-4 py-12 text-center text-slate-500">
|
||||
<td colspan="{{ auth()->user()->isSuperadmin() ? 8 : 7 }}" 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>
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
$wz = $page->weeztixConfig;
|
||||
$existing = $wz !== null
|
||||
? [
|
||||
'company_guid' => $wz->company_guid,
|
||||
'company_name' => $wz->company_name,
|
||||
'coupon_guid' => $wz->coupon_guid,
|
||||
'coupon_name' => $wz->coupon_name,
|
||||
'code_prefix' => $wz->code_prefix,
|
||||
'usage_count' => $wz->usage_count,
|
||||
]
|
||||
: null;
|
||||
$credentialsEdit = ! $hasStoredCredentials || request()->query('credentials') === 'edit';
|
||||
$oauthUrl = route('admin.pages.weeztix.oauth.redirect', ['page' => $page, 'wizard' => 1]);
|
||||
@endphp
|
||||
|
||||
@extends('layouts.admin')
|
||||
@@ -20,24 +20,7 @@
|
||||
@section('mobile_title', __('Weeztix'))
|
||||
|
||||
@section('content')
|
||||
<div
|
||||
class="mx-auto max-w-3xl"
|
||||
x-data="weeztixSetup(@js([
|
||||
'pageId' => $page->id,
|
||||
'companiesUrl' => route('admin.weeztix.companies'),
|
||||
'couponsUrl' => route('admin.weeztix.coupons'),
|
||||
'csrf' => csrf_token(),
|
||||
'isConnected' => $wz?->is_connected ?? false,
|
||||
'tokenExpiresAt' => $wz?->token_expires_at instanceof Carbon ? $wz->token_expires_at->timezone(config('app.timezone'))->format('Y-m-d H:i') : null,
|
||||
'callbackUrl' => route('admin.weeztix.callback', absolute: true),
|
||||
'existing' => $existing,
|
||||
'strings' => [
|
||||
'genericError' => __('Er ging iets mis. Probeer het opnieuw.'),
|
||||
'selectCompany' => __('Selecteer een bedrijf.'),
|
||||
'loadCouponsError' => __('Kon kortingsbonnen niet laden.'),
|
||||
],
|
||||
]))"
|
||||
>
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<div class="mb-8">
|
||||
<a href="{{ route('admin.pages.edit', $page) }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-500">← {{ __('Terug naar pagina') }}</a>
|
||||
<h1 class="mt-4 text-2xl font-semibold text-slate-900">{{ __('Weeztix') }}</h1>
|
||||
@@ -57,178 +40,312 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($wz !== null && $wz->is_connected)
|
||||
<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">{{ __('Verbonden met Weeztix') }}</p>
|
||||
@if ($wz->token_expires_at)
|
||||
<p class="mt-1 text-emerald-800">
|
||||
{{ __('Huidig toegangstoken tot:') }}
|
||||
<span class="font-mono text-xs">{{ $wz->token_expires_at->timezone(config('app.timezone'))->format('Y-m-d H:i') }}</span>
|
||||
</p>
|
||||
<p class="mt-2 text-emerald-800/90">
|
||||
{{ __('Het toegangstoken wordt automatisch vernieuwd bij API-gebruik (o.a. kortingscodes), zolang de refresh-token geldig is. Je hoeft niet opnieuw te verbinden.') }}
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
@elseif ($wz !== null && ! $wz->is_connected)
|
||||
<div class="mb-8 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950">
|
||||
<p class="font-medium">{{ __('Niet verbonden') }}</p>
|
||||
<p class="mt-1 text-amber-900">{{ __('Je moet opnieuw verbinden om kortingscodes aan te maken. Gebruik de knop “Verbind met Weeztix”.') }}</p>
|
||||
</div>
|
||||
@endif
|
||||
@if (! $showWizard && $wz !== null)
|
||||
{{-- Summary (read-only): change only via wizard --}}
|
||||
@if ($wz->is_connected && ($wz->company_guid === null || $wz->company_guid === ''))
|
||||
<div class="mb-6 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950">
|
||||
<p class="font-medium">{{ __('Bedrijf nog niet vastgelegd') }}</p>
|
||||
<p class="mt-1 text-amber-900">{{ __('Start de wizard en verbind opnieuw met Weeztix zodat het bedrijf automatisch wordt gekoppeld.') }}</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($wz !== null)
|
||||
<form action="{{ route('admin.pages.weeztix.destroy', $page) }}" method="post" class="mb-8"
|
||||
onsubmit="return confirm(@js(__('Weeztix-integratie voor deze pagina verwijderen? Opgeslagen tokens worden gewist.')));">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="text-sm font-semibold text-red-700 underline hover:text-red-800">
|
||||
{{ __('Weeztix loskoppelen') }}
|
||||
</button>
|
||||
</form>
|
||||
@endif
|
||||
|
||||
<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>
|
||||
|
||||
<section class="space-y-4 border-b border-slate-100 pb-8">
|
||||
<h2 class="text-lg font-semibold text-slate-900">{{ __('Stap 1: OAuth-gegevens') }}</h2>
|
||||
<p class="text-sm leading-relaxed text-slate-600">
|
||||
{{ __('Maak eerst een OAuth-client in het Weeztix-dashboard en stel de redirect-URI exact in op:') }}
|
||||
</p>
|
||||
<p class="rounded-lg bg-slate-50 px-3 py-2 font-mono text-xs text-slate-800 break-all" x-text="callbackUrl"></p>
|
||||
<p class="text-sm text-slate-600">
|
||||
{{ __('Maak daarna een korting (coupon) in Weeztix; die kies je hierna in stap 2.') }}
|
||||
</p>
|
||||
|
||||
<form method="post" action="{{ route('admin.pages.weeztix.update', $page) }}" class="space-y-4">
|
||||
<div class="mb-6 flex flex-wrap items-center gap-3">
|
||||
<a
|
||||
href="{{ route('admin.pages.weeztix.edit', ['page' => $page, 'wizard' => 1, 'step' => 1]) }}"
|
||||
class="inline-flex rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500"
|
||||
>
|
||||
{{ __('Instellingen wijzigen (wizard)') }}
|
||||
</a>
|
||||
<form action="{{ route('admin.pages.weeztix.destroy', $page) }}" method="post" class="inline"
|
||||
onsubmit="return confirm(@js(__('Weeztix-integratie voor deze pagina verwijderen? Opgeslagen tokens worden gewist.')));">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
<div>
|
||||
<label for="weeztix_client_id" class="block text-sm font-medium text-slate-700">{{ __('Client ID') }}</label>
|
||||
<input
|
||||
id="weeztix_client_id"
|
||||
name="client_id"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
value="{{ old('client_id') }}"
|
||||
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="{{ $wz !== null ? __('Laat leeg om ongewijzigd te laten') : '' }}"
|
||||
@if ($wz === null) required @endif
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label for="weeztix_client_secret" class="block text-sm font-medium text-slate-700">{{ __('Client secret') }}</label>
|
||||
<input
|
||||
id="weeztix_client_secret"
|
||||
name="client_secret"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
value=""
|
||||
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="{{ $wz !== null ? __('Laat leeg om ongewijzigd te laten') : '' }}"
|
||||
@if ($wz === null) required @endif
|
||||
>
|
||||
</div>
|
||||
@if ($wz !== null)
|
||||
<p class="text-xs text-slate-500">
|
||||
{{ __('Client ID en secret zijn opgeslagen maar worden niet opnieuw getoond. Laat de velden leeg om ze te behouden; vul ze alleen in als je ze wilt wijzigen.') }}
|
||||
</p>
|
||||
@endif
|
||||
<button type="submit" class="rounded-lg bg-slate-800 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-slate-700">
|
||||
{{ __('Gegevens opslaan') }}
|
||||
@method('DELETE')
|
||||
<button type="submit" class="rounded-lg border border-red-200 bg-white px-4 py-2.5 text-sm font-semibold text-red-700 shadow-sm hover:bg-red-50">
|
||||
{{ __('Weeztix loskoppelen') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-6 shadow-sm sm:p-8">
|
||||
<h2 class="text-lg font-semibold text-slate-900">{{ __('Huidige configuratie') }}</h2>
|
||||
<p class="mt-1 text-sm text-slate-600">{{ __('OAuth-gegevens zijn opgeslagen maar worden om veiligheidsredenen niet getoond.') }}</p>
|
||||
|
||||
<dl class="mt-6 space-y-4 border-t border-slate-100 pt-6 text-sm">
|
||||
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
|
||||
<dt class="font-medium text-slate-700">{{ __('Verbinding') }}</dt>
|
||||
<dd>
|
||||
@if ($wz->is_connected)
|
||||
<span class="inline-flex rounded-full bg-emerald-100 px-2.5 py-0.5 text-xs font-medium text-emerald-800">{{ __('Verbonden') }}</span>
|
||||
@else
|
||||
<span class="inline-flex rounded-full bg-amber-100 px-2.5 py-0.5 text-xs font-medium text-amber-900">{{ __('Niet verbonden') }}</span>
|
||||
@endif
|
||||
</dd>
|
||||
</div>
|
||||
@if ($wz->is_connected && $wz->token_expires_at)
|
||||
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
|
||||
<dt class="font-medium text-slate-700">{{ __('Toegangstoken tot') }}</dt>
|
||||
<dd class="font-mono text-xs text-slate-800">{{ $wz->token_expires_at->timezone(config('app.timezone'))->format('Y-m-d H:i') }}</dd>
|
||||
</div>
|
||||
@endif
|
||||
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
|
||||
<dt class="font-medium text-slate-700">{{ __('Callback-URL (in Weeztix-dashboard)') }}</dt>
|
||||
<dd class="break-all font-mono text-xs text-slate-600">{{ route('admin.weeztix.callback', absolute: true) }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
|
||||
<dt class="font-medium text-slate-700">{{ __('Coupon') }}</dt>
|
||||
<dd class="text-slate-800">{{ $wz->coupon_name ?: ($wz->coupon_guid ? $wz->coupon_guid : '—') }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
|
||||
<dt class="font-medium text-slate-700">{{ __('Codevoorvoegsel') }}</dt>
|
||||
<dd class="font-mono text-slate-800">{{ $wz->code_prefix ?? '—' }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
|
||||
<dt class="font-medium text-slate-700">{{ __('Gebruik per code') }}</dt>
|
||||
<dd class="text-slate-800">{{ (int) ($wz->usage_count ?? 1) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
@else
|
||||
{{-- Wizard --}}
|
||||
<div class="mb-6 flex flex-wrap items-center justify-between gap-3">
|
||||
@if ($wz !== null)
|
||||
<div class="pt-2">
|
||||
<a
|
||||
href="{{ route('admin.pages.weeztix.oauth.redirect', $page) }}"
|
||||
class="inline-flex rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500"
|
||||
>
|
||||
{{ __('Verbind met Weeztix') }}
|
||||
</a>
|
||||
<a href="{{ route('admin.pages.weeztix.edit', ['page' => $page]) }}" class="text-sm font-medium text-slate-600 hover:text-slate-900">
|
||||
{{ __('Annuleren en terug naar overzicht') }}
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="mb-8 flex flex-wrap items-center gap-2" aria-label="{{ __('Wizardstappen') }}">
|
||||
@foreach ([1 => __('OAuth'), 2 => __('Verbinden'), 3 => __('Coupon')] as $num => $label)
|
||||
@php
|
||||
$active = $wizardStep === $num;
|
||||
$done = $wizardStep > $num;
|
||||
@endphp
|
||||
<span class="inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-medium
|
||||
{{ $active ? 'border-indigo-500 bg-indigo-50 text-indigo-900' : '' }}
|
||||
{{ $done ? 'border-emerald-200 bg-emerald-50 text-emerald-900' : '' }}
|
||||
{{ ! $active && ! $done ? 'border-slate-200 bg-slate-50 text-slate-500' : '' }}">
|
||||
<span class="tabular-nums">{{ $num }}</span>
|
||||
{{ $label }}
|
||||
</span>
|
||||
@if ($num < 3)
|
||||
<span class="text-slate-300" aria-hidden="true">→</span>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-6 shadow-sm sm:p-8 space-y-10">
|
||||
@if ($wizardStep === 1)
|
||||
<section class="space-y-4" aria-labelledby="wz-wizard-step1">
|
||||
<h2 id="wz-wizard-step1" class="text-lg font-semibold text-slate-900">{{ __('Stap 1: OAuth-client') }}</h2>
|
||||
|
||||
@if ($hasStoredCredentials && ! $credentialsEdit)
|
||||
<p class="text-sm text-slate-600">{{ __('Wil je Client ID, client secret of de callback-URI in Weeztix wijzigen? De callback-URL van deze applicatie is hieronder; die moet exact overeenkomen in het Weeztix-dashboard.') }}</p>
|
||||
<p class="rounded-lg bg-slate-50 px-3 py-2 font-mono text-xs text-slate-800 break-all">{{ route('admin.weeztix.callback', absolute: true) }}</p>
|
||||
<div class="flex flex-wrap gap-3 pt-2">
|
||||
<a
|
||||
href="{{ route('admin.pages.weeztix.edit', ['page' => $page, 'wizard' => 1, 'step' => 2]) }}"
|
||||
class="inline-flex rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500"
|
||||
>
|
||||
{{ __('Nee, huidige gegevens behouden') }}
|
||||
</a>
|
||||
<a
|
||||
href="{{ route('admin.pages.weeztix.edit', ['page' => $page, 'wizard' => 1, 'step' => 1, 'credentials' => 'edit']) }}"
|
||||
class="inline-flex rounded-lg border border-slate-300 bg-white px-4 py-2.5 text-sm font-semibold text-slate-800 shadow-sm hover:bg-slate-50"
|
||||
>
|
||||
{{ __('Ja, Client ID en secret wijzigen') }}
|
||||
</a>
|
||||
</div>
|
||||
@else
|
||||
<p class="text-sm text-slate-600">{{ __('Vul de OAuth-client uit het Weeztix-dashboard in. Zet de redirect-URI exact op de onderstaande URL.') }}</p>
|
||||
<p class="rounded-lg bg-slate-50 px-3 py-2 font-mono text-xs text-slate-800 break-all">{{ route('admin.weeztix.callback', absolute: true) }}</p>
|
||||
|
||||
<form method="post" action="{{ route('admin.pages.weeztix.update', $page) }}" class="space-y-4 pt-2">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
<input type="hidden" name="wizard" value="1">
|
||||
<input type="hidden" name="wizard_credential_save" value="1">
|
||||
<div>
|
||||
<label for="weeztix_client_id" class="block text-sm font-medium text-slate-700">{{ __('Client ID') }}</label>
|
||||
<input
|
||||
id="weeztix_client_id"
|
||||
name="client_id"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
value="{{ old('client_id') }}"
|
||||
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="{{ $wz !== null ? __('Laat leeg om ongewijzigd te laten') : '' }}"
|
||||
@if ($wz === null) required @endif
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label for="weeztix_client_secret" class="block text-sm font-medium text-slate-700">{{ __('Client secret') }}</label>
|
||||
<input
|
||||
id="weeztix_client_secret"
|
||||
name="client_secret"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
value=""
|
||||
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="{{ $wz !== null ? __('Laat leeg om ongewijzigd te laten') : '' }}"
|
||||
@if ($wz === null) required @endif
|
||||
>
|
||||
</div>
|
||||
@if ($wz !== null)
|
||||
<p class="text-xs text-slate-500">
|
||||
{{ __('Laat velden leeg om opgeslagen waarden te behouden.') }}
|
||||
</p>
|
||||
@endif
|
||||
<button type="submit" class="rounded-lg bg-slate-800 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-slate-700">
|
||||
{{ __('Opslaan en verder naar verbinden') }}
|
||||
</button>
|
||||
</form>
|
||||
@endif
|
||||
</section>
|
||||
@endif
|
||||
|
||||
@if ($wizardStep === 2)
|
||||
<section class="space-y-4" aria-labelledby="wz-wizard-step2">
|
||||
<h2 id="wz-wizard-step2" class="text-lg font-semibold text-slate-900">{{ __('Stap 2: Verbinden met Weeztix') }}</h2>
|
||||
<p class="text-sm text-slate-600">{{ __('Log in bij Weeztix en keur de toegang goed. Daarna ga je automatisch verder naar de coupon.') }}</p>
|
||||
<p class="rounded-lg bg-slate-50 px-3 py-2 font-mono text-xs text-slate-800 break-all">{{ route('admin.weeztix.callback', absolute: true) }}</p>
|
||||
|
||||
@if ($wz !== null && $wz->is_connected)
|
||||
<div class="rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900">
|
||||
{{ __('Je bent verbonden. Start opnieuw de Weeztix-login hieronder om een ander bedrijf te kiezen; het gekoppelde bedrijf wordt daarna automatisch bijgewerkt.') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<a
|
||||
href="{{ $oauthUrl }}"
|
||||
class="inline-flex rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500"
|
||||
>
|
||||
@if ($wz !== null && $wz->is_connected)
|
||||
{{ __('Opnieuw verbinden met Weeztix') }}
|
||||
@else
|
||||
{{ __('Verbind met Weeztix') }}
|
||||
@endif
|
||||
</a>
|
||||
@if ($wz !== null && $wz->is_connected)
|
||||
<a
|
||||
href="{{ route('admin.pages.weeztix.edit', ['page' => $page, 'wizard' => 1, 'step' => 3]) }}"
|
||||
class="inline-flex rounded-lg border border-slate-300 bg-white px-4 py-2.5 text-sm font-semibold text-slate-800 shadow-sm hover:bg-slate-50"
|
||||
>
|
||||
{{ __('Naar stap 3: coupon') }}
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<p class="pt-4">
|
||||
<a href="{{ route('admin.pages.weeztix.edit', ['page' => $page, 'wizard' => 1, 'step' => 1]) }}" class="text-sm font-medium text-slate-600 hover:text-slate-900">{{ __('← Terug naar stap 1') }}</a>
|
||||
</p>
|
||||
</section>
|
||||
@endif
|
||||
|
||||
@if ($wizardStep === 3)
|
||||
<div
|
||||
x-data="weeztixSetup(@js([
|
||||
'pageId' => $page->id,
|
||||
'couponsUrl' => route('admin.weeztix.coupons'),
|
||||
'csrf' => csrf_token(),
|
||||
'isConnected' => $wz?->is_connected ?? false,
|
||||
'callbackUrl' => route('admin.weeztix.callback', absolute: true),
|
||||
'existing' => $existing,
|
||||
'strings' => [
|
||||
'genericError' => __('Er ging iets mis. Probeer het opnieuw.'),
|
||||
'loadCouponsError' => __('Kon kortingsbonnen niet laden.'),
|
||||
'refreshCoupons' => __('Vernieuwen'),
|
||||
'refreshCouponsBusy' => __('Bezig…'),
|
||||
'refreshCouponsTitle' => __('Couponlijst opnieuw ophalen van Weeztix'),
|
||||
],
|
||||
]))"
|
||||
>
|
||||
<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>
|
||||
|
||||
<section class="space-y-4" aria-labelledby="wz-wizard-step3">
|
||||
<h2 id="wz-wizard-step3" class="text-lg font-semibold text-slate-900">{{ __('Stap 3: Coupon en codes') }}</h2>
|
||||
<p class="text-sm text-slate-600">{{ __('Kies een bestaande coupon in Weeztix en stel het voorvoegsel en aantal gebruiken per code in.') }}</p>
|
||||
|
||||
<form method="post" action="{{ route('admin.pages.weeztix.update', $page) }}" class="space-y-4">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
<input type="hidden" name="wizard" value="1">
|
||||
<input type="hidden" name="wizard_coupon_save" value="1">
|
||||
|
||||
<div>
|
||||
<div class="flex flex-wrap items-end justify-between gap-2">
|
||||
<label for="weeztix_coupon" class="block text-sm font-medium text-slate-700">{{ __('Coupon (kortingssjabloon)') }}</label>
|
||||
<button
|
||||
type="button"
|
||||
x-show="isConnected"
|
||||
x-cloak
|
||||
@click="refreshCoupons()"
|
||||
:disabled="couponsRefreshing"
|
||||
:aria-busy="couponsRefreshing"
|
||||
:title="strings.refreshCouponsTitle"
|
||||
class="shrink-0 rounded-md border border-slate-300 bg-white px-2.5 py-1 text-xs font-medium text-slate-700 shadow-sm hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<span x-show="!couponsRefreshing" x-text="strings.refreshCoupons"></span>
|
||||
<span x-show="couponsRefreshing" x-cloak x-text="strings.refreshCouponsBusy"></span>
|
||||
</button>
|
||||
</div>
|
||||
<select
|
||||
id="weeztix_coupon"
|
||||
name="coupon_guid"
|
||||
x-model="couponGuid"
|
||||
@change="syncCouponName()"
|
||||
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="">{{ __('Selecteer een coupon…') }}</option>
|
||||
<template x-for="c in coupons" :key="c.guid">
|
||||
<option :value="c.guid" x-text="c.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
<input type="hidden" name="coupon_name" :value="couponName">
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="weeztix_code_prefix" class="block text-sm font-medium text-slate-700">{{ __('Codevoorvoegsel') }}</label>
|
||||
<input
|
||||
id="weeztix_code_prefix"
|
||||
name="code_prefix"
|
||||
type="text"
|
||||
maxlength="32"
|
||||
x-model="codePrefix"
|
||||
value="{{ old('code_prefix', $wz->code_prefix ?? 'PREREG') }}"
|
||||
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"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label for="weeztix_usage_count" class="block text-sm font-medium text-slate-700">{{ __('Gebruik per code') }}</label>
|
||||
<input
|
||||
id="weeztix_usage_count"
|
||||
name="usage_count"
|
||||
type="number"
|
||||
min="1"
|
||||
max="99999"
|
||||
x-model.number="usageCount"
|
||||
value="{{ old('usage_count', $wz->usage_count ?? 1) }}"
|
||||
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"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-3 pt-2">
|
||||
<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">
|
||||
{{ __('Opslaan en wizard afronden') }}
|
||||
</button>
|
||||
<a href="{{ route('admin.pages.weeztix.edit', ['page' => $page, 'wizard' => 1, 'step' => 2]) }}" class="inline-flex items-center rounded-lg border border-slate-300 bg-white px-4 py-2.5 text-sm font-semibold text-slate-700 shadow-sm hover:bg-slate-50">
|
||||
{{ __('Terug naar stap 2') }}
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
@endif
|
||||
</section>
|
||||
|
||||
@if ($wz !== null)
|
||||
<section class="space-y-4 pt-8">
|
||||
<h2 class="text-lg font-semibold text-slate-900">{{ __('Stap 2: Bedrijf en kortingsbon') }}</h2>
|
||||
<p class="text-sm text-slate-600">{{ __('Na een geslaagde verbinding kun je een bedrijf en bestaande coupon uit Weeztix kiezen.') }}</p>
|
||||
|
||||
<form method="post" action="{{ route('admin.pages.weeztix.update', $page) }}" class="space-y-4">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
|
||||
<div>
|
||||
<label for="weeztix_company" class="block text-sm font-medium text-slate-700">{{ __('Bedrijf') }}</label>
|
||||
<select
|
||||
id="weeztix_company"
|
||||
name="company_guid"
|
||||
x-model="companyGuid"
|
||||
@change="onCompanyChange()"
|
||||
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="">{{ __('Selecteer een bedrijf…') }}</option>
|
||||
<template x-for="c in companies" :key="c.guid">
|
||||
<option :value="c.guid" x-text="companyLabel(c)"></option>
|
||||
</template>
|
||||
</select>
|
||||
<input type="hidden" name="company_name" :value="companyName">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="weeztix_coupon" class="block text-sm font-medium text-slate-700">{{ __('Coupon (kortingssjabloon)') }}</label>
|
||||
<select
|
||||
id="weeztix_coupon"
|
||||
name="coupon_guid"
|
||||
x-model="couponGuid"
|
||||
@change="syncCouponName()"
|
||||
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="">{{ __('Selecteer een coupon…') }}</option>
|
||||
<template x-for="c in coupons" :key="c.guid">
|
||||
<option :value="c.guid" x-text="c.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
<input type="hidden" name="coupon_name" :value="couponName">
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="weeztix_code_prefix" class="block text-sm font-medium text-slate-700">{{ __('Codevoorvoegsel') }}</label>
|
||||
<input
|
||||
id="weeztix_code_prefix"
|
||||
name="code_prefix"
|
||||
type="text"
|
||||
maxlength="32"
|
||||
x-model="codePrefix"
|
||||
value="{{ old('code_prefix', $wz->code_prefix ?? 'PREREG') }}"
|
||||
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"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label for="weeztix_usage_count" class="block text-sm font-medium text-slate-700">{{ __('Gebruik per code') }}</label>
|
||||
<input
|
||||
id="weeztix_usage_count"
|
||||
name="usage_count"
|
||||
type="number"
|
||||
min="1"
|
||||
max="99999"
|
||||
x-model.number="usageCount"
|
||||
value="{{ old('usage_count', $wz->usage_count ?? 1) }}"
|
||||
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"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{{ __('Configuratie opslaan') }}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@@ -156,30 +156,6 @@
|
||||
</div>
|
||||
</div>
|
||||
<p class="whitespace-pre-line text-center text-base leading-relaxed text-white/95 sm:text-lg" x-text="thankYouMessage"></p>
|
||||
<template x-if="couponCode">
|
||||
<div class="mt-6 rounded-xl border border-white/20 bg-white/10 p-6 backdrop-blur">
|
||||
<p class="text-center text-sm text-white/70">{{ __('Jouw kortingscode') }}</p>
|
||||
<div class="mt-3 flex flex-wrap items-center justify-center gap-3">
|
||||
<span
|
||||
class="font-mono text-2xl font-bold tracking-wider text-festival"
|
||||
x-text="couponCode"
|
||||
></span>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border border-white/25 p-2 text-white/70 transition hover:border-white/40 hover:text-white"
|
||||
@click="copyCouponCode()"
|
||||
aria-label="{{ __('Kortingscode kopiëren') }}"
|
||||
>
|
||||
<svg class="h-6 w-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-3 text-center text-xs text-white/50">
|
||||
{{ __('Gebruik deze code bij het afrekenen in de ticketshop.') }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
<p
|
||||
x-show="redirectSecondsLeft !== null && redirectSecondsLeft > 0"
|
||||
x-cloak
|
||||
|
||||
@@ -71,7 +71,6 @@
|
||||
'redirectUrl' => filled($redirectAfterSubmit) ? $redirectAfterSubmit : null,
|
||||
'strings' => [
|
||||
'linkCopied' => __('Link gekopieerd!'),
|
||||
'couponCopied' => __('Kortingscode gekopieerd!'),
|
||||
'redirectCountdown' => __('You will be redirected in :seconds s…'),
|
||||
],
|
||||
]))"
|
||||
|
||||
@@ -56,7 +56,6 @@ Route::middleware(['auth', 'verified'])->prefix('admin')->name('admin.')->group(
|
||||
Route::get('pages/{page}/weeztix/oauth/redirect', [WeeztixOAuthController::class, 'redirect'])->name('pages.weeztix.oauth.redirect');
|
||||
Route::get('weeztix/callback', [WeeztixOAuthController::class, 'callback'])->name('weeztix.callback');
|
||||
|
||||
Route::post('weeztix/companies', [WeeztixApiController::class, 'companies'])->name('weeztix.companies');
|
||||
Route::post('weeztix/coupons', [WeeztixApiController::class, 'coupons'])->name('weeztix.coupons');
|
||||
|
||||
// User management (superadmin only)
|
||||
|
||||
@@ -4,10 +4,14 @@ declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\MailwizzConfig;
|
||||
use App\Models\PreregistrationPage;
|
||||
use App\Models\Subscriber;
|
||||
use App\Models\User;
|
||||
use App\Models\WeeztixConfig;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\Client\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\TestCase;
|
||||
|
||||
@@ -128,4 +132,202 @@ class DestroySubscriberTest extends TestCase
|
||||
$response->assertForbidden();
|
||||
$this->assertDatabaseHas('subscribers', ['id' => $subscriber->id]);
|
||||
}
|
||||
|
||||
public function test_delete_strips_mailwizz_source_tag_and_clears_coupon_field(): void
|
||||
{
|
||||
Http::fake(function (Request $request) {
|
||||
$url = $request->url();
|
||||
if (str_contains($url, 'search-by-email')) {
|
||||
return Http::response(['status' => 'success', 'data' => ['subscriber_uid' => 'sub-to-clean']]);
|
||||
}
|
||||
if ($request->method() === 'GET' && str_contains($url, '/subscribers/sub-to-clean') && ! str_contains($url, 'search-by-email')) {
|
||||
return Http::response([
|
||||
'status' => 'success',
|
||||
'data' => [
|
||||
'record' => [
|
||||
'TAGS' => 'preregister-source,other-tag',
|
||||
'COUPON' => 'PREREG-OLD',
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
if ($request->method() === 'PUT' && str_contains($url, '/subscribers/sub-to-clean')) {
|
||||
$body = $request->body();
|
||||
$this->assertStringContainsString('other-tag', $body);
|
||||
$this->assertStringNotContainsString('preregister-source', $body);
|
||||
$this->assertStringContainsString('COUPON', $body);
|
||||
|
||||
return Http::response(['status' => 'success']);
|
||||
}
|
||||
|
||||
return Http::response(['status' => 'error'], 500);
|
||||
});
|
||||
|
||||
$user = User::factory()->create(['role' => 'user']);
|
||||
$page = $this->makePageForDestroyTest($user);
|
||||
MailwizzConfig::query()->create([
|
||||
'preregistration_page_id' => $page->id,
|
||||
'api_key' => 'fake-api-key',
|
||||
'list_uid' => 'list-uid-1',
|
||||
'list_name' => 'Main list',
|
||||
'field_email' => 'EMAIL',
|
||||
'field_first_name' => 'FNAME',
|
||||
'field_last_name' => 'LNAME',
|
||||
'field_phone' => null,
|
||||
'field_coupon_code' => 'COUPON',
|
||||
'tag_field' => 'TAGS',
|
||||
'tag_value' => 'preregister-source',
|
||||
]);
|
||||
|
||||
$subscriber = Subscriber::query()->create([
|
||||
'preregistration_page_id' => $page->id,
|
||||
'first_name' => 'Clean',
|
||||
'last_name' => 'Up',
|
||||
'email' => 'cleanup@example.com',
|
||||
'coupon_code' => 'PREREG-LOCAL',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->delete(route('admin.pages.subscribers.destroy', [$page, $subscriber]));
|
||||
|
||||
$response->assertRedirect(route('admin.pages.subscribers.index', $page));
|
||||
$this->assertDatabaseMissing('subscribers', ['id' => $subscriber->id]);
|
||||
Http::assertSentCount(3);
|
||||
}
|
||||
|
||||
public function test_delete_clears_mailwizz_checkboxlist_when_only_configured_tag_is_present(): void
|
||||
{
|
||||
Http::fake(function (Request $request) {
|
||||
$url = $request->url();
|
||||
if (str_contains($url, 'search-by-email')) {
|
||||
return Http::response(['status' => 'success', 'data' => ['subscriber_uid' => 'sub-single-tag']]);
|
||||
}
|
||||
if ($request->method() === 'GET' && str_contains($url, '/subscribers/sub-single-tag') && ! str_contains($url, 'search-by-email')) {
|
||||
return Http::response([
|
||||
'status' => 'success',
|
||||
'data' => [
|
||||
'record' => [
|
||||
'TAGS' => 'preregister-source',
|
||||
'COUPON' => 'PREREG-OLD',
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
if ($request->method() === 'PUT' && str_contains($url, '/subscribers/sub-single-tag')) {
|
||||
$body = $request->body();
|
||||
$this->assertStringContainsString('TAGS', $body);
|
||||
$this->assertStringNotContainsString('preregister-source', $body);
|
||||
$this->assertStringNotContainsString('TAGS[]', $body);
|
||||
$this->assertStringContainsString('COUPON', $body);
|
||||
|
||||
return Http::response(['status' => 'success']);
|
||||
}
|
||||
|
||||
return Http::response(['status' => 'error'], 500);
|
||||
});
|
||||
|
||||
$user = User::factory()->create(['role' => 'user']);
|
||||
$page = $this->makePageForDestroyTest($user);
|
||||
MailwizzConfig::query()->create([
|
||||
'preregistration_page_id' => $page->id,
|
||||
'api_key' => 'fake-api-key',
|
||||
'list_uid' => 'list-uid-1',
|
||||
'list_name' => 'Main list',
|
||||
'field_email' => 'EMAIL',
|
||||
'field_first_name' => 'FNAME',
|
||||
'field_last_name' => 'LNAME',
|
||||
'field_phone' => null,
|
||||
'field_coupon_code' => 'COUPON',
|
||||
'tag_field' => 'TAGS',
|
||||
'tag_value' => 'preregister-source',
|
||||
]);
|
||||
|
||||
$subscriber = Subscriber::query()->create([
|
||||
'preregistration_page_id' => $page->id,
|
||||
'first_name' => 'Solo',
|
||||
'last_name' => 'Tag',
|
||||
'email' => 'solo-tag@example.com',
|
||||
'coupon_code' => 'PREREG-LOCAL',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->delete(route('admin.pages.subscribers.destroy', [$page, $subscriber]));
|
||||
|
||||
$response->assertRedirect(route('admin.pages.subscribers.index', $page));
|
||||
$this->assertDatabaseMissing('subscribers', ['id' => $subscriber->id]);
|
||||
Http::assertSentCount(3);
|
||||
}
|
||||
|
||||
public function test_delete_removes_coupon_code_in_weeztix_when_configured(): void
|
||||
{
|
||||
Http::fake(function (Request $request) {
|
||||
$url = $request->url();
|
||||
if ($request->method() === 'GET' && preg_match('#/coupon/coupon-guid-test/codes$#', $url) === 1) {
|
||||
return Http::response([
|
||||
'data' => [
|
||||
['guid' => 'wzx-code-guid', 'code' => 'PREREG-DEL99'],
|
||||
],
|
||||
], 200);
|
||||
}
|
||||
if ($request->method() === 'DELETE' && str_contains($url, '/coupon/coupon-guid-test/codes/wzx-code-guid')) {
|
||||
return Http::response(null, 204);
|
||||
}
|
||||
|
||||
return Http::response(['status' => 'error'], 500);
|
||||
});
|
||||
|
||||
$user = User::factory()->create(['role' => 'user']);
|
||||
$page = $this->makePageForDestroyTest($user);
|
||||
WeeztixConfig::query()->create([
|
||||
'preregistration_page_id' => $page->id,
|
||||
'client_id' => 'client-id',
|
||||
'client_secret' => 'client-secret',
|
||||
'redirect_uri' => 'https://app.test/callback',
|
||||
'access_token' => 'access-token',
|
||||
'refresh_token' => 'refresh-token',
|
||||
'token_expires_at' => now()->addHour(),
|
||||
'refresh_token_expires_at' => now()->addMonth(),
|
||||
'company_guid' => 'company-guid-test',
|
||||
'company_name' => 'Test Co',
|
||||
'coupon_guid' => 'coupon-guid-test',
|
||||
'coupon_name' => 'PreReg',
|
||||
'is_connected' => true,
|
||||
]);
|
||||
|
||||
$subscriber = Subscriber::query()->create([
|
||||
'preregistration_page_id' => $page->id,
|
||||
'first_name' => 'Weez',
|
||||
'last_name' => 'Tix',
|
||||
'email' => 'weez@example.com',
|
||||
'coupon_code' => 'PREREG-DEL99',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->delete(route('admin.pages.subscribers.destroy', [$page, $subscriber]));
|
||||
|
||||
$response->assertRedirect(route('admin.pages.subscribers.index', $page));
|
||||
$this->assertDatabaseMissing('subscribers', ['id' => $subscriber->id]);
|
||||
|
||||
Http::assertSent(function (Request $request): bool {
|
||||
return $request->method() === 'DELETE'
|
||||
&& str_contains($request->url(), '/coupon/coupon-guid-test/codes/wzx-code-guid');
|
||||
});
|
||||
}
|
||||
|
||||
private function makePageForDestroyTest(User $user): PreregistrationPage
|
||||
{
|
||||
return PreregistrationPage::query()->create([
|
||||
'slug' => (string) Str::uuid(),
|
||||
'user_id' => $user->id,
|
||||
'title' => 'Fest',
|
||||
'heading' => 'Fest',
|
||||
'intro_text' => null,
|
||||
'thank_you_message' => null,
|
||||
'expired_message' => null,
|
||||
'ticketshop_url' => null,
|
||||
'start_date' => now()->subDay(),
|
||||
'end_date' => now()->addMonth(),
|
||||
'phone_enabled' => false,
|
||||
'background_image' => null,
|
||||
'logo_image' => null,
|
||||
'is_active' => true,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\MailwizzConfig;
|
||||
use App\Models\PreregistrationPage;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
@@ -36,6 +37,50 @@ class MailwizzConfigUiTest extends TestCase
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_connected_mailwizz_shows_overview_until_wizard_requested(): void
|
||||
{
|
||||
$user = User::factory()->create(['role' => 'user']);
|
||||
$page = $this->makePageForUser($user);
|
||||
MailwizzConfig::query()->create([
|
||||
'preregistration_page_id' => $page->id,
|
||||
'api_key' => 'test-key',
|
||||
'list_uid' => 'list-uid-1',
|
||||
'list_name' => 'Main list',
|
||||
'field_email' => 'EMAIL',
|
||||
'field_first_name' => 'FNAME',
|
||||
'field_last_name' => 'LNAME',
|
||||
'field_phone' => null,
|
||||
'field_coupon_code' => null,
|
||||
'tag_field' => 'TAGS',
|
||||
'tag_value' => 'preregister-source',
|
||||
]);
|
||||
|
||||
$overview = $this->actingAs($user)->get(route('admin.pages.mailwizz.edit', $page));
|
||||
$overview->assertOk();
|
||||
$overview->assertSee('Current configuration', escape: false);
|
||||
$overview->assertSee('Change settings (wizard)', escape: false);
|
||||
$overview->assertDontSee('Step 1: API key', escape: false);
|
||||
|
||||
$wizard = $this->actingAs($user)->get(route('admin.pages.mailwizz.edit', [
|
||||
'page' => $page,
|
||||
'wizard' => 1,
|
||||
'step' => 1,
|
||||
]));
|
||||
$wizard->assertOk();
|
||||
$wizard->assertSee('Step 1: API key', escape: false);
|
||||
$wizard->assertSee('Cancel and return to overview', escape: false);
|
||||
}
|
||||
|
||||
public function test_mailwizz_wizard_redirects_to_step_one_when_no_config_and_step_gt_one(): void
|
||||
{
|
||||
$user = User::factory()->create(['role' => 'user']);
|
||||
$page = $this->makePageForUser($user);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('admin.pages.mailwizz.edit', ['page' => $page, 'wizard' => 1, 'step' => 3]))
|
||||
->assertRedirect(route('admin.pages.mailwizz.edit', ['page' => $page, 'wizard' => 1, 'step' => 1]));
|
||||
}
|
||||
|
||||
private function makePageForUser(User $user): PreregistrationPage
|
||||
{
|
||||
return PreregistrationPage::query()->create([
|
||||
|
||||
@@ -19,6 +19,27 @@ class SyncSubscriberToMailwizzTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_subscribe_returns_ok_when_mailwizz_api_fails_under_sync_queue(): void
|
||||
{
|
||||
Http::fake([
|
||||
'*' => Http::response(['status' => 'error', 'message' => 'service unavailable'], 503),
|
||||
]);
|
||||
|
||||
$page = $this->makePageWithMailwizz();
|
||||
|
||||
$this->postJson(route('public.subscribe', ['publicPage' => $page->slug]), [
|
||||
'first_name' => 'Broken',
|
||||
'last_name' => 'Mailwizz',
|
||||
'email' => 'broken-mailwizz@example.com',
|
||||
])
|
||||
->assertOk()
|
||||
->assertJson(['success' => true]);
|
||||
|
||||
$subscriber = Subscriber::query()->where('email', 'broken-mailwizz@example.com')->first();
|
||||
$this->assertNotNull($subscriber);
|
||||
$this->assertFalse($subscriber->synced_to_mailwizz);
|
||||
}
|
||||
|
||||
public function test_subscribe_with_mailwizz_config_runs_sync_create_path_and_marks_synced(): void
|
||||
{
|
||||
Http::fake(function (Request $request) {
|
||||
|
||||
Reference in New Issue
Block a user