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:
2026-04-04 14:52:41 +02:00
parent 17e784fee7
commit d3abdb7ed9
30 changed files with 2272 additions and 5 deletions

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Exceptions;
use RuntimeException;
final class WeeztixCouponCodeConflictException extends RuntimeException {}

View File

@@ -28,6 +28,7 @@ class PageController extends Controller
{
$query = PreregistrationPage::query()
->withCount('subscribers')
->with('weeztixConfig')
->orderByDesc('start_date');
if (! $request->user()?->isSuperadmin()) {

View File

@@ -89,7 +89,7 @@ class SubscriberController extends Controller
if ($phoneEnabled) {
$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);
foreach ($subscribers as $sub) {
@@ -97,6 +97,7 @@ class SubscriberController extends Controller
if ($phoneEnabled) {
$row[] = $sub->phoneDisplay() ?? '';
}
$row[] = $sub->coupon_code ?? '';
$row[] = $sub->created_at?->toDateTimeString() ?? '';
$row[] = $sub->synced_to_mailwizz ? 'Yes' : 'No';
$row[] = $sub->synced_at?->toDateTimeString() ?? '';

View 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;
}
}

View 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.'));
}
}

View 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);
}
}

View File

@@ -4,13 +4,19 @@ declare(strict_types=1);
namespace App\Http\Controllers;
use App\Exceptions\WeeztixCouponCodeConflictException;
use App\Http\Requests\SubscribePublicPageRequest;
use App\Jobs\SyncSubscriberToMailwizz;
use App\Models\PageBlock;
use App\Models\PreregistrationPage;
use App\Models\Subscriber;
use App\Models\WeeztixConfig;
use App\Services\WeeztixService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View;
use Throwable;
class PublicPageController extends Controller
{
@@ -52,13 +58,70 @@ class PublicPageController extends Controller
$subscriber = $publicPage->subscribers()->create($validated);
$publicPage->loadMissing('weeztixConfig');
$weeztix = $publicPage->weeztixConfig;
if ($this->weeztixCanIssueCodes($weeztix)) {
$this->tryAttachWeeztixCouponCode($subscriber, $weeztix);
}
if ($publicPage->mailwizzConfig !== null) {
SyncSubscriberToMailwizz::dispatch($subscriber);
SyncSubscriberToMailwizz::dispatch($subscriber->fresh());
}
return response()->json([
'success' => true,
'message' => $publicPage->thank_you_message ?? __('Thank you for registering!'),
'coupon_code' => $subscriber->fresh()?->coupon_code,
]);
}
private function weeztixCanIssueCodes(?WeeztixConfig $config): bool
{
if ($config === null || ! $config->is_connected) {
return false;
}
$company = $config->company_guid;
$coupon = $config->coupon_guid;
return is_string($company) && $company !== '' && is_string($coupon) && $coupon !== '';
}
private function tryAttachWeeztixCouponCode(Subscriber $subscriber, WeeztixConfig $config): void
{
$freshConfig = $config->fresh();
if ($freshConfig === null) {
return;
}
$service = new WeeztixService($freshConfig);
$maxAttempts = 5;
for ($attempt = 0; $attempt < $maxAttempts; $attempt++) {
try {
$code = WeeztixService::generateUniqueCode(
is_string($freshConfig->code_prefix) && $freshConfig->code_prefix !== ''
? $freshConfig->code_prefix
: 'PREREG'
);
$service->createCouponCode($code);
$subscriber->update(['coupon_code' => $code]);
return;
} catch (WeeztixCouponCodeConflictException) {
continue;
} catch (Throwable $e) {
Log::error('Weeztix coupon creation failed', [
'subscriber_id' => $subscriber->id,
'message' => $e->getMessage(),
]);
return;
}
}
Log::warning('Weeztix coupon: exhausted duplicate retries', [
'subscriber_id' => $subscriber->id,
]);
}

View File

@@ -42,6 +42,7 @@ class UpdateMailwizzConfigRequest extends FormRequest
'field_first_name' => ['required', 'string', 'max:255'],
'field_last_name' => ['required', 'string', 'max:255'],
'field_phone' => ['nullable', 'string', 'max:255'],
'field_coupon_code' => ['nullable', 'string', 'max:255'],
'tag_field' => ['required', 'string', 'max:255'],
'tag_value' => ['required', 'string', 'max:255'],
];

View 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'],
];
}
}

View File

@@ -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;
}

View File

@@ -21,6 +21,7 @@ class MailwizzConfig extends Model
'field_first_name',
'field_last_name',
'field_phone',
'field_coupon_code',
'tag_field',
'tag_value',
];

View File

@@ -162,6 +162,11 @@ class PreregistrationPage extends Model
return $this->hasOne(MailwizzConfig::class);
}
public function weeztixConfig(): HasOne
{
return $this->hasOne(WeeztixConfig::class);
}
public function isBeforeStart(): bool
{
return Carbon::now()->lt($this->start_date);

View File

@@ -22,6 +22,7 @@ class Subscriber extends Model
'phone',
'synced_to_mailwizz',
'synced_at',
'coupon_code',
];
protected function casts(): array
@@ -95,7 +96,8 @@ class Subscriber extends Model
$q->where('first_name', 'like', $like)
->orWhere('last_name', 'like', $like)
->orWhere('email', 'like', $like)
->orWhere('phone', 'like', $like);
->orWhere('phone', 'like', $like)
->orWhere('coupon_code', 'like', $like);
});
}
}

View 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();
}
}

View 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
View 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'),
];

View File

@@ -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');
}
};

View File

@@ -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');
});
}
};

View File

@@ -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');
});
}
};

View 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/

View File

@@ -420,6 +420,7 @@ document.addEventListener('alpine:init', () => {
redirectSecondsLeft: null,
redirectTimer: null,
strings: config.strings || {},
couponCode: '',
copyPageLink() {
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() {
if (this.phase === 'before') {
this.tickCountdown();
@@ -552,6 +566,8 @@ document.addEventListener('alpine:init', () => {
if (res.ok && data.success) {
this.phase = 'thanks';
this.thankYouMessage = data.message ?? '';
this.couponCode =
typeof data.coupon_code === 'string' && data.coupon_code !== '' ? data.coupon_code : '';
this.startRedirectCountdownIfNeeded();
return;
}
@@ -574,6 +590,7 @@ document.addEventListener('alpine:init', () => {
fieldsUrl: cfg.fieldsUrl,
phoneEnabled: cfg.phoneEnabled,
hasExistingConfig: cfg.hasExistingConfig,
hasWeeztixIntegration: cfg.hasWeeztixIntegration === true,
existing: cfg.existing,
csrf: cfg.csrf,
step: 1,
@@ -586,6 +603,7 @@ document.addEventListener('alpine:init', () => {
fieldFirstName: '',
fieldLastName: '',
fieldPhone: '',
fieldCouponCode: '',
tagField: '',
tagValue: '',
loading: false,
@@ -597,6 +615,7 @@ document.addEventListener('alpine:init', () => {
this.fieldFirstName = this.existing.field_first_name ?? '';
this.fieldLastName = this.existing.field_last_name ?? '';
this.fieldPhone = this.existing.field_phone ?? '';
this.fieldCouponCode = this.existing.field_coupon_code ?? '';
this.tagField = this.existing.tag_field ?? '';
this.tagValue = this.existing.tag_value ?? '';
this.selectedListUid = this.existing.list_uid ?? '';
@@ -706,6 +725,7 @@ document.addEventListener('alpine:init', () => {
this.fieldFirstName = this.existing.field_first_name || this.fieldFirstName;
this.fieldLastName = this.existing.field_last_name || this.fieldLastName;
this.fieldPhone = this.existing.field_phone || this.fieldPhone;
this.fieldCouponCode = this.existing.field_coupon_code || this.fieldCouponCode;
this.tagField = this.existing.tag_field || this.tagField;
this.tagValue = this.existing.tag_value || this.tagValue;
}
@@ -746,6 +766,104 @@ document.addEventListener('alpine:init', () => {
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;

View File

@@ -1,5 +1,7 @@
@php
$config = $page->mailwizzConfig;
$page->loadMissing('weeztixConfig');
$hasWeeztixForCouponMap = $page->weeztixConfig !== null && $page->weeztixConfig->is_connected;
$existing = $config !== null
? [
'list_uid' => $config->list_uid,
@@ -8,6 +10,7 @@
'field_first_name' => $config->field_first_name,
'field_last_name' => $config->field_last_name,
'field_phone' => $config->field_phone,
'field_coupon_code' => $config->field_coupon_code,
'tag_field' => $config->tag_field,
'tag_value' => $config->tag_value,
]
@@ -27,6 +30,7 @@
'csrf' => csrf_token(),
'phoneEnabled' => $page->isPhoneFieldEnabledForSubscribers(),
'hasExistingConfig' => $config !== null,
'hasWeeztixIntegration' => $hasWeeztixForCouponMap,
'existing' => $existing,
'strings' => [
'apiKeyRequired' => __('Enter your Mailwizz API key to continue.'),
@@ -190,6 +194,16 @@
</template>
</select>
</div>
<div x-show="hasWeeztixIntegration">
<label class="block text-sm font-medium text-slate-700">{{ __('Kortingscode (Weeztix)') }} <span class="font-normal text-slate-500">({{ __('optional') }})</span></label>
<p class="mt-1 text-xs text-slate-500">{{ __('Koppel aan een tekstveld in Mailwizz om de persoonlijke code in e-mails te tonen.') }}</p>
<select x-model="fieldCouponCode" class="mt-1 block w-full rounded-lg border border-slate-300 px-3 py-2 text-sm text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500">
<option value="">{{ __('Select field…') }}</option>
<template x-for="f in textFields()" :key="'cp-' + f.tag">
<option :value="f.tag" x-text="f.label + ' (' + f.tag + ')'"></option>
</template>
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">{{ __('Tag / source (checkbox list)') }}</label>
<select
@@ -244,6 +258,7 @@
<input type="hidden" name="field_first_name" x-bind:value="fieldFirstName">
<input type="hidden" name="field_last_name" x-bind:value="fieldLastName">
<input type="hidden" name="field_phone" x-bind:value="phoneEnabled ? fieldPhone : ''">
<input type="hidden" name="field_coupon_code" x-bind:value="hasWeeztixIntegration ? fieldCouponCode : ''">
<input type="hidden" name="tag_field" x-bind:value="tagField">
<input type="hidden" name="tag_value" x-bind:value="tagValue">

View File

@@ -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>
</p>
@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.weeztix.edit', $page) }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-500">{{ __('Weeztix integration') }} </a>
</p>
@endcan
</div>

View File

@@ -75,6 +75,12 @@
@can('update', $page)
<a href="{{ route('admin.pages.mailwizz.edit', $page) }}" class="text-indigo-600 hover:text-indigo-500">{{ __('Mailwizz') }}</a>
@endcan
@can('update', $page)
<a href="{{ route('admin.pages.weeztix.edit', $page) }}" class="text-indigo-600 hover:text-indigo-500">{{ __('Weeztix') }}</a>
@if ($page->weeztixConfig?->is_connected)
<span class="text-xs font-medium text-emerald-600" title="{{ __('Weeztix verbonden') }}"></span>
@endif
@endcan
<button
type="button"
x-data="{ copied: false }"

View File

@@ -52,6 +52,7 @@
@if ($page->isPhoneFieldEnabledForSubscribers())
<th class="px-4 py-3 font-semibold text-slate-700">{{ __('Phone') }}</th>
@endif
<th class="px-4 py-3 font-semibold text-slate-700">{{ __('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">{{ __('Mailwizz') }}</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())
<td class="px-4 py-3 text-slate-600">{{ $subscriber->phoneDisplay() ?? '—' }}</td>
@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="px-4 py-3">
@if ($subscriber->synced_to_mailwizz)
@@ -100,7 +102,7 @@
</tr>
@empty
<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.') }}
</td>
</tr>

View 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

View File

@@ -156,6 +156,30 @@
</div>
</div>
<p class="whitespace-pre-line text-center text-base leading-relaxed text-white/95 sm:text-lg" x-text="thankYouMessage"></p>
<template x-if="couponCode">
<div class="mt-6 rounded-xl border border-white/20 bg-white/10 p-6 backdrop-blur">
<p class="text-center text-sm text-white/70">{{ __('Jouw kortingscode') }}</p>
<div class="mt-3 flex flex-wrap items-center justify-center gap-3">
<span
class="font-mono text-2xl font-bold tracking-wider text-festival"
x-text="couponCode"
></span>
<button
type="button"
class="rounded-lg border border-white/25 p-2 text-white/70 transition hover:border-white/40 hover:text-white"
@click="copyCouponCode()"
aria-label="{{ __('Kortingscode kopiëren') }}"
>
<svg class="h-6 w-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184" />
</svg>
</button>
</div>
<p class="mt-3 text-center text-xs text-white/50">
{{ __('Gebruik deze code bij het afrekenen in de ticketshop.') }}
</p>
</div>
</template>
<p
x-show="redirectSecondsLeft !== null && redirectSecondsLeft > 0"
x-cloak

View File

@@ -71,6 +71,7 @@
'redirectUrl' => filled($redirectAfterSubmit) ? $redirectAfterSubmit : null,
'strings' => [
'linkCopied' => __('Link gekopieerd!'),
'couponCopied' => __('Kortingscode gekopieerd!'),
'redirectCountdown' => __('You will be redirected in :seconds s…'),
],
]))"

View File

@@ -8,6 +8,9 @@ use App\Http\Controllers\Admin\MailwizzController;
use App\Http\Controllers\Admin\PageController;
use App\Http\Controllers\Admin\SubscriberController;
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\PublicPageController;
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/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)
Route::middleware('role:superadmin')->group(function () {
Route::resource('users', UserController::class)->except(['show']);

View File

@@ -133,6 +133,43 @@ class SyncSubscriberToMailwizzTest extends TestCase
$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
*/
@@ -164,6 +201,7 @@ class SyncSubscriberToMailwizzTest extends TestCase
'field_first_name' => 'FNAME',
'field_last_name' => 'LNAME',
'field_phone' => null,
'field_coupon_code' => null,
'tag_field' => 'TAGS',
'tag_value' => 'preregister-source',
], $configOverrides));