feat: add Weeztix OAuth, coupon codes, and Mailwizz mapping
Implement Weeztix integration per documentation: database config and subscriber coupon_code, OAuth redirect/callback, admin setup UI with company/coupon selection via AJAX, synchronous coupon creation on public subscribe with duplicate and rate-limit handling, Mailwizz field mapping for coupon codes, subscriber table and CSV export, and connection hint on the pages list. Made-with: Cursor
This commit is contained in:
9
app/Exceptions/WeeztixCouponCodeConflictException.php
Normal file
9
app/Exceptions/WeeztixCouponCodeConflictException.php
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Exceptions;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
final class WeeztixCouponCodeConflictException extends RuntimeException {}
|
||||||
@@ -28,6 +28,7 @@ class PageController extends Controller
|
|||||||
{
|
{
|
||||||
$query = PreregistrationPage::query()
|
$query = PreregistrationPage::query()
|
||||||
->withCount('subscribers')
|
->withCount('subscribers')
|
||||||
|
->with('weeztixConfig')
|
||||||
->orderByDesc('start_date');
|
->orderByDesc('start_date');
|
||||||
|
|
||||||
if (! $request->user()?->isSuperadmin()) {
|
if (! $request->user()?->isSuperadmin()) {
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ class SubscriberController extends Controller
|
|||||||
if ($phoneEnabled) {
|
if ($phoneEnabled) {
|
||||||
$headers[] = 'Phone';
|
$headers[] = 'Phone';
|
||||||
}
|
}
|
||||||
$headers = array_merge($headers, ['Registered At', 'Synced to Mailwizz', 'Synced At']);
|
$headers = array_merge($headers, ['Coupon Code', 'Registered At', 'Synced to Mailwizz', 'Synced At']);
|
||||||
fputcsv($handle, $headers);
|
fputcsv($handle, $headers);
|
||||||
|
|
||||||
foreach ($subscribers as $sub) {
|
foreach ($subscribers as $sub) {
|
||||||
@@ -97,6 +97,7 @@ class SubscriberController extends Controller
|
|||||||
if ($phoneEnabled) {
|
if ($phoneEnabled) {
|
||||||
$row[] = $sub->phoneDisplay() ?? '';
|
$row[] = $sub->phoneDisplay() ?? '';
|
||||||
}
|
}
|
||||||
|
$row[] = $sub->coupon_code ?? '';
|
||||||
$row[] = $sub->created_at?->toDateTimeString() ?? '';
|
$row[] = $sub->created_at?->toDateTimeString() ?? '';
|
||||||
$row[] = $sub->synced_to_mailwizz ? 'Yes' : 'No';
|
$row[] = $sub->synced_to_mailwizz ? 'Yes' : 'No';
|
||||||
$row[] = $sub->synced_at?->toDateTimeString() ?? '';
|
$row[] = $sub->synced_at?->toDateTimeString() ?? '';
|
||||||
|
|||||||
111
app/Http/Controllers/Admin/WeeztixApiController.php
Normal file
111
app/Http/Controllers/Admin/WeeztixApiController.php
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\PreregistrationPage;
|
||||||
|
use App\Services\WeeztixService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
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'));
|
||||||
|
$this->authorize('update', $page);
|
||||||
|
|
||||||
|
$config = $page->weeztixConfig;
|
||||||
|
if ($config === null || ! $config->is_connected) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => __('Niet verbonden met Weeztix.'),
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$companyGuid = $request->string('company_guid')->toString();
|
||||||
|
$previousGuid = $config->company_guid;
|
||||||
|
$config->setAttribute('company_guid', $companyGuid);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$raw = (new WeeztixService($config))->getCoupons();
|
||||||
|
$coupons = $this->normalizeCouponsPayload($raw);
|
||||||
|
|
||||||
|
return response()->json(['coupons' => $coupons]);
|
||||||
|
} catch (RuntimeException) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => __('Kon kortingsbonnen niet laden.'),
|
||||||
|
], 422);
|
||||||
|
} finally {
|
||||||
|
$config->setAttribute('company_guid', $previousGuid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $raw
|
||||||
|
* @return list<array{guid: string, name: string}>
|
||||||
|
*/
|
||||||
|
private function normalizeCouponsPayload(array $raw): array
|
||||||
|
{
|
||||||
|
$list = $raw;
|
||||||
|
if (isset($raw['data']) && is_array($raw['data'])) {
|
||||||
|
$list = $raw['data'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_array($list)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
foreach ($list as $row) {
|
||||||
|
if (! is_array($row)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$guid = data_get($row, 'guid') ?? data_get($row, 'id');
|
||||||
|
if (! is_string($guid) || $guid === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$name = data_get($row, 'name') ?? data_get($row, 'title') ?? $guid;
|
||||||
|
$out[] = [
|
||||||
|
'guid' => $guid,
|
||||||
|
'name' => is_string($name) ? $name : $guid,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
}
|
||||||
59
app/Http/Controllers/Admin/WeeztixController.php
Normal file
59
app/Http/Controllers/Admin/WeeztixController.php
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Admin\UpdateWeeztixConfigRequest;
|
||||||
|
use App\Models\PreregistrationPage;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class WeeztixController extends Controller
|
||||||
|
{
|
||||||
|
public function edit(PreregistrationPage $page): View
|
||||||
|
{
|
||||||
|
$this->authorize('update', $page);
|
||||||
|
|
||||||
|
$page->load('weeztixConfig');
|
||||||
|
|
||||||
|
return view('admin.weeztix.edit', compact('page'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(UpdateWeeztixConfigRequest $request, PreregistrationPage $page): RedirectResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validated();
|
||||||
|
|
||||||
|
foreach (['client_id', 'client_secret'] as $key) {
|
||||||
|
if (array_key_exists($key, $validated) && $validated[$key] === '' && $page->weeztixConfig !== null) {
|
||||||
|
unset($validated[$key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated['redirect_uri'] = route('admin.weeztix.callback', absolute: true);
|
||||||
|
|
||||||
|
DB::transaction(function () use ($page, $validated): void {
|
||||||
|
$page->weeztixConfig()->updateOrCreate(
|
||||||
|
['preregistration_page_id' => $page->id],
|
||||||
|
array_merge($validated, ['preregistration_page_id' => $page->id])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('admin.pages.weeztix.edit', $page)
|
||||||
|
->with('status', __('Weeztix-configuratie opgeslagen.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(PreregistrationPage $page): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorize('update', $page);
|
||||||
|
|
||||||
|
$page->weeztixConfig()?->delete();
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('admin.pages.weeztix.edit', $page)
|
||||||
|
->with('status', __('Weeztix-integratie verwijderd.'));
|
||||||
|
}
|
||||||
|
}
|
||||||
141
app/Http/Controllers/Admin/WeeztixOAuthController.php
Normal file
141
app/Http/Controllers/Admin/WeeztixOAuthController.php
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\PreregistrationPage;
|
||||||
|
use App\Services\WeeztixService;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class WeeztixOAuthController extends Controller
|
||||||
|
{
|
||||||
|
public function redirect(PreregistrationPage $page): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorize('update', $page);
|
||||||
|
|
||||||
|
$page->load('weeztixConfig');
|
||||||
|
$config = $page->weeztixConfig;
|
||||||
|
if ($config === null) {
|
||||||
|
return redirect()
|
||||||
|
->route('admin.pages.weeztix.edit', $page)
|
||||||
|
->with('error', __('Sla eerst je client ID en client secret op.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$clientId = $config->client_id;
|
||||||
|
if (! is_string($clientId) || $clientId === '') {
|
||||||
|
return redirect()
|
||||||
|
->route('admin.pages.weeztix.edit', $page)
|
||||||
|
->with('error', __('Vul een geldige Weeztix client ID in.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$state = Str::random(40);
|
||||||
|
session([
|
||||||
|
'weeztix_oauth_state' => $state,
|
||||||
|
'weeztix_page_id' => $page->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$redirectUri = $config->redirect_uri;
|
||||||
|
if (! is_string($redirectUri) || $redirectUri === '') {
|
||||||
|
$redirectUri = route('admin.weeztix.callback', absolute: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$query = http_build_query([
|
||||||
|
'client_id' => $clientId,
|
||||||
|
'redirect_uri' => $redirectUri,
|
||||||
|
'response_type' => 'code',
|
||||||
|
'state' => $state,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$authorizeBase = rtrim(config('weeztix.auth_base_url'), '/').'/tokens/authorize';
|
||||||
|
|
||||||
|
return redirect()->away($authorizeBase.'?'.$query);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function callback(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
if ($request->filled('error')) {
|
||||||
|
Log::warning('Weeztix OAuth provider error', [
|
||||||
|
'error' => $request->string('error')->toString(),
|
||||||
|
'description' => $request->string('error_description')->toString(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $this->redirectToWeeztixEditWithSessionPage(__('Weeztix heeft de verbinding geweigerd. Probeer opnieuw.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->validate([
|
||||||
|
'state' => ['required', 'string'],
|
||||||
|
'code' => ['required', 'string'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$storedState = session('weeztix_oauth_state');
|
||||||
|
$pageId = session('weeztix_page_id');
|
||||||
|
if (! is_string($storedState) || $storedState === '' || ($pageId === null || (! is_int($pageId) && ! is_numeric($pageId)))) {
|
||||||
|
return redirect()
|
||||||
|
->route('admin.dashboard')
|
||||||
|
->with('error', __('Ongeldige OAuth-sessie. Start opnieuw vanaf de Weeztix-pagina.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->string('state')->toString() !== $storedState) {
|
||||||
|
abort(403, 'Invalid OAuth state');
|
||||||
|
}
|
||||||
|
|
||||||
|
$page = PreregistrationPage::query()->findOrFail((int) $pageId);
|
||||||
|
$this->authorize('update', $page);
|
||||||
|
|
||||||
|
$config = $page->weeztixConfig;
|
||||||
|
if ($config === null) {
|
||||||
|
session()->forget(['weeztix_oauth_state', 'weeztix_page_id']);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('admin.pages.weeztix.edit', $page)
|
||||||
|
->with('error', __('Geen Weeztix-configuratie gevonden voor deze pagina.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$service = new WeeztixService($config);
|
||||||
|
$service->exchangeAuthorizationCode($request->string('code')->toString());
|
||||||
|
} catch (RuntimeException $e) {
|
||||||
|
Log::error('Weeztix OAuth callback failed', [
|
||||||
|
'page_id' => $page->id,
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
session()->forget(['weeztix_oauth_state', 'weeztix_page_id']);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('admin.pages.weeztix.edit', $page)
|
||||||
|
->with('error', __('Verbinden met Weeztix is mislukt. Controleer je gegevens en probeer opnieuw.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
session()->forget(['weeztix_oauth_state', 'weeztix_page_id']);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('admin.pages.weeztix.edit', $page)
|
||||||
|
->with('status', __('Succesvol verbonden met Weeztix.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function redirectToWeeztixEditWithSessionPage(string $message): RedirectResponse
|
||||||
|
{
|
||||||
|
$pageId = session('weeztix_page_id');
|
||||||
|
session()->forget(['weeztix_oauth_state', 'weeztix_page_id']);
|
||||||
|
|
||||||
|
if (is_int($pageId) || is_numeric($pageId)) {
|
||||||
|
$page = PreregistrationPage::query()->find((int) $pageId);
|
||||||
|
if ($page !== null) {
|
||||||
|
return redirect()
|
||||||
|
->route('admin.pages.weeztix.edit', $page)
|
||||||
|
->with('error', $message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('admin.dashboard')
|
||||||
|
->with('error', $message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,13 +4,19 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Exceptions\WeeztixCouponCodeConflictException;
|
||||||
use App\Http\Requests\SubscribePublicPageRequest;
|
use App\Http\Requests\SubscribePublicPageRequest;
|
||||||
use App\Jobs\SyncSubscriberToMailwizz;
|
use App\Jobs\SyncSubscriberToMailwizz;
|
||||||
use App\Models\PageBlock;
|
use App\Models\PageBlock;
|
||||||
use App\Models\PreregistrationPage;
|
use App\Models\PreregistrationPage;
|
||||||
|
use App\Models\Subscriber;
|
||||||
|
use App\Models\WeeztixConfig;
|
||||||
|
use App\Services\WeeztixService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\View\View;
|
use Illuminate\View\View;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
class PublicPageController extends Controller
|
class PublicPageController extends Controller
|
||||||
{
|
{
|
||||||
@@ -52,13 +58,70 @@ class PublicPageController extends Controller
|
|||||||
|
|
||||||
$subscriber = $publicPage->subscribers()->create($validated);
|
$subscriber = $publicPage->subscribers()->create($validated);
|
||||||
|
|
||||||
|
$publicPage->loadMissing('weeztixConfig');
|
||||||
|
$weeztix = $publicPage->weeztixConfig;
|
||||||
|
if ($this->weeztixCanIssueCodes($weeztix)) {
|
||||||
|
$this->tryAttachWeeztixCouponCode($subscriber, $weeztix);
|
||||||
|
}
|
||||||
|
|
||||||
if ($publicPage->mailwizzConfig !== null) {
|
if ($publicPage->mailwizzConfig !== null) {
|
||||||
SyncSubscriberToMailwizz::dispatch($subscriber);
|
SyncSubscriberToMailwizz::dispatch($subscriber->fresh());
|
||||||
}
|
}
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'message' => $publicPage->thank_you_message ?? __('Thank you for registering!'),
|
'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,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ class UpdateMailwizzConfigRequest extends FormRequest
|
|||||||
'field_first_name' => ['required', 'string', 'max:255'],
|
'field_first_name' => ['required', 'string', 'max:255'],
|
||||||
'field_last_name' => ['required', 'string', 'max:255'],
|
'field_last_name' => ['required', 'string', 'max:255'],
|
||||||
'field_phone' => ['nullable', 'string', 'max:255'],
|
'field_phone' => ['nullable', 'string', 'max:255'],
|
||||||
|
'field_coupon_code' => ['nullable', 'string', 'max:255'],
|
||||||
'tag_field' => ['required', 'string', 'max:255'],
|
'tag_field' => ['required', 'string', 'max:255'],
|
||||||
'tag_value' => ['required', 'string', 'max:255'],
|
'tag_value' => ['required', 'string', 'max:255'],
|
||||||
];
|
];
|
||||||
|
|||||||
55
app/Http/Requests/Admin/UpdateWeeztixConfigRequest.php
Normal file
55
app/Http/Requests/Admin/UpdateWeeztixConfigRequest.php
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Admin;
|
||||||
|
|
||||||
|
use App\Models\PreregistrationPage;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class UpdateWeeztixConfigRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
$page = $this->route('page');
|
||||||
|
if (! $page instanceof PreregistrationPage) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->user()?->can('update', $page) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array<int, ValidationRule|string>>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
/** @var PreregistrationPage $page */
|
||||||
|
$page = $this->route('page');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'client_id' => [
|
||||||
|
'sometimes',
|
||||||
|
Rule::requiredIf(fn (): bool => $page->weeztixConfig === null),
|
||||||
|
'nullable',
|
||||||
|
'string',
|
||||||
|
'max:2048',
|
||||||
|
],
|
||||||
|
'client_secret' => [
|
||||||
|
'sometimes',
|
||||||
|
Rule::requiredIf(fn (): bool => $page->weeztixConfig === null),
|
||||||
|
'nullable',
|
||||||
|
'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'],
|
||||||
|
'usage_count' => ['nullable', 'integer', 'min:1', 'max:99999'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -134,6 +134,11 @@ class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueue
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$couponField = $config->field_coupon_code;
|
||||||
|
if (is_string($couponField) && $couponField !== '' && $subscriber->coupon_code !== null && $subscriber->coupon_code !== '') {
|
||||||
|
$data[$couponField] = $subscriber->coupon_code;
|
||||||
|
}
|
||||||
|
|
||||||
return $data;
|
return $data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ class MailwizzConfig extends Model
|
|||||||
'field_first_name',
|
'field_first_name',
|
||||||
'field_last_name',
|
'field_last_name',
|
||||||
'field_phone',
|
'field_phone',
|
||||||
|
'field_coupon_code',
|
||||||
'tag_field',
|
'tag_field',
|
||||||
'tag_value',
|
'tag_value',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -162,6 +162,11 @@ class PreregistrationPage extends Model
|
|||||||
return $this->hasOne(MailwizzConfig::class);
|
return $this->hasOne(MailwizzConfig::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function weeztixConfig(): HasOne
|
||||||
|
{
|
||||||
|
return $this->hasOne(WeeztixConfig::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function isBeforeStart(): bool
|
public function isBeforeStart(): bool
|
||||||
{
|
{
|
||||||
return Carbon::now()->lt($this->start_date);
|
return Carbon::now()->lt($this->start_date);
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ class Subscriber extends Model
|
|||||||
'phone',
|
'phone',
|
||||||
'synced_to_mailwizz',
|
'synced_to_mailwizz',
|
||||||
'synced_at',
|
'synced_at',
|
||||||
|
'coupon_code',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
@@ -95,7 +96,8 @@ class Subscriber extends Model
|
|||||||
$q->where('first_name', 'like', $like)
|
$q->where('first_name', 'like', $like)
|
||||||
->orWhere('last_name', 'like', $like)
|
->orWhere('last_name', 'like', $like)
|
||||||
->orWhere('email', 'like', $like)
|
->orWhere('email', 'like', $like)
|
||||||
->orWhere('phone', 'like', $like);
|
->orWhere('phone', 'like', $like)
|
||||||
|
->orWhere('coupon_code', 'like', $like);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
64
app/Models/WeeztixConfig.php
Normal file
64
app/Models/WeeztixConfig.php
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class WeeztixConfig extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'preregistration_page_id',
|
||||||
|
'client_id',
|
||||||
|
'client_secret',
|
||||||
|
'redirect_uri',
|
||||||
|
'access_token',
|
||||||
|
'refresh_token',
|
||||||
|
'token_expires_at',
|
||||||
|
'refresh_token_expires_at',
|
||||||
|
'company_guid',
|
||||||
|
'company_name',
|
||||||
|
'coupon_guid',
|
||||||
|
'coupon_name',
|
||||||
|
'code_prefix',
|
||||||
|
'usage_count',
|
||||||
|
'is_connected',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'client_id' => 'encrypted',
|
||||||
|
'client_secret' => 'encrypted',
|
||||||
|
'access_token' => 'encrypted',
|
||||||
|
'refresh_token' => 'encrypted',
|
||||||
|
'token_expires_at' => 'datetime',
|
||||||
|
'refresh_token_expires_at' => 'datetime',
|
||||||
|
'is_connected' => 'boolean',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function preregistrationPage(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(PreregistrationPage::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isTokenExpired(): bool
|
||||||
|
{
|
||||||
|
return ! $this->token_expires_at || $this->token_expires_at->isPast();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isRefreshTokenExpired(): bool
|
||||||
|
{
|
||||||
|
if ($this->refresh_token_expires_at === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->refresh_token_expires_at->isPast();
|
||||||
|
}
|
||||||
|
}
|
||||||
464
app/Services/WeeztixService.php
Normal file
464
app/Services/WeeztixService.php
Normal file
@@ -0,0 +1,464 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Exceptions\WeeztixCouponCodeConflictException;
|
||||||
|
use App\Models\WeeztixConfig;
|
||||||
|
use Illuminate\Http\Client\Response;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use LogicException;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
final class WeeztixService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private WeeztixConfig $config
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bearer token for API calls, refreshing from the stored refresh token when needed.
|
||||||
|
*/
|
||||||
|
public function getValidAccessToken(): string
|
||||||
|
{
|
||||||
|
if ($this->config->access_token && ! $this->config->isTokenExpired()) {
|
||||||
|
return (string) $this->config->access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->refreshAccessToken();
|
||||||
|
$this->config->refresh();
|
||||||
|
|
||||||
|
$token = $this->config->access_token;
|
||||||
|
if (! is_string($token) || $token === '') {
|
||||||
|
throw new RuntimeException('Weeztix access token missing after refresh.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{guid: string, name: string|null}>
|
||||||
|
*/
|
||||||
|
public function getCompanies(): array
|
||||||
|
{
|
||||||
|
$token = $this->getValidAccessToken();
|
||||||
|
$url = config('weeztix.user_profile_url');
|
||||||
|
|
||||||
|
$response = Http::withHeaders([
|
||||||
|
'Authorization' => 'Bearer '.$token,
|
||||||
|
])
|
||||||
|
->acceptJson()
|
||||||
|
->get($url);
|
||||||
|
|
||||||
|
if ($response->status() === 401) {
|
||||||
|
$this->refreshAccessToken();
|
||||||
|
$this->config->refresh();
|
||||||
|
$response = Http::withHeaders([
|
||||||
|
'Authorization' => 'Bearer '.(string) $this->config->access_token,
|
||||||
|
])
|
||||||
|
->acceptJson()
|
||||||
|
->get($url);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($response->failed()) {
|
||||||
|
$this->logFailedResponse('getCompanies', $url, $response);
|
||||||
|
|
||||||
|
throw new RuntimeException('Weeztix user profile request failed: '.$response->status());
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::debug('Weeztix API', [
|
||||||
|
'action' => 'getCompanies',
|
||||||
|
'url' => $url,
|
||||||
|
'http_status' => $response->status(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$json = $response->json();
|
||||||
|
if (! is_array($json)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->normalizeCompaniesFromProfile($json);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exchange OAuth authorization code for tokens (admin callback).
|
||||||
|
*/
|
||||||
|
public function exchangeAuthorizationCode(string $code): void
|
||||||
|
{
|
||||||
|
$redirectUri = $this->config->redirect_uri;
|
||||||
|
if (! is_string($redirectUri) || $redirectUri === '') {
|
||||||
|
throw new LogicException('Weeztix redirect_uri is not set.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$tokenUrl = config('weeztix.auth_base_url').'/tokens';
|
||||||
|
$response = Http::asForm()->post($tokenUrl, [
|
||||||
|
'grant_type' => 'authorization_code',
|
||||||
|
'client_id' => $this->config->client_id,
|
||||||
|
'client_secret' => $this->config->client_secret,
|
||||||
|
'redirect_uri' => $redirectUri,
|
||||||
|
'code' => $code,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($response->failed()) {
|
||||||
|
Log::error('Weeztix OAuth code exchange failed', [
|
||||||
|
'url' => $tokenUrl,
|
||||||
|
'status' => $response->status(),
|
||||||
|
'body' => $response->json(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
throw new RuntimeException('Weeztix OAuth code exchange failed: '.$response->status());
|
||||||
|
}
|
||||||
|
|
||||||
|
$json = $response->json();
|
||||||
|
if (! is_array($json)) {
|
||||||
|
throw new RuntimeException('Weeztix token response was not valid JSON.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->applyTokenResponseToConfig($json);
|
||||||
|
$this->hydrateCompanyFromTokenInfo($json);
|
||||||
|
|
||||||
|
Log::debug('Weeztix API', [
|
||||||
|
'action' => 'oauth_authorization_code',
|
||||||
|
'url' => $tokenUrl,
|
||||||
|
'http_status' => $response->status(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function getCoupons(): array
|
||||||
|
{
|
||||||
|
$this->assertCompanyGuid();
|
||||||
|
|
||||||
|
$url = config('weeztix.api_base_url').'/coupon';
|
||||||
|
|
||||||
|
return $this->apiRequest('get', $url, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a single coupon code on the coupon selected in config.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*
|
||||||
|
* @throws WeeztixCouponCodeConflictException When the code already exists (HTTP 409).
|
||||||
|
*/
|
||||||
|
public function createCouponCode(string $code): 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';
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'usage_count' => $this->config->usage_count,
|
||||||
|
'applies_to_count' => null,
|
||||||
|
'codes' => [
|
||||||
|
['code' => $code],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$rateAttempts = 3;
|
||||||
|
for ($rateAttempt = 0; $rateAttempt < $rateAttempts; $rateAttempt++) {
|
||||||
|
$token = $this->getValidAccessToken();
|
||||||
|
$response = $this->sendApiRequest('put', $url, $payload, $token);
|
||||||
|
|
||||||
|
if ($response->status() === 401) {
|
||||||
|
$this->refreshAccessToken();
|
||||||
|
$this->config->refresh();
|
||||||
|
$response = $this->sendApiRequest('put', $url, $payload, (string) $this->config->access_token);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($response->status() === 429) {
|
||||||
|
$waitSeconds = min(8, 2 ** $rateAttempt);
|
||||||
|
Log::warning('Weeztix API rate limited', [
|
||||||
|
'url' => $url,
|
||||||
|
'retry_in_seconds' => $waitSeconds,
|
||||||
|
]);
|
||||||
|
sleep($waitSeconds);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($response->status() === 409) {
|
||||||
|
throw new WeeztixCouponCodeConflictException('Weeztix coupon code already exists.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($response->failed()) {
|
||||||
|
$this->logFailedResponse('createCouponCode', $url, $response);
|
||||||
|
|
||||||
|
throw new RuntimeException('Weeztix API request failed: '.$response->status());
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::debug('Weeztix API', [
|
||||||
|
'action' => 'createCouponCode',
|
||||||
|
'url' => $url,
|
||||||
|
'http_status' => $response->status(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$json = $response->json();
|
||||||
|
|
||||||
|
return is_array($json) ? $json : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::error('Weeztix API rate limited after retries', ['url' => $url]);
|
||||||
|
|
||||||
|
throw new RuntimeException('Weeztix API rate limited after retries.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function generateUniqueCode(string $prefix = 'PREREG', int $length = 6): string
|
||||||
|
{
|
||||||
|
$chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||||
|
$code = '';
|
||||||
|
for ($i = 0; $i < $length; $i++) {
|
||||||
|
$code .= $chars[random_int(0, strlen($chars) - 1)];
|
||||||
|
}
|
||||||
|
|
||||||
|
return strtoupper($prefix).'-'.$code;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assertCompanyGuid(): void
|
||||||
|
{
|
||||||
|
$guid = $this->config->company_guid;
|
||||||
|
if (! is_string($guid) || $guid === '') {
|
||||||
|
throw new LogicException('Weeztix company is not configured.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function apiRequest(string $method, string $url, array $data = []): array
|
||||||
|
{
|
||||||
|
$this->assertCompanyGuid();
|
||||||
|
|
||||||
|
$token = $this->getValidAccessToken();
|
||||||
|
$response = $this->sendApiRequest($method, $url, $data, $token);
|
||||||
|
|
||||||
|
if ($response->status() === 401) {
|
||||||
|
$this->refreshAccessToken();
|
||||||
|
$this->config->refresh();
|
||||||
|
$response = $this->sendApiRequest($method, $url, $data, (string) $this->config->access_token);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($response->failed()) {
|
||||||
|
$this->logFailedResponse('apiRequest', $url, $response);
|
||||||
|
|
||||||
|
throw new RuntimeException('Weeztix API request failed: '.$response->status());
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::debug('Weeztix API', [
|
||||||
|
'action' => 'apiRequest',
|
||||||
|
'method' => $method,
|
||||||
|
'url' => $url,
|
||||||
|
'http_status' => $response->status(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$json = $response->json();
|
||||||
|
|
||||||
|
return is_array($json) ? $json : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
private function sendApiRequest(string $method, string $url, array $data, string $token): Response
|
||||||
|
{
|
||||||
|
$client = Http::withHeaders([
|
||||||
|
'Authorization' => 'Bearer '.$token,
|
||||||
|
'Company' => (string) $this->config->company_guid,
|
||||||
|
])->acceptJson();
|
||||||
|
|
||||||
|
return match (strtolower($method)) {
|
||||||
|
'get' => $client->get($url, $data),
|
||||||
|
'post' => $client->asJson()->post($url, $data),
|
||||||
|
'put' => $client->asJson()->put($url, $data),
|
||||||
|
'patch' => $client->asJson()->patch($url, $data),
|
||||||
|
'delete' => $client->delete($url, $data),
|
||||||
|
default => throw new InvalidArgumentException('Unsupported HTTP method: '.$method),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function refreshAccessToken(): void
|
||||||
|
{
|
||||||
|
if (! $this->config->refresh_token || $this->config->isRefreshTokenExpired()) {
|
||||||
|
$this->config->update([
|
||||||
|
'is_connected' => false,
|
||||||
|
]);
|
||||||
|
$this->config->refresh();
|
||||||
|
|
||||||
|
throw new RuntimeException('Weeztix refresh token missing or expired; reconnect OAuth.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$tokenUrl = config('weeztix.auth_base_url').'/tokens';
|
||||||
|
$response = Http::asForm()->post($tokenUrl, [
|
||||||
|
'grant_type' => 'refresh_token',
|
||||||
|
'refresh_token' => $this->config->refresh_token,
|
||||||
|
'client_id' => $this->config->client_id,
|
||||||
|
'client_secret' => $this->config->client_secret,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($response->failed()) {
|
||||||
|
Log::error('Weeztix token refresh failed', [
|
||||||
|
'url' => $tokenUrl,
|
||||||
|
'status' => $response->status(),
|
||||||
|
'body' => $response->json(),
|
||||||
|
]);
|
||||||
|
$this->config->update([
|
||||||
|
'is_connected' => false,
|
||||||
|
]);
|
||||||
|
$this->config->refresh();
|
||||||
|
|
||||||
|
throw new RuntimeException('Weeztix token refresh failed: '.$response->status());
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::debug('Weeztix API', [
|
||||||
|
'action' => 'refresh_token',
|
||||||
|
'url' => $tokenUrl,
|
||||||
|
'http_status' => $response->status(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$json = $response->json();
|
||||||
|
if (! is_array($json)) {
|
||||||
|
throw new RuntimeException('Weeztix token refresh returned invalid JSON.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->applyTokenResponseToConfig($json);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $json
|
||||||
|
*/
|
||||||
|
private function applyTokenResponseToConfig(array $json): void
|
||||||
|
{
|
||||||
|
$access = $json['access_token'] ?? null;
|
||||||
|
$refresh = $json['refresh_token'] ?? null;
|
||||||
|
if (! is_string($access) || $access === '') {
|
||||||
|
throw new RuntimeException('Weeztix token response missing access_token.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$expiresIn = isset($json['expires_in']) ? (int) $json['expires_in'] : 0;
|
||||||
|
$refreshExpiresIn = isset($json['refresh_token_expires_in']) ? (int) $json['refresh_token_expires_in'] : 0;
|
||||||
|
|
||||||
|
$updates = [
|
||||||
|
'access_token' => $access,
|
||||||
|
'token_expires_at' => $expiresIn > 0 ? Carbon::now()->addSeconds($expiresIn) : null,
|
||||||
|
'is_connected' => true,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (is_string($refresh) && $refresh !== '') {
|
||||||
|
$updates['refresh_token'] = $refresh;
|
||||||
|
$updates['refresh_token_expires_at'] = $refreshExpiresIn > 0
|
||||||
|
? Carbon::now()->addSeconds($refreshExpiresIn)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->config->update($updates);
|
||||||
|
$this->config->refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the token response includes exactly one company, store it to reduce admin steps.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $json
|
||||||
|
*/
|
||||||
|
private function hydrateCompanyFromTokenInfo(array $json): void
|
||||||
|
{
|
||||||
|
$companies = data_get($json, 'info.companies');
|
||||||
|
if (! is_array($companies) || count($companies) !== 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$row = $companies[0];
|
||||||
|
if (! is_array($row)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$guid = data_get($row, 'guid') ?? data_get($row, 'id');
|
||||||
|
$name = data_get($row, 'name');
|
||||||
|
if (! is_string($guid) || $guid === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->config->update([
|
||||||
|
'company_guid' => $guid,
|
||||||
|
'company_name' => is_string($name) ? $name : null,
|
||||||
|
]);
|
||||||
|
$this->config->refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $profile
|
||||||
|
* @return list<array{guid: string, name: string|null}>
|
||||||
|
*/
|
||||||
|
private function normalizeCompaniesFromProfile(array $profile): array
|
||||||
|
{
|
||||||
|
$fromInfo = data_get($profile, 'info.companies');
|
||||||
|
if (is_array($fromInfo) && $fromInfo !== []) {
|
||||||
|
$normalized = $this->normalizeCompanyRows($fromInfo);
|
||||||
|
if ($normalized !== []) {
|
||||||
|
return $normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$companies = data_get($profile, 'companies');
|
||||||
|
if (is_array($companies) && $companies !== []) {
|
||||||
|
return $this->normalizeCompanyRows($companies);
|
||||||
|
}
|
||||||
|
|
||||||
|
$defaultId = data_get($profile, 'default_company_id');
|
||||||
|
if (is_string($defaultId) && $defaultId !== '') {
|
||||||
|
return [
|
||||||
|
['guid' => $defaultId, 'name' => null],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, mixed> $rows
|
||||||
|
* @return list<array{guid: string, name: string|null}>
|
||||||
|
*/
|
||||||
|
private function normalizeCompanyRows(array $rows): array
|
||||||
|
{
|
||||||
|
$out = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
if (! is_array($row)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$guid = data_get($row, 'guid') ?? data_get($row, 'id');
|
||||||
|
if (! is_string($guid) || $guid === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$name = data_get($row, 'name');
|
||||||
|
$out[] = [
|
||||||
|
'guid' => $guid,
|
||||||
|
'name' => is_string($name) ? $name : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function logFailedResponse(string $action, string $url, Response $response): void
|
||||||
|
{
|
||||||
|
Log::error('Weeztix API request failed', [
|
||||||
|
'action' => $action,
|
||||||
|
'url' => $url,
|
||||||
|
'status' => $response->status(),
|
||||||
|
'body' => $response->json(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
40
config/weeztix.php
Normal file
40
config/weeztix.php
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| OAuth / token host (authorization code + refresh)
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Defaults match the integration spec (Open Ticket auth). Token requests
|
||||||
|
| use {auth_base_url}/tokens; authorize URL is {auth_base_url}/tokens/authorize
|
||||||
|
| when using the same host layout as Weeztix/Open Ticket.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'auth_base_url' => rtrim((string) env('WEEZTIX_AUTH_BASE_URL', 'https://auth.openticket.tech'), '/'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Weeztix API base URL
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
'api_base_url' => rtrim((string) env('WEEZTIX_API_BASE_URL', 'https://api.weeztix.com'), '/'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Current user profile (token validity + company hints)
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Documented at https://docs.weeztix.com/docs/introduction/issue-request/
|
||||||
|
| Uses auth.weeztix.com by default; override if your token issuer uses another host.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'user_profile_url' => (string) env('WEEZTIX_USER_PROFILE_URL', 'https://auth.weeztix.com/users/me'),
|
||||||
|
|
||||||
|
];
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('weeztix_configs', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('preregistration_page_id')->constrained()->cascadeOnDelete();
|
||||||
|
|
||||||
|
$table->text('client_id');
|
||||||
|
$table->text('client_secret');
|
||||||
|
$table->string('redirect_uri');
|
||||||
|
|
||||||
|
$table->text('access_token')->nullable();
|
||||||
|
$table->text('refresh_token')->nullable();
|
||||||
|
$table->timestamp('token_expires_at')->nullable();
|
||||||
|
$table->timestamp('refresh_token_expires_at')->nullable();
|
||||||
|
|
||||||
|
$table->string('company_guid')->nullable();
|
||||||
|
$table->string('company_name')->nullable();
|
||||||
|
|
||||||
|
$table->string('coupon_guid')->nullable();
|
||||||
|
$table->string('coupon_name')->nullable();
|
||||||
|
|
||||||
|
$table->string('code_prefix')->default('PREREG');
|
||||||
|
$table->integer('usage_count')->default(1);
|
||||||
|
|
||||||
|
$table->boolean('is_connected')->default(false);
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('weeztix_configs');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('subscribers', function (Blueprint $table) {
|
||||||
|
$table->string('coupon_code')->nullable()->after('synced_at');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('subscribers', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('coupon_code');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('mailwizz_configs', function (Blueprint $table) {
|
||||||
|
$table->string('field_coupon_code')->nullable()->after('field_phone');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('mailwizz_configs', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('field_coupon_code');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
709
documentation/CURSOR-PROMPT-WEEZTIX.md
Normal file
709
documentation/CURSOR-PROMPT-WEEZTIX.md
Normal file
@@ -0,0 +1,709 @@
|
|||||||
|
# Cursor Prompt — Weeztix Coupon Code Integration
|
||||||
|
|
||||||
|
> Paste this into Cursor chat with `@Codebase` and `@PreRegister-Development-Prompt.md` as context.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Role & Context
|
||||||
|
|
||||||
|
You are a senior full-stack developer and integration architect. You're working on the PreRegister Laravel 11 application (Blade + Tailwind CSS + Alpine.js). No React, no Vue, no Livewire.
|
||||||
|
|
||||||
|
The application already has a Mailwizz integration that syncs subscribers to an email marketing platform. You are now adding a **second integration: Weeztix** — a ticket sales platform. When a visitor pre-registers on a page, a unique coupon code is generated via the Weeztix API and assigned to the subscriber. This coupon code can then be forwarded to Mailwizz so the subscriber receives a personalized discount email.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 1: Weeztix Platform Concepts
|
||||||
|
|
||||||
|
### Coupon vs CouponCode
|
||||||
|
|
||||||
|
Weeztix separates these into two resources:
|
||||||
|
|
||||||
|
- **Coupon** = a template/definition. Defines the discount type (percentage, fixed amount), value, what it applies to (orders or products). The Coupon is configured by the event organizer in Weeztix dashboard.
|
||||||
|
- **CouponCode** = the actual code a visitor enters in the ticket shop to get the discount. Each CouponCode belongs to a Coupon and inherits its discount settings. CouponCodes are unique strings like `PREREG-A7X9K2`.
|
||||||
|
|
||||||
|
**In our flow:** The user selects an existing Coupon in the backend. When a visitor registers, we create a unique CouponCode under that Coupon via the API.
|
||||||
|
|
||||||
|
### Authentication: OAuth2 Authorization Code Grant
|
||||||
|
|
||||||
|
Weeztix uses the OAuth2 Authorization Code flow. Key details:
|
||||||
|
|
||||||
|
**Endpoints:**
|
||||||
|
| Action | Method | URL |
|
||||||
|
|---|---|---|
|
||||||
|
| Authorize (redirect user) | GET | `https://auth.openticket.tech/tokens/authorize` |
|
||||||
|
| Exchange code for token | POST | `https://auth.openticket.tech/tokens` |
|
||||||
|
| Refresh token | POST | `https://auth.openticket.tech/tokens` |
|
||||||
|
| API requests | Various | `https://api.weeztix.com/...` |
|
||||||
|
|
||||||
|
**Authorization redirect parameters:**
|
||||||
|
```
|
||||||
|
https://auth.openticket.tech/tokens/authorize?
|
||||||
|
client_id={OAUTH_CLIENT_ID}
|
||||||
|
&redirect_uri={OAUTH_CLIENT_REDIRECT}
|
||||||
|
&response_type=code
|
||||||
|
&state={random_state}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Token exchange (POST to `https://auth.openticket.tech/tokens`):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"client_id": "...",
|
||||||
|
"client_secret": "...",
|
||||||
|
"redirect_uri": "...",
|
||||||
|
"code": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Token response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"token_type": "Bearer",
|
||||||
|
"expires_in": 259200,
|
||||||
|
"access_token": "THE_ACTUAL_TOKEN",
|
||||||
|
"refresh_token": "REFRESH_TOKEN",
|
||||||
|
"refresh_token_expires_in": 31535999
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- `access_token` expires in ~3 days (259200 seconds)
|
||||||
|
- `refresh_token` expires in ~365 days, can only be used once
|
||||||
|
|
||||||
|
**Refresh token (POST to `https://auth.openticket.tech/tokens`):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"grant_type": "refresh_token",
|
||||||
|
"refresh_token": "...",
|
||||||
|
"client_id": "...",
|
||||||
|
"client_secret": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Returns a new `access_token` and a new `refresh_token`.
|
||||||
|
|
||||||
|
**API requests require:**
|
||||||
|
- Header: `Authorization: Bearer {access_token}`
|
||||||
|
- Header: `Company: {company_guid}` (to scope requests to a specific company)
|
||||||
|
|
||||||
|
### API Endpoints We Need
|
||||||
|
|
||||||
|
| Action | Method | URL | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Get coupons | GET | `https://api.weeztix.com/coupon` | Returns coupons for the company (Company header) |
|
||||||
|
| Add coupon codes | POST | `https://api.weeztix.com/coupon/{coupon_guid}/couponCode` | Creates one or more codes under a coupon |
|
||||||
|
|
||||||
|
**Add CouponCodes request body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"usage_count": 1,
|
||||||
|
"applies_to_count": null,
|
||||||
|
"codes": [
|
||||||
|
{
|
||||||
|
"code": "PREREG-A7X9K2"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- `usage_count`: how many times the code can be used (1 = single use per subscriber)
|
||||||
|
- `applies_to_count`: null = unlimited items in the order can use the discount
|
||||||
|
- `codes`: array of code objects, each with a unique `code` string
|
||||||
|
|
||||||
|
**Important:** Duplicate CouponCodes cannot be added to a Coupon. Generate unique codes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 2: Database Changes
|
||||||
|
|
||||||
|
### New Migration: `weeztix_configs` table
|
||||||
|
|
||||||
|
```php
|
||||||
|
Schema::create('weeztix_configs', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('preregistration_page_id')->constrained()->cascadeOnDelete();
|
||||||
|
|
||||||
|
// OAuth credentials (encrypted at rest)
|
||||||
|
$table->text('client_id');
|
||||||
|
$table->text('client_secret');
|
||||||
|
$table->string('redirect_uri');
|
||||||
|
|
||||||
|
// OAuth tokens (encrypted at rest)
|
||||||
|
$table->text('access_token')->nullable();
|
||||||
|
$table->text('refresh_token')->nullable();
|
||||||
|
$table->timestamp('token_expires_at')->nullable();
|
||||||
|
$table->timestamp('refresh_token_expires_at')->nullable();
|
||||||
|
|
||||||
|
// Company context
|
||||||
|
$table->string('company_guid')->nullable();
|
||||||
|
$table->string('company_name')->nullable();
|
||||||
|
|
||||||
|
// Selected coupon
|
||||||
|
$table->string('coupon_guid')->nullable();
|
||||||
|
$table->string('coupon_name')->nullable();
|
||||||
|
|
||||||
|
// CouponCode settings
|
||||||
|
$table->string('code_prefix')->default('PREREG'); // prefix for generated codes
|
||||||
|
$table->integer('usage_count')->default(1); // how many times a code can be used
|
||||||
|
|
||||||
|
$table->boolean('is_connected')->default(false); // OAuth flow completed?
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modify `subscribers` table
|
||||||
|
|
||||||
|
Add a column to store the generated coupon code per subscriber:
|
||||||
|
|
||||||
|
```php
|
||||||
|
Schema::table('subscribers', function (Blueprint $table) {
|
||||||
|
$table->string('coupon_code')->nullable()->after('synced_at');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Model: `WeeztixConfig`
|
||||||
|
|
||||||
|
```php
|
||||||
|
class WeeztixConfig extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'preregistration_page_id', 'client_id', 'client_secret', 'redirect_uri',
|
||||||
|
'access_token', 'refresh_token', 'token_expires_at', 'refresh_token_expires_at',
|
||||||
|
'company_guid', 'company_name', 'coupon_guid', 'coupon_name',
|
||||||
|
'code_prefix', 'usage_count', 'is_connected',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'client_id' => 'encrypted',
|
||||||
|
'client_secret' => 'encrypted',
|
||||||
|
'access_token' => 'encrypted',
|
||||||
|
'refresh_token' => 'encrypted',
|
||||||
|
'token_expires_at' => 'datetime',
|
||||||
|
'refresh_token_expires_at' => 'datetime',
|
||||||
|
'is_connected' => 'boolean',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function preregistrationPage(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(PreregistrationPage::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isTokenExpired(): bool
|
||||||
|
{
|
||||||
|
return !$this->token_expires_at || $this->token_expires_at->isPast();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isRefreshTokenExpired(): bool
|
||||||
|
{
|
||||||
|
return !$this->refresh_token_expires_at || $this->refresh_token_expires_at->isPast();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Update `PreregistrationPage` model
|
||||||
|
|
||||||
|
Add:
|
||||||
|
```php
|
||||||
|
public function weeztixConfig(): HasOne
|
||||||
|
{
|
||||||
|
return $this->hasOne(WeeztixConfig::class);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 3: WeeztixService
|
||||||
|
|
||||||
|
Create `app/Services/WeeztixService.php` — encapsulates all Weeztix API communication with automatic token refresh.
|
||||||
|
|
||||||
|
```php
|
||||||
|
class WeeztixService
|
||||||
|
{
|
||||||
|
private WeeztixConfig $config;
|
||||||
|
|
||||||
|
public function __construct(WeeztixConfig $config)
|
||||||
|
{
|
||||||
|
$this->config = $config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a valid access token, refreshing if necessary.
|
||||||
|
* Updates the config model with new tokens.
|
||||||
|
*/
|
||||||
|
public function getValidAccessToken(): string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh the access token using the refresh token.
|
||||||
|
* Stores the new tokens in the config.
|
||||||
|
* Throws exception if refresh token is also expired (re-auth needed).
|
||||||
|
*/
|
||||||
|
private function refreshAccessToken(): void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make an authenticated API request.
|
||||||
|
* Automatically refreshes token if expired.
|
||||||
|
*/
|
||||||
|
private function apiRequest(string $method, string $url, array $data = []): array
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all companies the token has access to.
|
||||||
|
* GET https://auth.weeztix.com/users/me (or similar endpoint)
|
||||||
|
*/
|
||||||
|
public function getCompanies(): array
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all coupons for the configured company.
|
||||||
|
* GET https://api.weeztix.com/coupon
|
||||||
|
* Header: Company: {company_guid}
|
||||||
|
*/
|
||||||
|
public function getCoupons(): array
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a unique coupon code under the configured coupon.
|
||||||
|
* POST https://api.weeztix.com/coupon/{coupon_guid}/couponCode
|
||||||
|
* Header: Company: {company_guid}
|
||||||
|
*/
|
||||||
|
public function createCouponCode(string $code): array
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a unique code string.
|
||||||
|
* Format: {prefix}-{random alphanumeric}
|
||||||
|
* Example: PREREG-A7X9K2
|
||||||
|
*/
|
||||||
|
public static function generateUniqueCode(string $prefix = 'PREREG', int $length = 6): string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Implementation Details for WeeztixService
|
||||||
|
|
||||||
|
**Token refresh logic:**
|
||||||
|
```
|
||||||
|
1. Check if access_token exists and is not expired
|
||||||
|
2. If expired, check if refresh_token exists and is not expired
|
||||||
|
3. If refresh_token valid: POST to https://auth.openticket.tech/tokens with grant_type=refresh_token
|
||||||
|
4. Store new access_token, refresh_token, and their expiry timestamps
|
||||||
|
5. If refresh_token also expired: mark config as disconnected, throw exception (user must re-authorize)
|
||||||
|
```
|
||||||
|
|
||||||
|
**API request wrapper:**
|
||||||
|
```php
|
||||||
|
private function apiRequest(string $method, string $url, array $data = []): array
|
||||||
|
{
|
||||||
|
$token = $this->getValidAccessToken();
|
||||||
|
|
||||||
|
$response = Http::withHeaders([
|
||||||
|
'Authorization' => "Bearer {$token}",
|
||||||
|
'Company' => $this->config->company_guid,
|
||||||
|
])->{$method}($url, $data);
|
||||||
|
|
||||||
|
if ($response->status() === 401) {
|
||||||
|
// Token might have been revoked, try refresh once
|
||||||
|
$this->refreshAccessToken();
|
||||||
|
$token = $this->config->access_token;
|
||||||
|
|
||||||
|
$response = Http::withHeaders([
|
||||||
|
'Authorization' => "Bearer {$token}",
|
||||||
|
'Company' => $this->config->company_guid,
|
||||||
|
])->{$method}($url, $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($response->failed()) {
|
||||||
|
Log::error('Weeztix API request failed', [
|
||||||
|
'url' => $url,
|
||||||
|
'status' => $response->status(),
|
||||||
|
'body' => $response->json(),
|
||||||
|
]);
|
||||||
|
throw new \RuntimeException("Weeztix API request failed: {$response->status()}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response->json();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Unique code generation:**
|
||||||
|
```php
|
||||||
|
public static function generateUniqueCode(string $prefix = 'PREREG', int $length = 6): string
|
||||||
|
{
|
||||||
|
$chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // no 0/O/1/I to avoid confusion
|
||||||
|
$code = '';
|
||||||
|
for ($i = 0; $i < $length; $i++) {
|
||||||
|
$code .= $chars[random_int(0, strlen($chars) - 1)];
|
||||||
|
}
|
||||||
|
return strtoupper($prefix) . '-' . $code;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 4: OAuth Flow — Routes & Controller
|
||||||
|
|
||||||
|
### Routes
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Inside the admin auth middleware group:
|
||||||
|
|
||||||
|
// Weeztix configuration (per page)
|
||||||
|
Route::get('pages/{page}/weeztix', [WeeztixController::class, 'edit'])->name('pages.weeztix.edit');
|
||||||
|
Route::put('pages/{page}/weeztix', [WeeztixController::class, 'update'])->name('pages.weeztix.update');
|
||||||
|
Route::delete('pages/{page}/weeztix', [WeeztixController::class, 'destroy'])->name('pages.weeztix.destroy');
|
||||||
|
|
||||||
|
// OAuth callback (needs to be accessible during OAuth flow)
|
||||||
|
Route::get('weeztix/callback', [WeeztixOAuthController::class, 'callback'])->name('weeztix.callback');
|
||||||
|
|
||||||
|
// AJAX endpoints for dynamic loading
|
||||||
|
Route::post('weeztix/coupons', [WeeztixApiController::class, 'coupons'])->name('weeztix.coupons');
|
||||||
|
```
|
||||||
|
|
||||||
|
### OAuth Flow
|
||||||
|
|
||||||
|
**Step 1: User enters client_id and client_secret in the backend form.**
|
||||||
|
|
||||||
|
**Step 2: User clicks "Connect to Weeztix" button.**
|
||||||
|
|
||||||
|
The controller builds the authorization URL and redirects:
|
||||||
|
```php
|
||||||
|
public function redirect(PreregistrationPage $page)
|
||||||
|
{
|
||||||
|
$config = $page->weeztixConfig;
|
||||||
|
$state = Str::random(40);
|
||||||
|
|
||||||
|
// Store state + page ID in session for the callback
|
||||||
|
session(['weeztix_oauth_state' => $state, 'weeztix_page_id' => $page->id]);
|
||||||
|
|
||||||
|
$query = http_build_query([
|
||||||
|
'client_id' => $config->client_id,
|
||||||
|
'redirect_uri' => route('admin.weeztix.callback'),
|
||||||
|
'response_type' => 'code',
|
||||||
|
'state' => $state,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect("https://auth.openticket.tech/tokens/authorize?{$query}");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Weeztix redirects back to our callback URL with `code` and `state`.**
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function callback(Request $request)
|
||||||
|
{
|
||||||
|
// Verify state
|
||||||
|
$storedState = session('weeztix_oauth_state');
|
||||||
|
$pageId = session('weeztix_page_id');
|
||||||
|
abort_if($request->state !== $storedState, 403, 'Invalid state');
|
||||||
|
|
||||||
|
// Exchange code for tokens
|
||||||
|
$response = Http::post('https://auth.openticket.tech/tokens', [
|
||||||
|
'grant_type' => 'authorization_code',
|
||||||
|
'client_id' => $config->client_id,
|
||||||
|
'client_secret' => $config->client_secret,
|
||||||
|
'redirect_uri' => route('admin.weeztix.callback'),
|
||||||
|
'code' => $request->code,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Store tokens in weeztix_configs
|
||||||
|
$config->update([
|
||||||
|
'access_token' => $data['access_token'],
|
||||||
|
'refresh_token' => $data['refresh_token'],
|
||||||
|
'token_expires_at' => now()->addSeconds($data['expires_in']),
|
||||||
|
'refresh_token_expires_at' => now()->addSeconds($data['refresh_token_expires_in']),
|
||||||
|
'is_connected' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Redirect back to the Weeztix config page
|
||||||
|
return redirect()->route('admin.pages.weeztix.edit', $pageId)
|
||||||
|
->with('success', 'Successfully connected to Weeztix!');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: After OAuth, the user selects a Company and Coupon (see Part 5).**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 5: Backend Configuration UI
|
||||||
|
|
||||||
|
### Weeztix Configuration Page (tab/section within page edit)
|
||||||
|
|
||||||
|
Build a multi-step configuration interface similar to the Mailwizz config:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─── Weeztix Integration ────────────────────────────────┐
|
||||||
|
│ │
|
||||||
|
│ Step 1: OAuth Credentials │
|
||||||
|
│ ┌────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Client ID: [________________________] │ │
|
||||||
|
│ │ Client Secret: [________________________] │ │
|
||||||
|
│ │ Redirect URI: https://preregister.crewli.nl │ │
|
||||||
|
│ │ /admin/weeztix/callback │ │
|
||||||
|
│ │ (auto-generated, read-only) │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ [Connect to Weeztix] ← OAuth redirect button │ │
|
||||||
|
│ └────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ Step 2: Select Coupon (shown after OAuth success) │
|
||||||
|
│ ┌────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Status: ✅ Connected │ │
|
||||||
|
│ │ Token expires: 2026-04-07 14:30 │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ Coupon: [▼ Select a coupon ] │ │
|
||||||
|
│ │ - Early Bird 20% discount │ │
|
||||||
|
│ │ - Pre-register €5 korting │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ Code Prefix: [PREREG ] │ │
|
||||||
|
│ │ Usage per Code: [1] │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ [Save Configuration] │ │
|
||||||
|
│ └────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ [Disconnect Weeztix] │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Instructions to show the user:**
|
||||||
|
> "Before connecting, create an OAuth Client in your Weeztix dashboard and set the redirect URI to: `{route('admin.weeztix.callback')}`. Also create a Coupon with the desired discount settings. You will select this Coupon here after connecting."
|
||||||
|
|
||||||
|
**Loading Coupons (AJAX):**
|
||||||
|
After successful OAuth, use Alpine.js to fetch coupons via:
|
||||||
|
```
|
||||||
|
POST /admin/weeztix/coupons
|
||||||
|
Body: { page_id: ... }
|
||||||
|
```
|
||||||
|
The controller uses `WeeztixService::getCoupons()` and returns the list as JSON.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 6: Frontend — Coupon Code Generation on Registration
|
||||||
|
|
||||||
|
### Updated Subscription Flow
|
||||||
|
|
||||||
|
When a visitor registers on a public page:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Validate form input
|
||||||
|
2. Check for duplicate email
|
||||||
|
3. Store subscriber in database
|
||||||
|
4. IF Weeztix is configured AND connected:
|
||||||
|
a. Generate a unique coupon code: PREREG-A7X9K2
|
||||||
|
b. Create the coupon code in Weeztix via API
|
||||||
|
c. Store the coupon code on the subscriber record
|
||||||
|
5. IF Mailwizz is configured:
|
||||||
|
a. Dispatch SyncSubscriberToMailwizz job
|
||||||
|
b. The job should include the coupon_code in the Mailwizz subscriber data
|
||||||
|
(if a Mailwizz field mapping for coupon_code is configured)
|
||||||
|
6. Return success with thank-you message
|
||||||
|
```
|
||||||
|
|
||||||
|
### Job: `CreateWeeztixCouponCode`
|
||||||
|
|
||||||
|
Create a queued job (or execute synchronously if speed is critical — the visitor should see the coupon code in the thank-you message):
|
||||||
|
|
||||||
|
```php
|
||||||
|
class CreateWeeztixCouponCode implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private Subscriber $subscriber
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
$page = $this->subscriber->preregistrationPage;
|
||||||
|
$config = $page->weeztixConfig;
|
||||||
|
|
||||||
|
if (!$config || !$config->is_connected || !$config->coupon_guid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$service = new WeeztixService($config);
|
||||||
|
$code = WeeztixService::generateUniqueCode($config->code_prefix);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$service->createCouponCode($code);
|
||||||
|
|
||||||
|
$this->subscriber->update(['coupon_code' => $code]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Failed to create Weeztix coupon code', [
|
||||||
|
'subscriber_id' => $this->subscriber->id,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
throw $e; // Let the job retry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**IMPORTANT DECISION: Sync vs Async**
|
||||||
|
|
||||||
|
If the coupon code should be shown in the thank-you message immediately after registration, the Weeztix API call must be **synchronous** (not queued). In that case, call the service directly in `PublicPageController@subscribe` instead of dispatching a job:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// In PublicPageController@subscribe, after storing the subscriber:
|
||||||
|
if ($page->weeztixConfig && $page->weeztixConfig->is_connected) {
|
||||||
|
try {
|
||||||
|
$service = new WeeztixService($page->weeztixConfig);
|
||||||
|
$code = WeeztixService::generateUniqueCode($page->weeztixConfig->code_prefix);
|
||||||
|
$service->createCouponCode($code);
|
||||||
|
$subscriber->update(['coupon_code' => $code]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Weeztix coupon creation failed', ['error' => $e->getMessage()]);
|
||||||
|
// Don't fail the registration — the subscriber is already saved
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then dispatch the Mailwizz sync job (which includes the coupon_code)
|
||||||
|
if ($page->mailwizzConfig) {
|
||||||
|
SyncSubscriberToMailwizz::dispatch($subscriber->fresh());
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => $page->thank_you_message ?? 'Bedankt voor je registratie!',
|
||||||
|
'coupon_code' => $subscriber->coupon_code, // null if Weeztix not configured
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Show Coupon Code in Thank-You State
|
||||||
|
|
||||||
|
Update the Alpine.js success state to display the coupon code if present:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div x-show="submitted" class="text-center text-white">
|
||||||
|
<p x-text="successMessage"></p>
|
||||||
|
|
||||||
|
<template x-if="couponCode">
|
||||||
|
<div class="mt-6 bg-white/10 backdrop-blur rounded-xl p-6 border border-white/20">
|
||||||
|
<p class="text-sm text-white/70 mb-2">Jouw kortingscode:</p>
|
||||||
|
<div class="flex items-center justify-center gap-3">
|
||||||
|
<span class="text-2xl font-mono font-bold tracking-wider text-orange-400"
|
||||||
|
x-text="couponCode"></span>
|
||||||
|
<button @click="copyCode()"
|
||||||
|
class="text-white/50 hover:text-white transition">
|
||||||
|
<!-- Copy icon (Heroicon clipboard) -->
|
||||||
|
<svg>...</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-white/50 mt-3">
|
||||||
|
Gebruik deze code bij het afrekenen in de ticketshop.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 7: Mailwizz Integration — Forward Coupon Code
|
||||||
|
|
||||||
|
### Add Coupon Code Field Mapping to Mailwizz Config
|
||||||
|
|
||||||
|
Add a new field to `mailwizz_configs`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// New migration
|
||||||
|
Schema::table('mailwizz_configs', function (Blueprint $table) {
|
||||||
|
$table->string('field_coupon_code')->nullable()->after('field_phone');
|
||||||
|
// This maps to a Mailwizz custom field (e.g., 'COUPON' tag)
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Update Mailwizz Configuration Wizard
|
||||||
|
|
||||||
|
In the field mapping step, add an additional mapping:
|
||||||
|
- **Coupon Code** → show text fields from the Mailwizz list → let user select the field where the coupon code should be stored (e.g., a custom field with tag `COUPON`)
|
||||||
|
- This mapping is optional — only shown if Weeztix is also configured
|
||||||
|
|
||||||
|
### Update SyncSubscriberToMailwizz Job
|
||||||
|
|
||||||
|
When building the Mailwizz subscriber data array, include the coupon code if the mapping exists:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// In the sync job, when building the data array:
|
||||||
|
if ($config->field_coupon_code && $subscriber->coupon_code) {
|
||||||
|
$data[$config->field_coupon_code] = $subscriber->coupon_code;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This allows the Mailwizz email template to include `[COUPON]` as a merge tag, so each subscriber receives their personal coupon code in the email.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 8: Subscriber Management — Show Coupon Codes
|
||||||
|
|
||||||
|
### Update Subscribers Index
|
||||||
|
|
||||||
|
Add the coupon code column to the subscribers table in the backend:
|
||||||
|
|
||||||
|
| First Name | Last Name | Email | Phone | Coupon Code | Synced | Registered |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| Bert | Hausmans | bert@... | +316... | PREREG-A7X9K2 | ✅ | 2026-04-04 |
|
||||||
|
|
||||||
|
### Update CSV Export
|
||||||
|
|
||||||
|
Add the `coupon_code` column to the CSV export.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 9: Implementation Order
|
||||||
|
|
||||||
|
### Step 1: Database
|
||||||
|
- Create `weeztix_configs` migration
|
||||||
|
- Add `coupon_code` to `subscribers` migration
|
||||||
|
- Add `field_coupon_code` to `mailwizz_configs` migration
|
||||||
|
- Create `WeeztixConfig` model
|
||||||
|
- Update `PreregistrationPage` model with `weeztixConfig` relationship
|
||||||
|
- Run migrations
|
||||||
|
|
||||||
|
### Step 2: WeeztixService
|
||||||
|
- Create `app/Services/WeeztixService.php`
|
||||||
|
- Implement token management (get valid token, refresh, detect expiry)
|
||||||
|
- Implement `getCoupons()` and `createCouponCode()`
|
||||||
|
- Add unique code generation
|
||||||
|
|
||||||
|
### Step 3: OAuth Flow
|
||||||
|
- Create `WeeztixOAuthController` (redirect + callback)
|
||||||
|
- Add routes for OAuth
|
||||||
|
- Handle state validation and token storage
|
||||||
|
|
||||||
|
### Step 4: Backend Configuration UI
|
||||||
|
- Create `WeeztixController` (edit, update, destroy)
|
||||||
|
- Create `WeeztixApiController` (coupons AJAX endpoint)
|
||||||
|
- Create Blade view `admin/weeztix/edit.blade.php`
|
||||||
|
- Build the multi-step form with Alpine.js
|
||||||
|
- Add link/tab in the page edit navigation
|
||||||
|
|
||||||
|
### Step 5: Frontend Coupon Generation
|
||||||
|
- Update `PublicPageController@subscribe` to generate coupon codes
|
||||||
|
- Update Alpine.js success state to show coupon code
|
||||||
|
- Add copy-to-clipboard functionality
|
||||||
|
|
||||||
|
### Step 6: Mailwizz Coupon Forwarding
|
||||||
|
- Update Mailwizz config migration and model
|
||||||
|
- Update Mailwizz configuration wizard (add coupon_code field mapping)
|
||||||
|
- Update `SyncSubscriberToMailwizz` job to include coupon code
|
||||||
|
|
||||||
|
### Step 7: Backend Updates
|
||||||
|
- Update subscribers index to show coupon codes
|
||||||
|
- Update CSV export to include coupon codes
|
||||||
|
- Add Weeztix connection status indicator on the pages index
|
||||||
|
|
||||||
|
### Step 8: Error Handling & Edge Cases
|
||||||
|
- Handle Weeztix API downtime (don't fail registration)
|
||||||
|
- Handle expired refresh tokens (show "Reconnect" button)
|
||||||
|
- Handle duplicate coupon codes (retry with new code)
|
||||||
|
- Handle Weeztix rate limiting
|
||||||
|
- Log all API interactions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Important Rules
|
||||||
|
|
||||||
|
- **OAuth credentials must be encrypted** — use Laravel's `encrypted` cast
|
||||||
|
- **Token refresh must be automatic** — the user should never have to manually refresh
|
||||||
|
- **Registration must not fail** if Weeztix is down — coupon code is a bonus, not a requirement
|
||||||
|
- **Coupon codes must be unique** — use the unambiguous character set (no 0/O/1/I)
|
||||||
|
- **Blade + Alpine.js + Tailwind only** — no additional JS frameworks
|
||||||
|
- **All text in Dutch** — labels, messages, instructions
|
||||||
|
- **Don't break existing integrations** — Mailwizz sync must continue working, form blocks must remain functional
|
||||||
|
- **Refer to the Weeztix API documentation** for exact request/response formats:
|
||||||
|
- Authentication: https://docs.weeztix.com/docs/introduction/authentication/
|
||||||
|
- Request token: https://docs.weeztix.com/docs/introduction/authentication/request-token
|
||||||
|
- Refresh token: https://docs.weeztix.com/docs/introduction/authentication/refresh-token
|
||||||
|
- Get Coupons: https://docs.weeztix.com/api/dashboard/get-coupons
|
||||||
|
- Add CouponCodes: https://docs.weeztix.com/api/dashboard/add-coupon-codes
|
||||||
|
- Issuing requests (Company header): https://docs.weeztix.com/docs/introduction/issue-request/
|
||||||
@@ -420,6 +420,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
redirectSecondsLeft: null,
|
redirectSecondsLeft: null,
|
||||||
redirectTimer: null,
|
redirectTimer: null,
|
||||||
strings: config.strings || {},
|
strings: config.strings || {},
|
||||||
|
couponCode: '',
|
||||||
|
|
||||||
copyPageLink() {
|
copyPageLink() {
|
||||||
const url = this.pageShareUrl;
|
const url = this.pageShareUrl;
|
||||||
@@ -434,6 +435,19 @@ 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() {
|
init() {
|
||||||
if (this.phase === 'before') {
|
if (this.phase === 'before') {
|
||||||
this.tickCountdown();
|
this.tickCountdown();
|
||||||
@@ -552,6 +566,8 @@ document.addEventListener('alpine:init', () => {
|
|||||||
if (res.ok && data.success) {
|
if (res.ok && data.success) {
|
||||||
this.phase = 'thanks';
|
this.phase = 'thanks';
|
||||||
this.thankYouMessage = data.message ?? '';
|
this.thankYouMessage = data.message ?? '';
|
||||||
|
this.couponCode =
|
||||||
|
typeof data.coupon_code === 'string' && data.coupon_code !== '' ? data.coupon_code : '';
|
||||||
this.startRedirectCountdownIfNeeded();
|
this.startRedirectCountdownIfNeeded();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -574,6 +590,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
fieldsUrl: cfg.fieldsUrl,
|
fieldsUrl: cfg.fieldsUrl,
|
||||||
phoneEnabled: cfg.phoneEnabled,
|
phoneEnabled: cfg.phoneEnabled,
|
||||||
hasExistingConfig: cfg.hasExistingConfig,
|
hasExistingConfig: cfg.hasExistingConfig,
|
||||||
|
hasWeeztixIntegration: cfg.hasWeeztixIntegration === true,
|
||||||
existing: cfg.existing,
|
existing: cfg.existing,
|
||||||
csrf: cfg.csrf,
|
csrf: cfg.csrf,
|
||||||
step: 1,
|
step: 1,
|
||||||
@@ -586,6 +603,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
fieldFirstName: '',
|
fieldFirstName: '',
|
||||||
fieldLastName: '',
|
fieldLastName: '',
|
||||||
fieldPhone: '',
|
fieldPhone: '',
|
||||||
|
fieldCouponCode: '',
|
||||||
tagField: '',
|
tagField: '',
|
||||||
tagValue: '',
|
tagValue: '',
|
||||||
loading: false,
|
loading: false,
|
||||||
@@ -597,6 +615,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
this.fieldFirstName = this.existing.field_first_name ?? '';
|
this.fieldFirstName = this.existing.field_first_name ?? '';
|
||||||
this.fieldLastName = this.existing.field_last_name ?? '';
|
this.fieldLastName = this.existing.field_last_name ?? '';
|
||||||
this.fieldPhone = this.existing.field_phone ?? '';
|
this.fieldPhone = this.existing.field_phone ?? '';
|
||||||
|
this.fieldCouponCode = this.existing.field_coupon_code ?? '';
|
||||||
this.tagField = this.existing.tag_field ?? '';
|
this.tagField = this.existing.tag_field ?? '';
|
||||||
this.tagValue = this.existing.tag_value ?? '';
|
this.tagValue = this.existing.tag_value ?? '';
|
||||||
this.selectedListUid = this.existing.list_uid ?? '';
|
this.selectedListUid = this.existing.list_uid ?? '';
|
||||||
@@ -706,6 +725,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
this.fieldFirstName = this.existing.field_first_name || this.fieldFirstName;
|
this.fieldFirstName = this.existing.field_first_name || this.fieldFirstName;
|
||||||
this.fieldLastName = this.existing.field_last_name || this.fieldLastName;
|
this.fieldLastName = this.existing.field_last_name || this.fieldLastName;
|
||||||
this.fieldPhone = this.existing.field_phone || this.fieldPhone;
|
this.fieldPhone = this.existing.field_phone || this.fieldPhone;
|
||||||
|
this.fieldCouponCode = this.existing.field_coupon_code || this.fieldCouponCode;
|
||||||
this.tagField = this.existing.tag_field || this.tagField;
|
this.tagField = this.existing.tag_field || this.tagField;
|
||||||
this.tagValue = this.existing.tag_value || this.tagValue;
|
this.tagValue = this.existing.tag_value || this.tagValue;
|
||||||
}
|
}
|
||||||
@@ -746,6 +766,104 @@ document.addEventListener('alpine:init', () => {
|
|||||||
this.$refs.saveForm.requestSubmit();
|
this.$refs.saveForm.requestSubmit();
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
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,
|
||||||
|
strings: cfg.strings || {},
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
if (cfg.existing) {
|
||||||
|
this.codePrefix = cfg.existing.code_prefix || 'PREREG';
|
||||||
|
this.usageCount =
|
||||||
|
typeof cfg.existing.usage_count === 'number' ? cfg.existing.usage_count : 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async postJson(url, body) {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
|
'X-CSRF-TOKEN': this.csrf,
|
||||||
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
return { res, data };
|
||||||
|
},
|
||||||
|
|
||||||
|
syncCompanyNameFromSelection() {
|
||||||
|
const c = this.companies.find((x) => x.guid === this.companyGuid);
|
||||||
|
this.companyName = c && c.name ? c.name : '';
|
||||||
|
},
|
||||||
|
|
||||||
|
syncCouponName() {
|
||||||
|
const c = this.coupons.find((x) => x.guid === this.couponGuid);
|
||||||
|
this.couponName = c ? c.name : '';
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadCompanies() {
|
||||||
|
this.errorMessage = '';
|
||||||
|
const { res, data } = await this.postJson(this.companiesUrl, { page_id: this.pageId });
|
||||||
|
if (!res.ok) {
|
||||||
|
this.errorMessage = data.message || this.strings.genericError;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.companies = Array.isArray(data.companies) ? data.companies : [];
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
this.errorMessage = data.message || this.strings.loadCouponsError;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.coupons = Array.isArray(data.coupons) ? data.coupons : [];
|
||||||
|
this.syncCouponName();
|
||||||
|
},
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
window.Alpine = Alpine;
|
window.Alpine = Alpine;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
@php
|
@php
|
||||||
$config = $page->mailwizzConfig;
|
$config = $page->mailwizzConfig;
|
||||||
|
$page->loadMissing('weeztixConfig');
|
||||||
|
$hasWeeztixForCouponMap = $page->weeztixConfig !== null && $page->weeztixConfig->is_connected;
|
||||||
$existing = $config !== null
|
$existing = $config !== null
|
||||||
? [
|
? [
|
||||||
'list_uid' => $config->list_uid,
|
'list_uid' => $config->list_uid,
|
||||||
@@ -8,6 +10,7 @@
|
|||||||
'field_first_name' => $config->field_first_name,
|
'field_first_name' => $config->field_first_name,
|
||||||
'field_last_name' => $config->field_last_name,
|
'field_last_name' => $config->field_last_name,
|
||||||
'field_phone' => $config->field_phone,
|
'field_phone' => $config->field_phone,
|
||||||
|
'field_coupon_code' => $config->field_coupon_code,
|
||||||
'tag_field' => $config->tag_field,
|
'tag_field' => $config->tag_field,
|
||||||
'tag_value' => $config->tag_value,
|
'tag_value' => $config->tag_value,
|
||||||
]
|
]
|
||||||
@@ -27,6 +30,7 @@
|
|||||||
'csrf' => csrf_token(),
|
'csrf' => csrf_token(),
|
||||||
'phoneEnabled' => $page->isPhoneFieldEnabledForSubscribers(),
|
'phoneEnabled' => $page->isPhoneFieldEnabledForSubscribers(),
|
||||||
'hasExistingConfig' => $config !== null,
|
'hasExistingConfig' => $config !== null,
|
||||||
|
'hasWeeztixIntegration' => $hasWeeztixForCouponMap,
|
||||||
'existing' => $existing,
|
'existing' => $existing,
|
||||||
'strings' => [
|
'strings' => [
|
||||||
'apiKeyRequired' => __('Enter your Mailwizz API key to continue.'),
|
'apiKeyRequired' => __('Enter your Mailwizz API key to continue.'),
|
||||||
@@ -190,6 +194,16 @@
|
|||||||
</template>
|
</template>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<label class="block text-sm font-medium text-slate-700">{{ __('Tag / source (checkbox list)') }}</label>
|
<label class="block text-sm font-medium text-slate-700">{{ __('Tag / source (checkbox list)') }}</label>
|
||||||
<select
|
<select
|
||||||
@@ -244,6 +258,7 @@
|
|||||||
<input type="hidden" name="field_first_name" x-bind:value="fieldFirstName">
|
<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_last_name" x-bind:value="fieldLastName">
|
||||||
<input type="hidden" name="field_phone" x-bind:value="phoneEnabled ? fieldPhone : ''">
|
<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_field" x-bind:value="tagField">
|
||||||
<input type="hidden" name="tag_value" x-bind:value="tagValue">
|
<input type="hidden" name="tag_value" x-bind:value="tagValue">
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,9 @@
|
|||||||
{{ __('Public URL') }}: <a href="{{ route('public.page', ['publicPage' => $page]) }}" class="text-indigo-600 hover:underline" target="_blank" rel="noopener">{{ url('/r/'.$page->slug) }}</a>
|
{{ __('Public URL') }}: <a href="{{ route('public.page', ['publicPage' => $page]) }}" class="text-indigo-600 hover:underline" target="_blank" rel="noopener">{{ url('/r/'.$page->slug) }}</a>
|
||||||
</p>
|
</p>
|
||||||
@can('update', $page)
|
@can('update', $page)
|
||||||
<p class="mt-3">
|
<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.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>
|
</p>
|
||||||
@endcan
|
@endcan
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -75,6 +75,12 @@
|
|||||||
@can('update', $page)
|
@can('update', $page)
|
||||||
<a href="{{ route('admin.pages.mailwizz.edit', $page) }}" class="text-indigo-600 hover:text-indigo-500">{{ __('Mailwizz') }}</a>
|
<a href="{{ route('admin.pages.mailwizz.edit', $page) }}" class="text-indigo-600 hover:text-indigo-500">{{ __('Mailwizz') }}</a>
|
||||||
@endcan
|
@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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
x-data="{ copied: false }"
|
x-data="{ copied: false }"
|
||||||
|
|||||||
@@ -52,6 +52,7 @@
|
|||||||
@if ($page->isPhoneFieldEnabledForSubscribers())
|
@if ($page->isPhoneFieldEnabledForSubscribers())
|
||||||
<th class="px-4 py-3 font-semibold text-slate-700">{{ __('Phone') }}</th>
|
<th class="px-4 py-3 font-semibold text-slate-700">{{ __('Phone') }}</th>
|
||||||
@endif
|
@endif
|
||||||
|
<th class="px-4 py-3 font-semibold text-slate-700">{{ __('Kortingscode') }}</th>
|
||||||
<th class="px-4 py-3 font-semibold text-slate-700">{{ __('Registered at') }}</th>
|
<th class="px-4 py-3 font-semibold text-slate-700">{{ __('Registered at') }}</th>
|
||||||
<th class="px-4 py-3 font-semibold text-slate-700">{{ __('Mailwizz') }}</th>
|
<th class="px-4 py-3 font-semibold text-slate-700">{{ __('Mailwizz') }}</th>
|
||||||
<th class="w-px whitespace-nowrap px-4 py-3 font-semibold text-slate-700">{{ __('Actions') }}</th>
|
<th class="w-px whitespace-nowrap px-4 py-3 font-semibold text-slate-700">{{ __('Actions') }}</th>
|
||||||
@@ -66,6 +67,7 @@
|
|||||||
@if ($page->isPhoneFieldEnabledForSubscribers())
|
@if ($page->isPhoneFieldEnabledForSubscribers())
|
||||||
<td class="px-4 py-3 text-slate-600">{{ $subscriber->phoneDisplay() ?? '—' }}</td>
|
<td class="px-4 py-3 text-slate-600">{{ $subscriber->phoneDisplay() ?? '—' }}</td>
|
||||||
@endif
|
@endif
|
||||||
|
<td class="px-4 py-3 font-mono text-xs text-slate-700">{{ $subscriber->coupon_code ?? '—' }}</td>
|
||||||
<td class="whitespace-nowrap px-4 py-3 text-slate-600">{{ $subscriber->created_at->timezone(config('app.timezone'))->format('Y-m-d H:i') }}</td>
|
<td class="whitespace-nowrap px-4 py-3 text-slate-600">{{ $subscriber->created_at->timezone(config('app.timezone'))->format('Y-m-d H:i') }}</td>
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
@if ($subscriber->synced_to_mailwizz)
|
@if ($subscriber->synced_to_mailwizz)
|
||||||
@@ -100,7 +102,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
@empty
|
@empty
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="{{ $page->isPhoneFieldEnabledForSubscribers() ? 7 : 6 }}" class="px-4 py-12 text-center text-slate-500">
|
<td colspan="{{ $page->isPhoneFieldEnabledForSubscribers() ? 8 : 7 }}" class="px-4 py-12 text-center text-slate-500">
|
||||||
{{ __('No subscribers match your criteria.') }}
|
{{ __('No subscribers match your criteria.') }}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
226
resources/views/admin/weeztix/edit.blade.php
Normal file
226
resources/views/admin/weeztix/edit.blade.php
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
@php
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
$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;
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
@extends('layouts.admin')
|
||||||
|
|
||||||
|
@section('title', __('Weeztix') . ' — ' . $page->title)
|
||||||
|
|
||||||
|
@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="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>
|
||||||
|
<p class="mt-2 text-sm text-slate-600">{{ __('Pagina:') }} <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">{{ __('Controleer het volgende:') }}</p>
|
||||||
|
<ul class="mt-2 list-disc space-y-1 pl-5">
|
||||||
|
@foreach ($errors->all() as $message)
|
||||||
|
<li>{{ $message }}</li>
|
||||||
|
@endforeach
|
||||||
|
</ul>
|
||||||
|
</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">
|
||||||
|
{{ __('Toegangstoken verloopt om:') }}
|
||||||
|
<span class="font-mono text-xs">{{ $wz->token_expires_at->timezone(config('app.timezone'))->format('Y-m-d H:i') }}</span>
|
||||||
|
</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 ($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">
|
||||||
|
@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>
|
||||||
|
<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') }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
@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>
|
||||||
|
</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="(c.name || c.guid)"></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>
|
||||||
|
@endsection
|
||||||
@@ -156,6 +156,30 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="whitespace-pre-line text-center text-base leading-relaxed text-white/95 sm:text-lg" x-text="thankYouMessage"></p>
|
<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
|
<p
|
||||||
x-show="redirectSecondsLeft !== null && redirectSecondsLeft > 0"
|
x-show="redirectSecondsLeft !== null && redirectSecondsLeft > 0"
|
||||||
x-cloak
|
x-cloak
|
||||||
|
|||||||
@@ -71,6 +71,7 @@
|
|||||||
'redirectUrl' => filled($redirectAfterSubmit) ? $redirectAfterSubmit : null,
|
'redirectUrl' => filled($redirectAfterSubmit) ? $redirectAfterSubmit : null,
|
||||||
'strings' => [
|
'strings' => [
|
||||||
'linkCopied' => __('Link gekopieerd!'),
|
'linkCopied' => __('Link gekopieerd!'),
|
||||||
|
'couponCopied' => __('Kortingscode gekopieerd!'),
|
||||||
'redirectCountdown' => __('You will be redirected in :seconds s…'),
|
'redirectCountdown' => __('You will be redirected in :seconds s…'),
|
||||||
],
|
],
|
||||||
]))"
|
]))"
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ use App\Http\Controllers\Admin\MailwizzController;
|
|||||||
use App\Http\Controllers\Admin\PageController;
|
use App\Http\Controllers\Admin\PageController;
|
||||||
use App\Http\Controllers\Admin\SubscriberController;
|
use App\Http\Controllers\Admin\SubscriberController;
|
||||||
use App\Http\Controllers\Admin\UserController;
|
use App\Http\Controllers\Admin\UserController;
|
||||||
|
use App\Http\Controllers\Admin\WeeztixApiController;
|
||||||
|
use App\Http\Controllers\Admin\WeeztixController;
|
||||||
|
use App\Http\Controllers\Admin\WeeztixOAuthController;
|
||||||
use App\Http\Controllers\ProfileController;
|
use App\Http\Controllers\ProfileController;
|
||||||
use App\Http\Controllers\PublicPageController;
|
use App\Http\Controllers\PublicPageController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
@@ -46,6 +49,16 @@ Route::middleware(['auth', 'verified'])->prefix('admin')->name('admin.')->group(
|
|||||||
Route::post('mailwizz/lists', [MailwizzApiController::class, 'lists'])->name('mailwizz.lists');
|
Route::post('mailwizz/lists', [MailwizzApiController::class, 'lists'])->name('mailwizz.lists');
|
||||||
Route::post('mailwizz/fields', [MailwizzApiController::class, 'fields'])->name('mailwizz.fields');
|
Route::post('mailwizz/fields', [MailwizzApiController::class, 'fields'])->name('mailwizz.fields');
|
||||||
|
|
||||||
|
// Weeztix configuration (nested under pages)
|
||||||
|
Route::get('pages/{page}/weeztix', [WeeztixController::class, 'edit'])->name('pages.weeztix.edit');
|
||||||
|
Route::put('pages/{page}/weeztix', [WeeztixController::class, 'update'])->name('pages.weeztix.update');
|
||||||
|
Route::delete('pages/{page}/weeztix', [WeeztixController::class, 'destroy'])->name('pages.weeztix.destroy');
|
||||||
|
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)
|
// User management (superadmin only)
|
||||||
Route::middleware('role:superadmin')->group(function () {
|
Route::middleware('role:superadmin')->group(function () {
|
||||||
Route::resource('users', UserController::class)->except(['show']);
|
Route::resource('users', UserController::class)->except(['show']);
|
||||||
|
|||||||
@@ -133,6 +133,43 @@ class SyncSubscriberToMailwizzTest extends TestCase
|
|||||||
$this->assertTrue($subscriber->synced_to_mailwizz);
|
$this->assertTrue($subscriber->synced_to_mailwizz);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_mailwizz_sync_includes_coupon_code_when_mapped(): void
|
||||||
|
{
|
||||||
|
Http::fake(function (Request $request) {
|
||||||
|
$url = $request->url();
|
||||||
|
if (str_contains($url, 'search-by-email')) {
|
||||||
|
return Http::response(['status' => 'error']);
|
||||||
|
}
|
||||||
|
if ($request->method() === 'POST' && preg_match('#/lists/[^/]+/subscribers$#', $url) === 1) {
|
||||||
|
$body = $request->body();
|
||||||
|
$this->assertStringContainsString('COUPON', $body);
|
||||||
|
$this->assertStringContainsString('PREREG-TEST99', $body);
|
||||||
|
|
||||||
|
return Http::response(['status' => 'success']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Http::response(['status' => 'error'], 500);
|
||||||
|
});
|
||||||
|
|
||||||
|
$page = $this->makePageWithMailwizz([
|
||||||
|
'field_coupon_code' => 'COUPON',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$subscriber = Subscriber::query()->create([
|
||||||
|
'preregistration_page_id' => $page->id,
|
||||||
|
'first_name' => 'Coupon',
|
||||||
|
'last_name' => 'User',
|
||||||
|
'email' => 'coupon-map@example.com',
|
||||||
|
'coupon_code' => 'PREREG-TEST99',
|
||||||
|
'synced_to_mailwizz' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
SyncSubscriberToMailwizz::dispatchSync($subscriber);
|
||||||
|
|
||||||
|
$subscriber->refresh();
|
||||||
|
$this->assertTrue($subscriber->synced_to_mailwizz);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $configOverrides
|
* @param array<string, mixed> $configOverrides
|
||||||
*/
|
*/
|
||||||
@@ -164,6 +201,7 @@ class SyncSubscriberToMailwizzTest extends TestCase
|
|||||||
'field_first_name' => 'FNAME',
|
'field_first_name' => 'FNAME',
|
||||||
'field_last_name' => 'LNAME',
|
'field_last_name' => 'LNAME',
|
||||||
'field_phone' => null,
|
'field_phone' => null,
|
||||||
|
'field_coupon_code' => null,
|
||||||
'tag_field' => 'TAGS',
|
'tag_field' => 'TAGS',
|
||||||
'tag_value' => 'preregister-source',
|
'tag_value' => 'preregister-source',
|
||||||
], $configOverrides));
|
], $configOverrides));
|
||||||
|
|||||||
Reference in New Issue
Block a user