Hintergründe zum EventInvitePage Layout Customizer hinzugefügt. Badge und CTA entfernt, Textfelder zu Textareas gemacht. Geschenkgutscheine verbessert, E-Mail-Versand ergänzt + Resend + Confirmationseite mit Code-Copy und Link zur Package-Seite, die den Code als URL-Parameter enthält.

This commit is contained in:
Codex Agent
2025-12-08 16:20:04 +01:00
parent 046e2fe3ec
commit 4784c23e70
35 changed files with 1503 additions and 136 deletions

View File

@@ -6,13 +6,17 @@ use App\Filament\Resources\GiftVoucherResource\Pages;
use App\Models\GiftVoucher;
use App\Services\GiftVouchers\GiftVoucherService;
use BackedEnum;
use Carbon\Carbon;
use Filament\Forms\Components\Textarea;
use Filament\Actions\Action;
use Filament\Forms\Components\DateTimePicker;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\BadgeColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Filament\Tables\Actions\ExportAction;
use UnitEnum;
class GiftVoucherResource extends Resource
@@ -92,6 +96,45 @@ class GiftVoucherResource extends Resource
$service->refund($record, 'customer_request');
})
->successNotificationTitle('Gutschein erstattet'),
Action::make('resend')
->label('E-Mails erneut senden')
->icon('heroicon-o-paper-airplane')
->action(fn (GiftVoucher $record, GiftVoucherService $service) => $service->resend($record))
->successNotificationTitle('E-Mails werden erneut versendet'),
Action::make('schedule_delivery')
->label('Versand terminieren')
->icon('heroicon-o-clock')
->form([
DateTimePicker::make('recipient_delivery_scheduled_at')
->label('Versenden am')
->required()
->minDate(now()->addMinutes(10)),
])
->action(function (GiftVoucher $record, array $data, GiftVoucherService $service): void {
$service->scheduleRecipientDelivery(
$record,
Carbon::parse($data['recipient_delivery_scheduled_at'])
);
})
->visible(fn (GiftVoucher $record): bool => ! empty($record->recipient_email)),
Action::make('mark_redeemed')
->label('Als eingelöst markieren')
->color('success')
->visible(fn (GiftVoucher $record): bool => $record->canBeRedeemed())
->form([
Textarea::make('note')->label('Notiz')->maxLength(250),
])
->action(function (GiftVoucher $record, array $data): void {
$record->forceFill([
'status' => GiftVoucher::STATUS_REDEEMED,
'redeemed_at' => now(),
'metadata' => array_merge($record->metadata ?? [], [
'manual_note' => $data['note'] ?? null,
'manual_marked' => true,
]),
])->save();
})
->successNotificationTitle('Als eingelöst markiert'),
]);
}
@@ -106,4 +149,12 @@ class GiftVoucherResource extends Resource
'index' => Pages\ListGiftVouchers::route('/'),
];
}
public static function tableActions(): array
{
return [
ExportAction::make()
->label('Exportieren'),
];
}
}

View File

@@ -3,7 +3,12 @@
namespace App\Filament\Resources\GiftVoucherResource\Pages;
use App\Filament\Resources\GiftVoucherResource;
use App\Services\GiftVouchers\GiftVoucherService;
use Filament\Actions\Action;
use Filament\Resources\Pages\ListRecords;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Placeholder;
use Illuminate\Support\Str;
class ListGiftVouchers extends ListRecords
{
@@ -11,6 +16,55 @@ class ListGiftVouchers extends ListRecords
protected function getHeaderActions(): array
{
return [];
return [
Action::make('issue')
->label('Gutschein ausstellen')
->icon('heroicon-o-plus')
->form([
TextInput::make('amount')
->label('Betrag')
->numeric()
->required(),
TextInput::make('currency')
->label('Währung')
->default('EUR')
->maxLength(3)
->required(),
TextInput::make('purchaser_email')->label('E-Mail Käufer')->required(),
TextInput::make('recipient_email')->label('E-Mail Empfänger'),
TextInput::make('recipient_name')->label('Name Empfänger'),
TextInput::make('message')
->label('Nachricht')
->maxLength(500),
TextInput::make('code')
->label('Code (optional)')
->placeholder('GIFT-'.Str::upper(Str::random(8))),
Placeholder::make('code_hint')
->label('')
->content('Wenn kein Code eingetragen wird, erzeugen wir automatisch einen eindeutigen Gutscheincode.'),
])
->action(function (array $data, GiftVoucherService $service): void {
$payload = [
'id' => null,
'metadata' => [
'type' => 'gift_voucher',
'purchaser_email' => $data['purchaser_email'],
'recipient_email' => $data['recipient_email'] ?? null,
'recipient_name' => $data['recipient_name'] ?? null,
'message' => $data['message'] ?? null,
'gift_code' => $data['code'] ?? null,
],
'currency_code' => $data['currency'] ?? 'EUR',
'totals' => [
'grand_total' => [
'amount' => (float) $data['amount'],
],
],
];
$service->issueFromPaddle($payload);
})
->modalHeading('Geschenkgutschein ausstellen'),
];
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Api\Marketing;
use App\Http\Controllers\Controller;
use App\Models\GiftVoucher;
use App\Services\GiftVouchers\GiftVoucherCheckoutService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -41,4 +42,44 @@ class GiftVoucherCheckoutController extends Controller
return response()->json($checkout);
}
public function show(Request $request): JsonResponse
{
$data = $request->validate([
'checkout_id' => ['nullable', 'string', 'required_without_all:transaction_id,code'],
'transaction_id' => ['nullable', 'string', 'required_without_all:checkout_id,code'],
'code' => ['nullable', 'string', 'required_without_all:checkout_id,transaction_id'],
]);
$voucherQuery = GiftVoucher::query();
if (! empty($data['checkout_id'])) {
$voucherQuery->where('paddle_checkout_id', $data['checkout_id']);
}
if (! empty($data['transaction_id'])) {
$voucherQuery->orWhere('paddle_transaction_id', $data['transaction_id']);
}
if (! empty($data['code'])) {
$voucherQuery->orWhere('code', strtoupper($data['code']));
}
$voucher = $voucherQuery->latest()->firstOrFail();
return response()->json([
'data' => [
'code' => $voucher->code,
'amount' => (float) $voucher->amount,
'currency' => $voucher->currency,
'expires_at' => optional($voucher->expires_at)->toIso8601String(),
'recipient_name' => $voucher->recipient_name,
'recipient_email' => $voucher->recipient_email,
'purchaser_email' => $voucher->purchaser_email,
'status' => $voucher->status,
'redeemed_at' => optional($voucher->redeemed_at)->toIso8601String(),
'refunded_at' => optional($voucher->refunded_at)->toIso8601String(),
],
]);
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Http\Controllers\Api\Marketing;
use App\Http\Controllers\Controller;
use App\Models\GiftVoucher;
use App\Services\GiftVouchers\GiftVoucherService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
class GiftVoucherResendController extends Controller
{
public function __construct(private readonly GiftVoucherService $vouchers) {}
public function __invoke(Request $request): JsonResponse
{
$data = $request->validate([
'code' => ['required', 'string'],
'recipient_only' => ['sometimes', 'boolean'],
'locale' => ['nullable', 'string'],
'schedule_at' => ['nullable', 'date'],
]);
$voucher = GiftVoucher::query()
->where('code', strtoupper($data['code']))
->first();
if (! $voucher) {
throw ValidationException::withMessages([
'code' => __('Voucher not found.'),
]);
}
if (! empty($data['schedule_at'])) {
$this->vouchers->scheduleRecipientDelivery($voucher, now()->parse($data['schedule_at']), $data['locale'] ?? app()->getLocale());
} else {
$this->vouchers->resend($voucher, $data['locale'] ?? app()->getLocale(), $data['recipient_only'] ?? null);
}
return response()->json([
'status' => 'ok',
]);
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Http\Controllers\Marketing;
use App\Http\Controllers\Controller;
use App\Models\GiftVoucher;
use Illuminate\Http\Request;
class GiftVoucherPrintController extends Controller
{
public function __invoke(Request $request, GiftVoucher $voucher)
{
if ($voucher->code !== strtoupper($request->query('code'))) {
abort(404);
}
return view('marketing.gift-voucher-print', [
'voucher' => $voucher,
]);
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Jobs;
use App\Mail\GiftVoucherIssued;
use App\Models\GiftVoucher;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Mail;
class NotifyGiftVoucherReminder implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(public GiftVoucher $voucher, public bool $expiry = false) {}
public function handle(): void
{
$voucher = $this->voucher->fresh();
if (! $voucher || $voucher->isRedeemed() || $voucher->isRefunded() || $voucher->isExpired()) {
return;
}
$recipients = collect([$voucher->purchaser_email, $voucher->recipient_email])
->filter()
->unique()
->all();
foreach ($recipients as $email) {
Mail::to($email)->queue((new GiftVoucherIssued($voucher, $email === $voucher->recipient_email))->with([
'isReminder' => true,
'isExpiry' => $this->expiry,
]));
}
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Mail;
use App\Models\GiftVoucher;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\URL;
class GiftVoucherIssued extends Mailable
{
use Queueable, SerializesModels;
public function __construct(
public GiftVoucher $voucher,
public bool $forRecipient = false
) {}
public function envelope(): Envelope
{
$amount = number_format((float) $this->voucher->amount, 2);
return new Envelope(
subject: $this->forRecipient
? __('emails.gift_voucher.recipient.subject', ['amount' => $amount, 'currency' => $this->voucher->currency])
: __('emails.gift_voucher.purchaser.subject', ['amount' => $amount, 'currency' => $this->voucher->currency]),
);
}
public function content(): Content
{
$amount = number_format((float) $this->voucher->amount, 2);
$printUrl = URL::signedRoute('marketing.gift-vouchers.print', [
'locale' => app()->getLocale(),
'voucher' => $this->voucher->id,
'code' => $this->voucher->code,
]);
return new Content(
view: 'emails.gift-voucher',
with: [
'voucher' => $this->voucher,
'amount' => $amount,
'currency' => $this->voucher->currency,
'forRecipient' => $this->forRecipient,
'printUrl' => $printUrl,
],
);
}
public function attachments(): array
{
return [];
}
}

View File

@@ -39,6 +39,8 @@ class GiftVoucher extends Model
'expires_at',
'redeemed_at',
'refunded_at',
'recipient_delivery_scheduled_at',
'recipient_delivery_sent_at',
'metadata',
];
@@ -47,6 +49,8 @@ class GiftVoucher extends Model
'expires_at' => 'datetime',
'redeemed_at' => 'datetime',
'refunded_at' => 'datetime',
'recipient_delivery_scheduled_at' => 'datetime',
'recipient_delivery_sent_at' => 'datetime',
'metadata' => 'array',
];

View File

@@ -178,6 +178,26 @@ class AppServiceProvider extends ServiceProvider
];
});
RateLimiter::for('gift-lookup', function (Request $request) {
$code = strtoupper((string) $request->query('code'));
$ip = $request->ip() ?? 'unknown';
return [
Limit::perMinute(30)->by('gift-lookup:ip:'.$ip),
Limit::perMinute(10)->by('gift-lookup:code:'.($code ?: $ip)),
];
});
RateLimiter::for('gift-resend', function (Request $request) {
$code = strtoupper((string) $request->input('code'));
$ip = $request->ip() ?? 'unknown';
return [
Limit::perMinute(10)->by('gift-resend:ip:'.$ip),
Limit::perHour(5)->by('gift-resend:code:'.($code ?: $ip)),
];
});
Inertia::share('locale', fn () => app()->getLocale());
Inertia::share('analytics', static function () {
$config = config('services.matomo');

View File

@@ -5,6 +5,8 @@ namespace App\Services\GiftVouchers;
use App\Enums\CouponStatus;
use App\Enums\CouponType;
use App\Jobs\SyncCouponToPaddle;
use App\Mail\GiftVoucherIssued;
use App\Jobs\NotifyGiftVoucherReminder;
use App\Models\Coupon;
use App\Models\GiftVoucher;
use App\Models\Package;
@@ -12,8 +14,10 @@ use App\Services\Paddle\PaddleTransactionService;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Carbon\Carbon;
class GiftVoucherService
{
@@ -28,9 +32,19 @@ class GiftVoucherService
$priceId = $this->resolvePriceId($payload);
$amount = $this->resolveAmount($payload);
$currency = Str::upper($this->resolveCurrency($payload));
$locale = $metadata['app_locale'] ?? app()->getLocale();
$existing = null;
$expiresAt = now()->addYears((int) config('gift-vouchers.default_valid_years', 5));
if (! empty($payload['id'])) {
$existing = GiftVoucher::query()
->where('paddle_transaction_id', $payload['id'])
->first();
}
$mergedMetadata = array_merge($existing?->metadata ?? [], $metadata);
$voucher = GiftVoucher::query()->updateOrCreate(
[
'paddle_transaction_id' => $payload['id'] ?? null,
@@ -46,7 +60,7 @@ class GiftVoucherService
'message' => $metadata['message'] ?? null,
'paddle_checkout_id' => $payload['checkout_id'] ?? Arr::get($payload, 'details.checkout_id'),
'paddle_price_id' => $priceId,
'metadata' => $metadata,
'metadata' => $mergedMetadata,
'expires_at' => $expiresAt,
'refunded_at' => null,
'redeemed_at' => null,
@@ -59,9 +73,29 @@ class GiftVoucherService
SyncCouponToPaddle::dispatch($coupon);
}
$notificationsSent = (bool) Arr::get($voucher->metadata ?? [], 'notifications_sent', false);
if (! $notificationsSent) {
$this->sendNotifications($voucher, locale: $locale);
}
return $voucher;
}
public function resend(GiftVoucher $voucher, ?string $locale = null, ?bool $recipientOnly = null): void
{
$this->sendNotifications($voucher, force: true, locale: $locale, recipientOnly: $recipientOnly);
}
public function scheduleRecipientDelivery(GiftVoucher $voucher, Carbon $when, ?string $locale = null): void
{
$voucher->forceFill([
'recipient_delivery_scheduled_at' => $when,
])->save();
$this->sendNotifications($voucher, force: true, when: $when, locale: $locale, recipientOnly: true);
}
public function markRedeemed(?Coupon $coupon, ?string $transactionId = null): void
{
if (! $coupon?->giftVoucher) {
@@ -212,4 +246,65 @@ class GiftVoucherService
{
return 'GIFT-'.Str::upper(Str::random(8));
}
protected function sendNotifications(
GiftVoucher $voucher,
bool $force = false,
?Carbon $when = null,
?string $locale = null,
?bool $recipientOnly = null
): void {
$alreadySent = (bool) Arr::get($voucher->metadata ?? [], 'notifications_sent', false);
if ($alreadySent && ! $force) {
return;
}
$purchaserMail = $voucher->purchaser_email ? Mail::to($voucher->purchaser_email) : null;
$recipientMail = $voucher->recipient_email && $voucher->recipient_email !== $voucher->purchaser_email
? Mail::to($voucher->recipient_email)
: null;
if (! $recipientOnly && $purchaserMail) {
$mailable = (new GiftVoucherIssued($voucher, false))->locale($locale);
$when ? $purchaserMail->later($when, $mailable) : $purchaserMail->queue($mailable);
}
if ($recipientMail) {
$mailable = (new GiftVoucherIssued($voucher, true))->locale($locale);
$when ? $recipientMail->later($when, $mailable) : $recipientMail->queue($mailable);
}
$metadata = $voucher->metadata ?? [];
if (! $recipientOnly) {
$metadata['notifications_sent'] = true;
}
$voucher->forceFill([
'metadata' => $metadata,
'recipient_delivery_sent_at' => $when ? $voucher->recipient_delivery_sent_at : ($recipientMail ? now() : $voucher->recipient_delivery_sent_at),
])->save();
$this->scheduleReminders($voucher);
}
protected function scheduleReminders(GiftVoucher $voucher): void
{
if ($voucher->isRedeemed() || $voucher->isRefunded()) {
return;
}
$reminderDays = (int) config('gift-vouchers.reminder_days', 7);
$expiryReminderDays = (int) config('gift-vouchers.expiry_reminder_days', 14);
if ($reminderDays > 0) {
NotifyGiftVoucherReminder::dispatch($voucher)->delay(now()->addDays($reminderDays));
}
if ($voucher->expires_at && $expiryReminderDays > 0) {
$when = $voucher->expires_at->copy()->subDays($expiryReminderDays);
if ($when->isFuture()) {
NotifyGiftVoucherReminder::dispatch($voucher, true)->delay($when);
}
}
}
}