From 4784c23e702d4fb1653e8c3977c01ccb2766c9cd Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Mon, 8 Dec 2025 16:20:04 +0100 Subject: [PATCH] =?UTF-8?q?Hintergr=C3=BCnde=20zum=20EventInvitePage=20Lay?= =?UTF-8?q?out=20Customizer=20hinzugef=C3=BCgt.=20Badge=20und=20CTA=20entf?= =?UTF-8?q?ernt,=20Textfelder=20zu=20Textareas=20gemacht.=20Geschenkgutsch?= =?UTF-8?q?eine=20verbessert,=20E-Mail-Versand=20erg=C3=A4nzt=20+=20Resend?= =?UTF-8?q?=20+=20Confirmationseite=20mit=20Code-Copy=20und=20Link=20zur?= =?UTF-8?q?=20Package-Seite,=20die=20den=20Code=20als=20URL-Parameter=20en?= =?UTF-8?q?th=C3=A4lt.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Resources/GiftVoucherResource.php | 51 +++++ .../Pages/ListGiftVouchers.php | 56 ++++- .../GiftVoucherCheckoutController.php | 41 ++++ .../Marketing/GiftVoucherResendController.php | 45 ++++ .../Marketing/GiftVoucherPrintController.php | 21 ++ app/Jobs/NotifyGiftVoucherReminder.php | 40 ++++ app/Mail/GiftVoucherIssued.php | 58 +++++ app/Models/GiftVoucher.php | 4 + app/Providers/AppServiceProvider.php | 20 ++ .../GiftVouchers/GiftVoucherService.php | 97 ++++++++- config/gift-vouchers.php | 58 +++++ ...elivery_columns_to_gift_vouchers_table.php | 29 +++ resources/js/admin/pages/EventInvitesPage.tsx | 7 + .../InviteLayoutCustomizerPanel.tsx | 104 ++++++--- .../invite-layout/DesignerCanvas.tsx | 202 ++++++++++++------ .../components/invite-layout/backgrounds.ts | 44 ++++ .../pages/components/invite-layout/schema.ts | 34 +-- resources/js/hooks/useRateLimitHelper.ts | 35 +++ resources/js/lib/giftVouchers.ts | 55 +++++ resources/js/pages/marketing/GiftVoucher.tsx | 101 ++++++++- resources/js/pages/marketing/Success.tsx | 89 ++++++++ .../marketing/checkout/steps/PaymentStep.tsx | 29 +++ resources/lang/de/emails.php | 23 ++ resources/lang/de/marketing.json | 32 ++- resources/lang/de/marketing.php | 4 + resources/lang/en/emails.php | 23 ++ resources/lang/en/marketing.json | 32 ++- resources/lang/en/marketing.php | 4 + resources/views/emails/gift-voucher.blade.php | 65 ++++++ .../marketing/gift-voucher-print.blade.php | 62 ++++++ routes/api.php | 6 + routes/web.php | 4 + tests/Feature/Api/GiftVoucherLookupTest.php | 52 +++++ tests/Feature/Api/GiftVoucherResendTest.php | 41 ++++ tests/Unit/GiftVoucherServiceTest.php | 71 ++++++ 35 files changed, 1503 insertions(+), 136 deletions(-) create mode 100644 app/Http/Controllers/Api/Marketing/GiftVoucherResendController.php create mode 100644 app/Http/Controllers/Marketing/GiftVoucherPrintController.php create mode 100644 app/Jobs/NotifyGiftVoucherReminder.php create mode 100644 app/Mail/GiftVoucherIssued.php create mode 100644 database/migrations/2026_02_04_120001_add_delivery_columns_to_gift_vouchers_table.php create mode 100644 resources/js/admin/pages/components/invite-layout/backgrounds.ts create mode 100644 resources/js/hooks/useRateLimitHelper.ts create mode 100644 resources/views/emails/gift-voucher.blade.php create mode 100644 resources/views/marketing/gift-voucher-print.blade.php create mode 100644 tests/Feature/Api/GiftVoucherLookupTest.php create mode 100644 tests/Feature/Api/GiftVoucherResendTest.php diff --git a/app/Filament/Resources/GiftVoucherResource.php b/app/Filament/Resources/GiftVoucherResource.php index 07f7933..7b86c35 100644 --- a/app/Filament/Resources/GiftVoucherResource.php +++ b/app/Filament/Resources/GiftVoucherResource.php @@ -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'), + ]; + } } diff --git a/app/Filament/Resources/GiftVoucherResource/Pages/ListGiftVouchers.php b/app/Filament/Resources/GiftVoucherResource/Pages/ListGiftVouchers.php index 0d0fa4b..4bed8fc 100644 --- a/app/Filament/Resources/GiftVoucherResource/Pages/ListGiftVouchers.php +++ b/app/Filament/Resources/GiftVoucherResource/Pages/ListGiftVouchers.php @@ -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'), + ]; } } diff --git a/app/Http/Controllers/Api/Marketing/GiftVoucherCheckoutController.php b/app/Http/Controllers/Api/Marketing/GiftVoucherCheckoutController.php index b692806..bdf7f0e 100644 --- a/app/Http/Controllers/Api/Marketing/GiftVoucherCheckoutController.php +++ b/app/Http/Controllers/Api/Marketing/GiftVoucherCheckoutController.php @@ -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(), + ], + ]); + } } diff --git a/app/Http/Controllers/Api/Marketing/GiftVoucherResendController.php b/app/Http/Controllers/Api/Marketing/GiftVoucherResendController.php new file mode 100644 index 0000000..f091d75 --- /dev/null +++ b/app/Http/Controllers/Api/Marketing/GiftVoucherResendController.php @@ -0,0 +1,45 @@ +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', + ]); + } +} diff --git a/app/Http/Controllers/Marketing/GiftVoucherPrintController.php b/app/Http/Controllers/Marketing/GiftVoucherPrintController.php new file mode 100644 index 0000000..6a65ac1 --- /dev/null +++ b/app/Http/Controllers/Marketing/GiftVoucherPrintController.php @@ -0,0 +1,21 @@ +code !== strtoupper($request->query('code'))) { + abort(404); + } + + return view('marketing.gift-voucher-print', [ + 'voucher' => $voucher, + ]); + } +} diff --git a/app/Jobs/NotifyGiftVoucherReminder.php b/app/Jobs/NotifyGiftVoucherReminder.php new file mode 100644 index 0000000..a8e04c0 --- /dev/null +++ b/app/Jobs/NotifyGiftVoucherReminder.php @@ -0,0 +1,40 @@ +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, + ])); + } + } +} diff --git a/app/Mail/GiftVoucherIssued.php b/app/Mail/GiftVoucherIssued.php new file mode 100644 index 0000000..2729ec2 --- /dev/null +++ b/app/Mail/GiftVoucherIssued.php @@ -0,0 +1,58 @@ +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 []; + } +} diff --git a/app/Models/GiftVoucher.php b/app/Models/GiftVoucher.php index 18d4c6b..c00115a 100644 --- a/app/Models/GiftVoucher.php +++ b/app/Models/GiftVoucher.php @@ -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', ]; diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index e402945..a3da26b 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -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'); diff --git a/app/Services/GiftVouchers/GiftVoucherService.php b/app/Services/GiftVouchers/GiftVoucherService.php index 4c8d0ba..513010f 100644 --- a/app/Services/GiftVouchers/GiftVoucherService.php +++ b/app/Services/GiftVouchers/GiftVoucherService.php @@ -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); + } + } + } } diff --git a/config/gift-vouchers.php b/config/gift-vouchers.php index 6d4d70c..ff0d274 100644 --- a/config/gift-vouchers.php +++ b/config/gift-vouchers.php @@ -2,6 +2,8 @@ return [ 'default_valid_years' => 5, + 'reminder_days' => 7, + 'expiry_reminder_days' => 14, // Map voucher tiers to Paddle price IDs (create matching prices in Paddle Billing). 'tiers' => [ @@ -12,6 +14,20 @@ return [ 'currency' => 'EUR', 'paddle_price_id' => env('PADDLE_GIFT_PRICE_STARTER', 'pri_01kbwccfe1mpwh7hh60eygemx6'), ], + [ + 'key' => 'gift-starter-usd', + 'label' => 'Gift Starter (USD)', + 'amount' => 32.00, + 'currency' => 'USD', + 'paddle_price_id' => env('PADDLE_GIFT_PRICE_STARTER_USD'), + ], + [ + 'key' => 'gift-starter-gbp', + 'label' => 'Gift Starter (GBP)', + 'amount' => 25.00, + 'currency' => 'GBP', + 'paddle_price_id' => env('PADDLE_GIFT_PRICE_STARTER_GBP'), + ], [ 'key' => 'gift-standard', 'label' => 'Geschenk Standard', @@ -19,6 +35,20 @@ return [ 'currency' => 'EUR', 'paddle_price_id' => env('PADDLE_GIFT_PRICE_STANDARD', 'pri_01kbwccfvzrf4z2f1r62vns7gh'), ], + [ + 'key' => 'gift-standard-usd', + 'label' => 'Gift Standard (USD)', + 'amount' => 65.00, + 'currency' => 'USD', + 'paddle_price_id' => env('PADDLE_GIFT_PRICE_STANDARD_USD'), + ], + [ + 'key' => 'gift-standard-gbp', + 'label' => 'Gift Standard (GBP)', + 'amount' => 55.00, + 'currency' => 'GBP', + 'paddle_price_id' => env('PADDLE_GIFT_PRICE_STANDARD_GBP'), + ], [ 'key' => 'gift-premium', 'label' => 'Geschenk Premium', @@ -26,6 +56,20 @@ return [ 'currency' => 'EUR', 'paddle_price_id' => env('PADDLE_GIFT_PRICE_PREMIUM', 'pri_01kbwccg8vjc5cwz0kftfvf9wm'), ], + [ + 'key' => 'gift-premium-usd', + 'label' => 'Gift Premium (USD)', + 'amount' => 139.00, + 'currency' => 'USD', + 'paddle_price_id' => env('PADDLE_GIFT_PRICE_PREMIUM_USD'), + ], + [ + 'key' => 'gift-premium-gbp', + 'label' => 'Gift Premium (GBP)', + 'amount' => 119.00, + 'currency' => 'GBP', + 'paddle_price_id' => env('PADDLE_GIFT_PRICE_PREMIUM_GBP'), + ], [ 'key' => 'gift-premium-plus', 'label' => 'Geschenk Premium Plus', @@ -33,6 +77,20 @@ return [ 'currency' => 'EUR', 'paddle_price_id' => env('PADDLE_GIFT_PRICE_PREMIUM_PLUS', 'pri_01kbwccgnjzwrjy5xg1yp981p6'), ], + [ + 'key' => 'gift-premium-plus-usd', + 'label' => 'Gift Premium Plus (USD)', + 'amount' => 159.00, + 'currency' => 'USD', + 'paddle_price_id' => env('PADDLE_GIFT_PRICE_PREMIUM_PLUS_USD'), + ], + [ + 'key' => 'gift-premium-plus-gbp', + 'label' => 'Gift Premium Plus (GBP)', + 'amount' => 139.00, + 'currency' => 'GBP', + 'paddle_price_id' => env('PADDLE_GIFT_PRICE_PREMIUM_PLUS_GBP'), + ], ], // Package types a voucher coupon should apply to. diff --git a/database/migrations/2026_02_04_120001_add_delivery_columns_to_gift_vouchers_table.php b/database/migrations/2026_02_04_120001_add_delivery_columns_to_gift_vouchers_table.php new file mode 100644 index 0000000..e168d71 --- /dev/null +++ b/database/migrations/2026_02_04_120001_add_delivery_columns_to_gift_vouchers_table.php @@ -0,0 +1,29 @@ +timestamp('recipient_delivery_scheduled_at')->nullable()->after('redeemed_at'); + $table->timestamp('recipient_delivery_sent_at')->nullable()->after('recipient_delivery_scheduled_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('gift_vouchers', function (Blueprint $table) { + $table->dropColumn(['recipient_delivery_scheduled_at', 'recipient_delivery_sent_at']); + }); + } +}; diff --git a/resources/js/admin/pages/EventInvitesPage.tsx b/resources/js/admin/pages/EventInvitesPage.tsx index d6b0f00..0d349f2 100644 --- a/resources/js/admin/pages/EventInvitesPage.tsx +++ b/resources/js/admin/pages/EventInvitesPage.tsx @@ -54,6 +54,7 @@ import { triggerDownloadFromBlob, triggerDownloadFromDataUrl, } from './components/invite-layout/export-utils'; +import { preloadedBackgrounds } from './components/invite-layout/backgrounds'; import { useOnboardingProgress } from '../onboarding'; import { FloatingActionBar, type FloatingAction } from '../components/FloatingActionBar'; @@ -358,6 +359,7 @@ export default function EventInvitesPage(): React.ReactElement { const secondaryColor = '#1F2937'; const badgeColor = normalizeHexColor(customization?.badge_color ?? (layoutPreview.accent as string | undefined)) ?? accentColor; const gradient = normalizeGradient(customization?.background_gradient ?? layoutPreview.background_gradient ?? null); + const backgroundImage = customization?.background_image ?? null; const instructions = ensureInstructionList(customization?.instructions, exportLayout.instructions ?? []); const workflowSteps = toStringList(t('invites.export.workflow.steps', { returnObjects: true })); @@ -373,6 +375,7 @@ export default function EventInvitesPage(): React.ReactElement { backgroundStyle: buildBackgroundStyle(backgroundColor, gradient), backgroundColor, backgroundGradient: gradient, + backgroundImage, badgeLabel: customization?.badge_label?.trim() || t('tasks.customizer.defaults.badgeLabel'), badgeColor, badgeTextColor: '#FFFFFF', @@ -722,6 +725,7 @@ export default function EventInvitesPage(): React.ReactElement { logoDataUrl: exportLogo, backgroundColor: exportPreview.backgroundColor ?? '#FFFFFF', backgroundGradient: exportPreview.backgroundGradient ?? null, + backgroundImageUrl: exportPreview.backgroundImage ?? null, readOnly: true, selectedId: null, } as const; @@ -769,6 +773,7 @@ export default function EventInvitesPage(): React.ReactElement { logoDataUrl: exportLogo, backgroundColor: exportPreview.backgroundColor ?? '#FFFFFF', backgroundGradient: exportPreview.backgroundGradient ?? null, + backgroundImageUrl: exportPreview.backgroundImage ?? null, readOnly: true, selectedId: null, } as const; @@ -1008,6 +1013,7 @@ export default function EventInvitesPage(): React.ReactElement { initialCustomization={currentCustomization} draftCustomization={customizerDraft} onDraftChange={handleCustomizerDraftChange} + backgroundImages={preloadedBackgrounds} /> )} @@ -1102,6 +1108,7 @@ export default function EventInvitesPage(): React.ReactElement { onChange={handlePreviewChange} background={exportPreview.backgroundColor} gradient={exportPreview.backgroundGradient} + backgroundImageUrl={exportPreview.backgroundImage ?? null} accent={exportPreview.accentColor} text={exportPreview.textColor} secondary={exportPreview.secondaryColor} diff --git a/resources/js/admin/pages/components/InviteLayoutCustomizerPanel.tsx b/resources/js/admin/pages/components/InviteLayoutCustomizerPanel.tsx index 26f2635..8fc0938 100644 --- a/resources/js/admin/pages/components/InviteLayoutCustomizerPanel.tsx +++ b/resources/js/admin/pages/components/InviteLayoutCustomizerPanel.tsx @@ -34,6 +34,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { cn } from '@/lib/utils'; import { ensureFontLoaded, useTenantFonts } from '../../lib/fonts'; +import { preloadedBackgrounds, type BackgroundImageOption } from './invite-layout/backgrounds'; const DEFAULT_FONT_VALUE = '__default'; @@ -115,6 +116,12 @@ function sanitizePayload(payload: QrLayoutCustomization): QrLayoutCustomization normalized.background_color = sanitizeColor(payload.background_color ?? null) ?? undefined; normalized.secondary_color = sanitizeColor(payload.secondary_color ?? null) ?? undefined; normalized.badge_color = sanitizeColor(payload.badge_color ?? null) ?? undefined; + if (typeof payload.background_image === 'string') { + const trimmed = payload.background_image.trim(); + normalized.background_image = trimmed.length ? trimmed : undefined; + } else { + normalized.background_image = undefined; + } if (payload.background_gradient && typeof payload.background_gradient === 'object') { const { angle, stops } = payload.background_gradient as { angle?: number; stops?: unknown }; @@ -192,6 +199,7 @@ type InviteLayoutCustomizerPanelProps = { invite: EventQrInvite | null; eventName: string; eventDate: string | null; + backgroundImages?: BackgroundImageOption[]; saving: boolean; resetting: boolean; onSave: (customization: QrLayoutCustomization) => Promise; @@ -217,6 +225,7 @@ export function InviteLayoutCustomizerPanel({ initialCustomization, draftCustomization, onDraftChange, + backgroundImages = preloadedBackgrounds, }: InviteLayoutCustomizerPanelProps): React.JSX.Element { const { t } = useTranslation('management'); const { fonts: availableFonts, isLoading: fontsLoading } = useTenantFonts(); @@ -792,6 +801,7 @@ export function InviteLayoutCustomizerPanel({ background_color: sanitizeColor((reuseCustomization ? activeCustomization?.background_color : activeLayout.preview?.background) ?? null) ?? '#FFFFFF', secondary_color: reuseCustomization ? activeCustomization?.secondary_color ?? '#1F2937' : '#1F2937', badge_color: reuseCustomization ? activeCustomization?.badge_color ?? '#2563EB' : '#2563EB', + background_image: reuseCustomization ? activeCustomization?.background_image ?? null : null, background_gradient: reuseCustomization ? activeCustomization?.background_gradient ?? activeLayout.preview?.background_gradient ?? null : activeLayout.preview?.background_gradient ?? null, logo_data_url: reuseCustomization ? activeCustomization?.logo_data_url ?? activeCustomization?.logo_url ?? null : null, mode: reuseCustomization ? activeCustomization?.mode : 'standard', @@ -1285,9 +1295,10 @@ export function InviteLayoutCustomizerPanel({ blocks.push(
- updateForm(binding.field, event.target.value as never)} + className="min-h-[72px]" />
); @@ -1551,6 +1562,7 @@ export function InviteLayoutCustomizerPanel({ logoDataUrl: form.logo_data_url ?? form.logo_url ?? null, backgroundColor: form.background_color ?? activeLayout?.preview?.background ?? '#FFFFFF', backgroundGradient: form.background_gradient ?? activeLayout?.preview?.background_gradient ?? null, + backgroundImageUrl: form.background_image ?? null, readOnly: true, selectedId: null, } as const; @@ -1595,6 +1607,7 @@ export function InviteLayoutCustomizerPanel({ logoDataUrl: form.logo_data_url ?? form.logo_url ?? null, backgroundColor: form.background_color ?? activeLayout?.preview?.background ?? '#FFFFFF', backgroundGradient: form.background_gradient ?? activeLayout?.preview?.background_gradient ?? null, + backgroundImageUrl: form.background_image ?? null, readOnly: true, selectedId: null, } as const; @@ -1738,18 +1751,20 @@ export function InviteLayoutCustomizerPanel({
- updateForm('headline', event.target.value)} + className="min-h-[68px]" />
- updateForm('subtitle', event.target.value)} + className="min-h-[68px]" />
@@ -1761,39 +1776,23 @@ export function InviteLayoutCustomizerPanel({ className="min-h-[96px]" />
-
-
- - updateForm('badge_label', event.target.value)} - /> -
-
- - updateForm('cta_label', event.target.value)} - /> -
-
- updateForm('link_heading', event.target.value)} + className="min-h-[68px]" />
- updateForm('link_label', event.target.value)} + className="min-h-[68px]" />
@@ -1832,18 +1831,56 @@ export function InviteLayoutCustomizerPanel({ className="h-11" />
-
- - updateForm('badge_color', event.target.value)} - className="h-11" - /> -
+ {backgroundImages.length ? ( +
+
+ + {form.background_image ? ( + + ) : null} +
+

+ {t('invites.customizer.fields.backgroundImageHint', 'Wähle ein Bild. Es ersetzt den Farbverlauf und füllt den ganzen Hintergrund.')} +

+
+ {backgroundImages.map((item) => { + const isActive = form.background_image === item.url; + return ( + + ); + })} +
+
+ ) : null} +
{form.logo_data_url ? ( @@ -2075,6 +2112,7 @@ export function InviteLayoutCustomizerPanel({ onChange={updateElement} background={form.background_color ?? activeLayout.preview?.background ?? '#FFFFFF'} gradient={form.background_gradient ?? activeLayout.preview?.background_gradient ?? null} + backgroundImageUrl={form.background_image ?? null} accent={form.accent_color ?? activeLayout.preview?.accent ?? '#6366F1'} text={form.text_color ?? activeLayout.preview?.text ?? '#111827'} secondary={form.secondary_color ?? '#1F2937'} diff --git a/resources/js/admin/pages/components/invite-layout/DesignerCanvas.tsx b/resources/js/admin/pages/components/invite-layout/DesignerCanvas.tsx index 27c576d..7cb3faa 100644 --- a/resources/js/admin/pages/components/invite-layout/DesignerCanvas.tsx +++ b/resources/js/admin/pages/components/invite-layout/DesignerCanvas.tsx @@ -22,6 +22,7 @@ type DesignerCanvasProps = { badge: string; qrCodeDataUrl: string | null; logoDataUrl: string | null; + backgroundImageUrl?: string | null; scale?: number; readOnly?: boolean; layoutKey?: string; @@ -36,6 +37,7 @@ export function DesignerCanvas({ onChange, background, gradient, + backgroundImageUrl = null, accent, text, secondary, @@ -233,6 +235,8 @@ export function DesignerCanvas({ return; } const elementId = target.elementId; + const action = event.transform?.action ?? null; + const isScalingAction = action?.startsWith('scale') || action === 'resize'; const bounds = target.getBoundingRect(); const nextPatch: Partial = { @@ -240,61 +244,55 @@ export function DesignerCanvas({ y: clamp(Math.round(bounds.top ?? 0), 20, CANVAS_HEIGHT - 20), }; - // Manual collision check: Calculate overlap and push vertically - const otherObjects = canvas - .getObjects() - .filter((obj): obj is FabricObjectWithId => obj !== target && Boolean((obj as FabricObjectWithId).elementId)); - otherObjects.forEach((other) => { - const otherBounds = other.getBoundingRect(); - const overlapX = Math.max(0, Math.min(bounds.left + bounds.width, otherBounds.left + otherBounds.width) - Math.max(bounds.left, otherBounds.left)); - const overlapY = Math.max(0, Math.min(bounds.top + bounds.height, otherBounds.top + otherBounds.height) - Math.max(bounds.top, otherBounds.top)); - if (overlapX > 0 && overlapY > 0) { - // Push down by 120px if overlap (massive spacing für größeren QR-Code) - nextPatch.y = Math.max(nextPatch.y ?? 0, (Number(otherBounds.top || 0)) + (Number(otherBounds.height || 0)) + 120); - } - }); - const isImage = target.type === 'image'; - if (isImage) { - const currentScaleX = target.scaleX ?? 1; - const currentScaleY = target.scaleY ?? 1; - const naturalWidth = target.width ?? 0; - const naturalHeight = target.height ?? 0; - if (elementId === 'qr') { - // For QR: Enforce uniform scale, cap size, padding=0 - const avgScale = (currentScaleX + currentScaleY) / 2; - const cappedSize = Math.min(Math.round(naturalWidth * avgScale), 800); // Cap at 800px for massive QR - nextPatch.width = cappedSize; - nextPatch.height = cappedSize; - nextPatch.scaleX = cappedSize / naturalWidth; - nextPatch.scaleY = cappedSize / naturalHeight; + if (isScalingAction) { + if (isImage) { + const currentScaleX = target.scaleX ?? 1; + const currentScaleY = target.scaleY ?? 1; + const naturalWidth = target.width ?? 0; + const naturalHeight = target.height ?? 0; + if (elementId === 'qr') { + // For QR: Enforce uniform scale, cap size, padding=0 + const avgScale = (currentScaleX + currentScaleY) / 2; + const cappedSize = Math.min(Math.round(naturalWidth * avgScale), 800); // Cap at 800px for massive QR + nextPatch.width = cappedSize; + nextPatch.height = cappedSize; + nextPatch.scaleX = cappedSize / naturalWidth; + nextPatch.scaleY = cappedSize / naturalHeight; + target.set({ + left: nextPatch.x, + top: nextPatch.y, + scaleX: nextPatch.scaleX, + scaleY: nextPatch.scaleY, + padding: 12, // Increased padding for better frame visibility + uniformScaling: true, // Lock aspect ratio + lockScalingFlip: true, + }); + } else { + nextPatch.width = Math.round(naturalWidth * currentScaleX); + nextPatch.height = Math.round(naturalHeight * currentScaleY); + nextPatch.scaleX = currentScaleX; + nextPatch.scaleY = currentScaleY; + target.set({ left: nextPatch.x, top: nextPatch.y, padding: 10 }); + } + } else { + nextPatch.width = clamp(Math.round(bounds.width ?? target.width ?? 0), 40, CANVAS_WIDTH - 40); + nextPatch.height = clamp(Math.round(bounds.height ?? target.height ?? 0), 40, CANVAS_HEIGHT - 40); target.set({ + scaleX: 1, + scaleY: 1, left: nextPatch.x, top: nextPatch.y, - scaleX: nextPatch.scaleX, - scaleY: nextPatch.scaleY, - padding: 12, // Increased padding for better frame visibility - uniformScaling: true, // Lock aspect ratio - lockScalingFlip: true, + width: nextPatch.width, + height: nextPatch.height, + padding: 10, // Default padding for text }); - } else { - nextPatch.width = Math.round(naturalWidth * currentScaleX); - nextPatch.height = Math.round(naturalHeight * currentScaleY); - nextPatch.scaleX = currentScaleX; - nextPatch.scaleY = currentScaleY; - target.set({ left: nextPatch.x, top: nextPatch.y, padding: 10 }); } } else { - nextPatch.width = clamp(Math.round(bounds.width ?? target.width ?? 0), 40, CANVAS_WIDTH - 40); - nextPatch.height = clamp(Math.round(bounds.height ?? target.height ?? 0), 40, CANVAS_HEIGHT - 40); + // Dragging: keep size, only move target.set({ - scaleX: 1, - scaleY: 1, left: nextPatch.x, top: nextPatch.y, - width: nextPatch.width, - height: nextPatch.height, - padding: 10, // Default padding for text }); } @@ -349,6 +347,7 @@ export function DesignerCanvas({ logoDataUrl, background, gradient, + backgroundImageUrl, readOnly, }); @@ -367,6 +366,7 @@ export function DesignerCanvas({ logoDataUrl, backgroundColor: background, backgroundGradient: gradient, + backgroundImageUrl, readOnly, }).catch((error) => { console.error('[Fabric] Failed to render layout', error); @@ -381,6 +381,7 @@ export function DesignerCanvas({ logoDataUrl, background, gradient, + backgroundImageUrl, readOnly, ]); @@ -456,6 +457,7 @@ export type FabricRenderOptions = { logoDataUrl: string | null; backgroundColor: string; backgroundGradient: { angle?: number; stops?: string[] } | null; + backgroundImageUrl?: string | null; readOnly: boolean; }; @@ -473,13 +475,21 @@ export async function renderFabricLayout( logoDataUrl, backgroundColor, backgroundGradient, + backgroundImageUrl, readOnly, } = options; canvas.discardActiveObject(); + // Aggressively clear previous objects/state to avoid stacking duplicates between renders. + try { + const existing = canvas.getObjects(); + existing.forEach((obj) => canvas.remove(obj)); + } catch (error) { + console.warn('[Invites][Fabric] failed to remove existing objects', error); + } canvas.clear(); - applyBackground(canvas, backgroundColor, backgroundGradient); + await applyBackground(canvas, backgroundColor, backgroundGradient, backgroundImageUrl); console.debug('[Invites][Fabric] render', { elementCount: elements.length, @@ -543,11 +553,70 @@ export async function renderFabricLayout( canvas.renderAll(); } -export function applyBackground( +export async function applyBackground( canvas: fabric.Canvas, color: string, gradient: { angle?: number; stops?: string[] } | null, -): void { + backgroundImageUrl?: string | null, +): Promise { + try { + if (typeof canvas.setBackgroundImage === 'function') { + canvas.setBackgroundImage(null, canvas.requestRenderAll.bind(canvas)); + } else { + // Fallback for environments where setBackgroundImage is not present + (canvas as fabric.StaticCanvas).backgroundImage = null; + canvas.requestRenderAll(); + } + + if (backgroundImageUrl) { + try { + const resolvedUrl = backgroundImageUrl.startsWith('http') + ? backgroundImageUrl + : `${window.location.origin}${backgroundImageUrl.startsWith('/') ? '' : '/'}${backgroundImageUrl}`; + const image = await new Promise((resolve) => { + const imgEl = new Image(); + imgEl.crossOrigin = 'anonymous'; + const timeoutId = window.setTimeout(() => resolve(null), 3000); + imgEl.onload = () => { + window.clearTimeout(timeoutId); + resolve(new fabric.Image(imgEl, { crossOrigin: 'anonymous' })); + }; + imgEl.onerror = () => { + window.clearTimeout(timeoutId); + resolve(null); + }; + imgEl.src = resolvedUrl; + }); + if (image) { + const scaleX = CANVAS_WIDTH / (image.width || CANVAS_WIDTH); + const scaleY = CANVAS_HEIGHT / (image.height || CANVAS_HEIGHT); + const scale = Math.max(scaleX, scaleY); + image.set({ + originX: 'left', + originY: 'top', + left: 0, + top: 0, + scaleX: scale, + scaleY: scale, + selectable: false, + evented: false, + }); + if (typeof canvas.setBackgroundImage === 'function') { + canvas.setBackgroundImage(image, canvas.requestRenderAll.bind(canvas)); + } else { + (canvas as fabric.StaticCanvas).backgroundImage = image; + canvas.requestRenderAll(); + } + return; + } + } catch (error) { + console.warn('[Fabric] Failed to load background image', error); + } + } + } catch (error) { + console.warn('[Fabric] applyBackground failed', error); + } + let background: string | fabric.Gradient<'linear'> = color; if (gradient?.stops?.length) { @@ -821,15 +890,34 @@ export async function loadImageObject( const intrinsicWidth = image.width ?? element.width; const intrinsicHeight = image.height ?? element.height; - const scaleX = element.width / intrinsicWidth; - const scaleY = element.height / intrinsicHeight; + const safeIntrinsicWidth = intrinsicWidth || 1; + const safeIntrinsicHeight = intrinsicHeight || 1; + + let targetLeft = element.x; + let targetTop = element.y; + let scaleX = element.width / safeIntrinsicWidth; + let scaleY = element.height / safeIntrinsicHeight; + + if (options?.objectFit === 'contain') { + const ratio = Math.min(scaleX, scaleY); + scaleX = ratio; + scaleY = ratio; + const renderedWidth = safeIntrinsicWidth * ratio; + const renderedHeight = safeIntrinsicHeight * ratio; + targetLeft = element.x + (element.width - renderedWidth) / 2; + targetTop = element.y + (element.height - renderedHeight) / 2; + } image.set({ ...baseConfig, - width: element.width, - height: element.height, + originX: 'left', + originY: 'top', + width: safeIntrinsicWidth, + height: safeIntrinsicHeight, scaleX, scaleY, + left: targetLeft, + top: targetTop, padding: options?.padding ?? 0, }); @@ -837,16 +925,6 @@ export async function loadImageObject( image.set('shadow', options.shadow); } - if (options?.objectFit === 'contain') { - const ratio = Math.min(scaleX, scaleY); - image.set({ - scaleX: ratio, - scaleY: ratio, - left: element.x + (element.width - intrinsicWidth * ratio) / 2, - top: element.y + (element.height - intrinsicHeight * ratio) / 2, - }); - } - resolveSafely(image); }; diff --git a/resources/js/admin/pages/components/invite-layout/backgrounds.ts b/resources/js/admin/pages/components/invite-layout/backgrounds.ts new file mode 100644 index 0000000..1a00e4b --- /dev/null +++ b/resources/js/admin/pages/components/invite-layout/backgrounds.ts @@ -0,0 +1,44 @@ +export type BackgroundImageOption = { + id: string; + url: string; + label: string; +}; + +// Preload background assets from public/storage/layouts/backgrounds. +// Vite does not process the public directory, so we try a glob (for cases where assets are in src) +// and fall back to known public URLs. +const backgroundImports: Record = { + ...import.meta.glob('../../../../../public/storage/layouts/backgrounds/*.{jpg,jpeg,png,webp,avif}', { + eager: true, + as: 'url', + }), + ...import.meta.glob('/storage/layouts/backgrounds/*.{jpg,jpeg,png,webp,avif}', { + eager: true, + as: 'url', + }), +}; + +const fallbackFiles = ['bg-blue-floral.png', 'bg-goldframe.png', 'gr-green-floral.png']; + +const importedBackgrounds: BackgroundImageOption[] = Object.entries(backgroundImports).map(([path, url]) => { + const filename = path.split('/').pop() ?? path; + const id = filename.replace(/\.[^.]+$/, ''); + return { id, url: url as string, label: filename }; +}); + +const fallbackBackgrounds: BackgroundImageOption[] = fallbackFiles.map((filename) => ({ + id: filename.replace(/\.[^.]+$/, ''), + url: `/storage/layouts/backgrounds/${filename}`, + label: filename, +})); + +const merged = [...importedBackgrounds, ...fallbackBackgrounds]; + +export const preloadedBackgrounds: BackgroundImageOption[] = Array.from( + merged.reduce((map, item) => { + if (!map.has(item.id)) { + map.set(item.id, item); + } + return map; + }, new Map()), +).map(([, value]) => value); diff --git a/resources/js/admin/pages/components/invite-layout/schema.ts b/resources/js/admin/pages/components/invite-layout/schema.ts index f6685cc..564f214 100644 --- a/resources/js/admin/pages/components/invite-layout/schema.ts +++ b/resources/js/admin/pages/components/invite-layout/schema.ts @@ -127,6 +127,7 @@ export type QrLayoutCustomization = { secondary_color?: string; badge_color?: string; background_gradient?: { angle?: number; stops?: string[] } | null; + background_image?: string | null; logo_data_url?: string | null; logo_url?: string | null; mode?: 'standard' | 'advanced'; @@ -172,7 +173,6 @@ const DEFAULT_TYPE_STYLES: Record (c.canvasWidth - 280) / 2, y: 80, width: 280, height: 140, align: 'center' }, - { id: 'badge', type: 'badge', x: (c) => (c.canvasWidth - 520) / 2, y: 240, width: 520, height: 90, align: 'center', fontSize: 28, lineHeight: 1.4, letterSpacing: 0.5, fontFamily: 'Montserrat' }, { id: 'headline', type: 'headline', @@ -188,13 +188,11 @@ const DEFAULT_PRESET: LayoutPreset = [ { id: 'subtitle', type: 'subtitle', x: (c) => (c.canvasWidth - 800) / 2, y: 580, width: 800, height: 120, fontSize: 42, align: 'center', fontFamily: 'Montserrat', lineHeight: 1.4 }, { id: 'description', type: 'description', x: (c) => (c.canvasWidth - 900) / 2, y: 720, width: 900, height: 180, fontSize: 34, align: 'center', fontFamily: 'Lora', lineHeight: 1.5 }, { id: 'qr', type: 'qr', x: (c) => (c.canvasWidth - 500) / 2, y: 940, width: (c) => Math.min(c.qrSize, 500), height: (c) => Math.min(c.qrSize, 500) }, - { id: 'cta', type: 'cta', x: (c) => (c.canvasWidth - 600) / 2, y: (c) => 940 + Math.min(c.qrSize, 500) + 40, width: 600, height: 100, align: 'center', fontSize: 32, fontFamily: 'Montserrat', lineHeight: 1.4 }, { id: 'link', type: 'link', x: (c) => (c.canvasWidth - 700) / 2, y: (c) => 940 + Math.min(c.qrSize, 500) + 160, width: 700, height: 80, align: 'center', fontSize: 26, fontFamily: 'Montserrat', lineHeight: 1.5 }, ]; const evergreenVowsPreset: LayoutPreset = [ // Elegant, linksbündig mit verbesserter Balance { id: 'logo', type: 'logo', x: 120, y: 100, width: 300, height: 150, align: 'left' }, - { id: 'badge', type: 'badge', x: (c) => c.canvasWidth - 520 - 120, y: 125, width: 520, height: 90, align: 'right', fontSize: 28, lineHeight: 1.4, fontFamily: 'Montserrat' }, { id: 'headline', type: 'headline', @@ -239,13 +237,11 @@ const evergreenVowsPreset: LayoutPreset = [ width: (c) => Math.min(c.qrSize, 440), height: (c) => Math.min(c.qrSize, 440), }, - { id: 'cta', type: 'cta', x: (c) => c.canvasWidth - 440 - 120, y: (c) => 920 + Math.min(c.qrSize, 440) + 40, width: 440, height: 100, align: 'center', fontSize: 30, fontFamily: 'Montserrat', lineHeight: 1.4 }, { id: 'link', type: 'link', x: (c) => c.canvasWidth - 440 - 120, y: (c) => 920 + Math.min(c.qrSize, 440) + 160, width: 440, height: 80, align: 'center', fontSize: 24, fontFamily: 'Montserrat' }, ]; const midnightGalaPreset: LayoutPreset = [ // Zentriert, premium, mehr vertikaler Abstand - { id: 'badge', type: 'badge', x: (c) => (c.canvasWidth - 560) / 2, y: 120, width: 560, height: 90, align: 'center', fontSize: 28, fontFamily: 'Montserrat' }, { id: 'headline', type: 'headline', @@ -268,13 +264,11 @@ const midnightGalaPreset: LayoutPreset = [ width: (c) => Math.min(c.qrSize, 480), height: (c) => Math.min(c.qrSize, 480), }, - { id: 'cta', type: 'cta', x: (c) => (c.canvasWidth - 520) / 2, y: (c) => 880 + Math.min(c.qrSize, 480) + 40, width: 520, height: 100, align: 'center', fontSize: 30, fontFamily: 'Montserrat', lineHeight: 1.4 }, { id: 'link', type: 'link', x: (c) => (c.canvasWidth - 600) / 2, y: (c) => 880 + Math.min(c.qrSize, 480) + 160, width: 600, height: 80, align: 'center', fontSize: 24, fontFamily: 'Montserrat' }, ]; const gardenBrunchPreset: LayoutPreset = [ // Verspielt, asymmetrisch, aber ausbalanciert - { id: 'badge', type: 'badge', x: 120, y: 120, width: 500, height: 90, align: 'left', fontSize: 28, fontFamily: 'Montserrat' }, { id: 'headline', type: 'headline', x: 120, y: 240, width: 900, height: 200, fontSize: 90, align: 'left', fontFamily: 'Playfair Display', lineHeight: 1.3 }, { id: 'subtitle', type: 'subtitle', x: 120, y: 450, width: 700, height: 120, fontSize: 40, align: 'left', fontFamily: 'Montserrat', lineHeight: 1.4 }, { @@ -285,7 +279,6 @@ const gardenBrunchPreset: LayoutPreset = [ width: (c) => Math.min(c.qrSize, 460), height: (c) => Math.min(c.qrSize, 460), }, - { id: 'cta', type: 'cta', x: 120, y: (c) => 880 + Math.min(c.qrSize, 460) + 40, width: 460, height: 100, align: 'center', fontSize: 30, fontFamily: 'Montserrat' }, { id: 'description', type: 'description', @@ -303,7 +296,6 @@ const gardenBrunchPreset: LayoutPreset = [ const sparklerSoireePreset: LayoutPreset = [ // Festlich, zentriert, klar - { id: 'badge', type: 'badge', x: (c) => (c.canvasWidth - 560) / 2, y: 120, width: 560, height: 90, align: 'center', fontSize: 28, fontFamily: 'Montserrat' }, { id: 'headline', type: 'headline', @@ -326,14 +318,12 @@ const sparklerSoireePreset: LayoutPreset = [ width: (c) => Math.min(c.qrSize, 480), height: (c) => Math.min(c.qrSize, 480), }, - { id: 'cta', type: 'cta', x: (c) => (c.canvasWidth - 520) / 2, y: (c) => 880 + Math.min(c.qrSize, 480) + 40, width: 520, height: 100, align: 'center', fontSize: 30, fontFamily: 'Montserrat' }, { id: 'link', type: 'link', x: (c) => (c.canvasWidth - 600) / 2, y: (c) => 880 + Math.min(c.qrSize, 480) + 160, width: 600, height: 80, align: 'center', fontSize: 24, fontFamily: 'Montserrat' }, ]; const confettiBashPreset: LayoutPreset = [ // Zentriertes, luftiges Layout mit klarer Hierarchie. { id: 'logo', type: 'logo', x: (c) => (c.canvasWidth - 280) / 2, y: 80, width: 280, height: 140, align: 'center' }, - { id: 'badge', type: 'badge', x: (c) => (c.canvasWidth - 520) / 2, y: 240, width: 520, height: 90, align: 'center', fontSize: 28, lineHeight: 1.4, letterSpacing: 0.5, fontFamily: 'Montserrat' }, { id: 'headline', type: 'headline', @@ -378,18 +368,6 @@ const confettiBashPreset: LayoutPreset = [ width: (c) => Math.min(c.qrSize, 500), height: (c) => Math.min(c.qrSize, 500), }, - { - id: 'cta', - type: 'cta', - x: (c) => (c.canvasWidth - 600) / 2, - y: (c) => 940 + Math.min(c.qrSize, 500) + 40, - width: 600, - height: 100, - align: 'center', - fontSize: 32, - fontFamily: 'Montserrat', - lineHeight: 1.4, - }, { id: 'link', type: 'link', @@ -407,7 +385,6 @@ const confettiBashPreset: LayoutPreset = [ const balancedModernPreset: LayoutPreset = [ // Wahrhaftig balanciert: Text links, QR rechts { id: 'logo', type: 'logo', x: 120, y: 100, width: 300, height: 150, align: 'left' }, - { id: 'badge', type: 'badge', x: 120, y: 270, width: 500, height: 90, align: 'left', fontSize: 28, fontFamily: 'Montserrat' }, { id: 'headline', type: 'headline', @@ -452,7 +429,6 @@ const balancedModernPreset: LayoutPreset = [ width: 480, height: 480, }, - { id: 'cta', type: 'cta', x: (c) => c.canvasWidth - 480 - 120, y: 880, width: 480, height: 100, align: 'center', fontSize: 30, fontFamily: 'Montserrat' }, { id: 'link', type: 'link', x: (c) => c.canvasWidth - 480 - 120, y: 1000, width: 480, height: 80, align: 'center', fontSize: 24, fontFamily: 'Montserrat' }, ]; @@ -501,9 +477,7 @@ export function buildDefaultElements( headline: form.headline ?? eventName, subtitle: form.subtitle ?? layout.subtitle ?? '', description: form.description ?? layout.description ?? '', - badge: form.badge_label ?? layout.badge_label ?? 'Digitale Gästebox', link: form.link_label ?? '', - cta: form.cta_label ?? layout.cta_label ?? 'Scan mich & starte direkt', instructions_heading: instructionsHeading, instructions_text: instructionsList[0] ?? null, }; @@ -541,15 +515,9 @@ export function buildDefaultElements( case 'description': element.content = baseContent.description; break; - case 'badge': - element.content = baseContent.badge; - break; case 'link': element.content = baseContent.link; break; - case 'cta': - element.content = baseContent.cta; - break; case 'text-strip': element.content = instructionsList.join('\n').trim() || layout.description || 'Nutze diesen Bereich für zusätzliche Hinweise oder Storytelling.'; break; diff --git a/resources/js/hooks/useRateLimitHelper.ts b/resources/js/hooks/useRateLimitHelper.ts new file mode 100644 index 0000000..4dffde9 --- /dev/null +++ b/resources/js/hooks/useRateLimitHelper.ts @@ -0,0 +1,35 @@ +import { useMemo } from 'react'; + +type RateBucket = 'coupon' | 'voucher'; + +export function useRateLimitHelper(bucket: RateBucket) { + return useMemo(() => { + const key = (code: string) => `${bucket}:${code.toUpperCase()}`; + + return { + isLimited: (code: string): boolean => { + const item = localStorage.getItem(key(code)); + if (!item) return false; + const parsed = JSON.parse(item) as { attempts: number; ts: number }; + const ageSeconds = (Date.now() - parsed.ts) / 1000; + if (ageSeconds > 300) { + localStorage.removeItem(key(code)); + return false; + } + return parsed.attempts >= 3; + }, + bump: (code: string): void => { + const item = localStorage.getItem(key(code)); + if (!item) { + localStorage.setItem(key(code), JSON.stringify({ attempts: 1, ts: Date.now() })); + return; + } + const parsed = JSON.parse(item) as { attempts: number; ts: number }; + localStorage.setItem(key(code), JSON.stringify({ attempts: (parsed.attempts || 0) + 1, ts: Date.now() })); + }, + clear: (code: string): void => { + localStorage.removeItem(key(code)); + }, + }; + }, [bucket]); +} diff --git a/resources/js/lib/giftVouchers.ts b/resources/js/lib/giftVouchers.ts index 206a0c2..f675b2b 100644 --- a/resources/js/lib/giftVouchers.ts +++ b/resources/js/lib/giftVouchers.ts @@ -23,6 +23,19 @@ export type GiftVoucherCheckoutResponse = { id: string | null; }; +export type GiftVoucherLookupResponse = { + code: string; + amount: number; + currency: string; + expires_at: string | null; + recipient_name?: string | null; + recipient_email?: string | null; + purchaser_email?: string | null; + status: string; + redeemed_at?: string | null; + refunded_at?: string | null; +}; + export async function fetchGiftVoucherTiers(): Promise { const response = await fetch('/api/v1/marketing/gift-vouchers/tiers', { headers: { @@ -61,3 +74,45 @@ export async function createGiftVoucherCheckout(data: GiftVoucherCheckoutRequest return payload as GiftVoucherCheckoutResponse; } + +export async function fetchGiftVoucherByCheckout(checkoutId?: string | null, transactionId?: string | null): Promise { + if (!checkoutId && !transactionId) { + return null; + } + + const params = new URLSearchParams(); + if (checkoutId) params.set('checkout_id', checkoutId); + if (transactionId) params.set('transaction_id', transactionId); + + const response = await fetch(`/api/v1/marketing/gift-vouchers/lookup?${params.toString()}`, { + headers: { Accept: 'application/json' }, + }); + + if (!response.ok) { + return null; + } + + const payload = await response.json(); + + return (payload?.data ?? null) as GiftVoucherLookupResponse | null; +} + +export async function fetchGiftVoucherByCode(code: string): Promise { + const trimmed = code.trim(); + if (!trimmed) { + return null; + } + + const params = new URLSearchParams({ code: trimmed }); + const response = await fetch(`/api/v1/marketing/gift-vouchers/lookup?${params.toString()}`, { + headers: { Accept: 'application/json' }, + }); + + if (!response.ok) { + return null; + } + + const payload = await response.json(); + + return (payload?.data ?? null) as GiftVoucherLookupResponse | null; +} diff --git a/resources/js/pages/marketing/GiftVoucher.tsx b/resources/js/pages/marketing/GiftVoucher.tsx index 45e3bcd..3290ed9 100644 --- a/resources/js/pages/marketing/GiftVoucher.tsx +++ b/resources/js/pages/marketing/GiftVoucher.tsx @@ -7,14 +7,22 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; -import { fetchGiftVoucherTiers, createGiftVoucherCheckout, type GiftVoucherTier } from '@/lib/giftVouchers'; +import { + fetchGiftVoucherTiers, + createGiftVoucherCheckout, + fetchGiftVoucherByCode, + type GiftVoucherTier, + type GiftVoucherLookupResponse, +} from '@/lib/giftVouchers'; import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes'; import { cn } from '@/lib/utils'; +import { useRateLimitHelper } from '@/hooks/useRateLimitHelper'; function useGiftVoucherTiers(initial: GiftVoucherTier[] = []) { const [tiers, setTiers] = React.useState(initial); const [loading, setLoading] = React.useState(initial.length === 0); const [error, setError] = React.useState(null); + const { locale } = useLocalizedRoutes(); React.useEffect(() => { if (initial.length > 0) { @@ -22,10 +30,14 @@ function useGiftVoucherTiers(initial: GiftVoucherTier[] = []) { return; } fetchGiftVoucherTiers() - .then(setTiers) + .then((data) => { + const preferredCurrency = locale === 'en' ? 'USD' : 'EUR'; + const preferred = data.filter((tier) => tier.currency === preferredCurrency && tier.can_checkout); + setTiers(preferred.length > 0 ? preferred : data); + }) .catch((err) => setError(err?.message || 'Failed to load tiers')) .finally(() => setLoading(false)); - }, [initial]); + }, [initial, locale]); return { tiers, loading, error }; } @@ -45,6 +57,11 @@ function GiftVoucherPage({ tiers: initialTiers = [] }: { tiers: GiftVoucherTier[ accept_terms: false, }); const [errors, setErrors] = React.useState>({}); + const [lookupCode, setLookupCode] = React.useState(''); + const [lookupResult, setLookupResult] = React.useState(null); + const [lookupError, setLookupError] = React.useState(null); + const [lookupLoading, setLookupLoading] = React.useState(false); + const rateLimit = useRateLimitHelper('voucher'); const selectedTierKey = form.tier_key; @@ -96,6 +113,10 @@ function GiftVoucherPage({ tiers: initialTiers = [] }: { tiers: GiftVoucherTier[ return_url: returnUrl, }); + if (response.id) { + sessionStorage.setItem('gift_checkout_id', response.id); + } + if (response.checkout_url) { window.location.assign(response.checkout_url); } else { @@ -108,6 +129,32 @@ function GiftVoucherPage({ tiers: initialTiers = [] }: { tiers: GiftVoucherTier[ } }; + const onLookup = async () => { + if (rateLimit.isLimited(lookupCode)) { + setLookupError(t('gift.too_many_attempts')); + return; + } + + setLookupLoading(true); + setLookupError(null); + setLookupResult(null); + try { + const result = await fetchGiftVoucherByCode(lookupCode); + if (result) { + setLookupResult(result); + rateLimit.clear(lookupCode); + } else { + setLookupError(t('gift.lookup_not_found')); + rateLimit.bump(lookupCode); + } + } catch (error: any) { + setLookupError(error?.message || t('gift.lookup_not_found')); + rateLimit.bump(lookupCode); + } finally { + setLookupLoading(false); + } + }; + return (
@@ -147,7 +194,6 @@ function GiftVoucherPage({ tiers: initialTiers = [] }: { tiers: GiftVoucherTier[ selectedTierKey === tier.key ? 'border-primary shadow-lg' : '', !tier.can_checkout && 'opacity-60' )} - onClick={() => tier.can_checkout && setValue('tier_key', tier.key, { shouldValidate: true })} onClick={() => tier.can_checkout && updateField('tier_key', tier.key)} > @@ -247,6 +293,53 @@ function GiftVoucherPage({ tiers: initialTiers = [] }: { tiers: GiftVoucherTier[ + + + + {t('gift.lookup_title')} + {t('gift.lookup_subtitle')} + + +
+ + setLookupCode(e.target.value)} + /> +
+
+ +
+ {lookupError &&

{lookupError}

} + {lookupResult && ( +
+

+ {t('gift.lookup_result_code', { code: lookupResult.code })} +

+

+ {t('gift.lookup_result_value', { + amount: lookupResult.amount.toFixed(2), + currency: lookupResult.currency, + })} +

+ {lookupResult.expires_at && ( +

+ {t('gift.lookup_result_expires', { + date: new Date(lookupResult.expires_at).toLocaleDateString(locale || undefined), + })} +

+ )} +

+ {t(`gift.lookup_status.${lookupResult.status}`, lookupResult.status)} +

+
+ )} +
+
diff --git a/resources/js/pages/marketing/Success.tsx b/resources/js/pages/marketing/Success.tsx index a110972..0cfab4a 100644 --- a/resources/js/pages/marketing/Success.tsx +++ b/resources/js/pages/marketing/Success.tsx @@ -7,6 +7,7 @@ import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes'; import { useLocale } from '@/hooks/useLocale'; import { ADMIN_HOME_PATH } from '@/admin/constants'; import { Button } from '@/components/ui/button'; +import { fetchGiftVoucherByCheckout, type GiftVoucherLookupResponse } from '@/lib/giftVouchers'; type SuccessProps = { type?: string; @@ -16,6 +17,58 @@ const GiftSuccess: React.FC = () => { const { t } = useTranslation('marketing'); const { localizedPath } = useLocalizedRoutes(); const locale = useLocale(); + const [voucher, setVoucher] = React.useState(null); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(null); + const [copied, setCopied] = React.useState(false); + + React.useEffect(() => { + const params = new URLSearchParams(window.location.search); + const checkoutId = params.get('checkout_id') || sessionStorage.getItem('gift_checkout_id'); + const transactionId = params.get('transaction_id'); + + fetchGiftVoucherByCheckout(checkoutId || undefined, transactionId || undefined) + .then((data) => { + if (data) { + setVoucher(data); + } else { + setError(t('success.gift_lookup_failed')); + } + }) + .catch(() => setError(t('success.gift_lookup_failed'))) + .finally(() => setLoading(false)); + }, [t]); + + const onCopy = async () => { + if (!voucher?.code) return; + try { + await navigator.clipboard.writeText(voucher.code); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (e) { + setError(t('success.gift_copy_failed')); + } + }; + + const onShare = async () => { + if (!voucher?.code) return; + const text = t('success.gift_share_text', { + code: voucher.code, + amount: voucher.amount.toFixed(2), + currency: voucher.currency, + }); + + if (navigator.share) { + try { + await navigator.share({ title: t('success.gift_title'), text }); + return; + } catch (e) { + // fall back to copy + } + } + + await onCopy(); + }; return ( @@ -24,6 +77,42 @@ const GiftSuccess: React.FC = () => {

{t('success.gift_title')}

{t('success.gift_description')}

+
+

{t('success.gift_code_title')}

+ {loading &&

{t('success.gift_loading')}

} + {error &&

{error}

} + {voucher && ( +
+
+
+

{t('success.gift_code_label')}

+

{voucher.code}

+
+
+ + +
+
+

+ {t('success.gift_value', { + amount: voucher.amount.toFixed(2), + currency: voucher.currency, + })} +

+ {voucher.expires_at && ( +

+ {t('success.gift_expires', { + date: new Date(voucher.expires_at).toLocaleDateString(locale || undefined), + })} +

+ )} +
+ )} +

{t('success.gift_bullets_title')}

    diff --git a/resources/js/pages/marketing/checkout/steps/PaymentStep.tsx b/resources/js/pages/marketing/checkout/steps/PaymentStep.tsx index 84954a5..e8a30e2 100644 --- a/resources/js/pages/marketing/checkout/steps/PaymentStep.tsx +++ b/resources/js/pages/marketing/checkout/steps/PaymentStep.tsx @@ -9,6 +9,7 @@ import { Separator } from '@/components/ui/separator'; import { previewCoupon as requestCouponPreview } from '@/lib/coupons'; import type { CouponPreviewResponse } from '@/types/coupon'; import { cn } from '@/lib/utils'; +import { useRateLimitHelper } from '@/hooks/useRateLimitHelper'; import toast from 'react-hot-toast'; import { Checkbox } from '@/components/ui/checkbox'; import { Label } from '@/components/ui/label'; @@ -154,6 +155,9 @@ export const PaymentStep: React.FC = () => { const [withdrawalTitle, setWithdrawalTitle] = useState(null); const [withdrawalLoading, setWithdrawalLoading] = useState(false); const [withdrawalError, setWithdrawalError] = useState(null); + const RateLimitHelper = useRateLimitHelper('coupon'); + const [voucherExpiry, setVoucherExpiry] = useState(null); + const [isGiftVoucher, setIsGiftVoucher] = useState(false); const paddleLocale = useMemo(() => { const sourceLocale = i18n.language || (typeof document !== 'undefined' ? document.documentElement.lang : null); @@ -177,6 +181,11 @@ export const PaymentStep: React.FC = () => { return; } + if (RateLimitHelper.isLimited(trimmed)) { + setCouponError(t('coupon.errors.too_many_attempts')); + return; + } + setCouponLoading(true); setCouponError(null); setCouponNotice(null); @@ -190,6 +199,8 @@ export const PaymentStep: React.FC = () => { amount: preview.pricing.formatted.discount, }) ); + setVoucherExpiry(preview.coupon.expires_at ?? null); + setIsGiftVoucher(preview.coupon.code?.toUpperCase().startsWith('GIFT-') ?? false); if (typeof window !== 'undefined') { localStorage.setItem('preferred_coupon_code', preview.coupon.code); } @@ -197,6 +208,7 @@ export const PaymentStep: React.FC = () => { setCouponPreview(null); setCouponNotice(null); setCouponError(error instanceof Error ? error.message : t('coupon.errors.generic')); + RateLimitHelper.bump(trimmed); } finally { setCouponLoading(false); } @@ -742,9 +754,26 @@ export const PaymentStep: React.FC = () => { {t('coupon.fields.total')} {couponPreview.pricing.formatted.total}
+ {voucherExpiry && ( +
+ {t('coupon.fields.expires')} + {new Date(voucherExpiry).toLocaleDateString(i18n.language)} +
+ )} )} + {isGiftVoucher && ( +
+ {t('coupon.legal_note')}{' '} + + {t('coupon.legal_link')} + +
+ )} {!inlineActive && ( diff --git a/resources/lang/de/emails.php b/resources/lang/de/emails.php index 56a028b..09d230f 100644 --- a/resources/lang/de/emails.php +++ b/resources/lang/de/emails.php @@ -139,6 +139,29 @@ return [ ], ], + 'gift_voucher' => [ + 'purchaser' => [ + 'subject' => 'Dein Geschenkgutschein (:amount :currency)', + 'greeting' => 'Danke für deinen Kauf!', + 'body' => 'Hier ist dein Fotospiel-Geschenkgutschein im Wert von :amount :currency. Teile den Code mit deiner beschenkten Person: :recipient.', + 'recipient_fallback' => 'dein:e Beschenkte:r', + ], + 'recipient' => [ + 'subject' => 'Du hast einen Fotospiel-Geschenkgutschein erhalten (:amount :currency)', + 'greeting' => 'Du hast ein Geschenk bekommen!', + 'body' => ':purchaser hat dir einen Fotospiel-Geschenkgutschein im Wert von :amount :currency gesendet. Löse ihn mit dem untenstehenden Code ein.', + ], + 'code_label' => 'Gutscheincode', + 'redeem_hint' => 'Löse den Code beim Checkout für Endkunden-Pakete ein.', + 'expiry' => 'Gültig bis :date.', + 'message_title' => 'Persönliche Nachricht', + 'withdrawal' => 'Widerrufsbelehrung: Details ansehen (14 Tage; erlischt mit Einlösung).', + 'footer' => 'Viele Grüße,
dein Fotospiel Team', + 'printable' => 'Druckversion (mit QR)', + 'reminder' => 'Erinnerung: Dein Gutschein ist noch nicht eingelöst.', + 'expiry_soon' => 'Hinweis: Dein Gutschein läuft bald ab.', + ], + 'tenant_feedback' => [ 'subject' => 'Neues Feedback: :tenant (:sentiment)', 'unknown_tenant' => 'Unbekannter Tenant', diff --git a/resources/lang/de/marketing.json b/resources/lang/de/marketing.json index c7f1360..86b1ba8 100644 --- a/resources/lang/de/marketing.json +++ b/resources/lang/de/marketing.json @@ -215,7 +215,37 @@ "purchase_complete_title": "Kauf abschließen", "purchase_complete_desc": "Melden Sie sich an, um fortzufahren.", "login": "Anmelden", - "no_account": "Kein Konto? Registrieren" + "no_account": "Kein Konto? Registrieren", + "gift_code_title": "Dein Gutscheincode", + "gift_code_label": "Gutscheincode", + "gift_loading": "Gutschein wird geladen …", + "gift_lookup_failed": "Der Gutschein konnte nicht geladen werden. Bitte prüfe deine Bestätigungs-E-Mail.", + "gift_copy": "Code kopieren", + "gift_copied": "Kopiert!", + "gift_copy_failed": "Konnte nicht kopiert werden. Bitte erneut versuchen.", + "gift_share": "Teilen", + "gift_value": "Wert: :amount :currency", + "gift_expires": "Gültig bis :date", + "gift_share_text": "Hier ist dein Fotospiel-Geschenkgutschein. Code: :code (Wert :amount :currency)." + }, + "gift": { + "lookup_title": "Gutscheinstatus prüfen", + "lookup_subtitle": "Du hast schon einen Code? Prüfe Wert, Gültigkeit und Status.", + "lookup_label": "Gutscheincode", + "lookup_cta": "Code prüfen", + "lookup_not_found": "Gutschein nicht gefunden oder nicht mehr gültig.", + "lookup_result_code": "Code: :code", + "lookup_result_value": "Wert: :amount :currency", + "lookup_result_expires": "Gültig bis :date", + "lookup_status": { + "issued": "Status: Ausgestellt (einlösbar)", + "redeemed": "Status: Eingelöst", + "refunded": "Status: Erstattet", + "expired": "Status: Abgelaufen", + "reminder": "Erinnerung geplant", + "expiry": "Ablauf-Hinweis geplant" + }, + "too_many_attempts": "Zu viele Versuche. Bitte kurz warten und erneut probieren." }, "blog_show": { "title_suffix": " - Fotospiel Blog", diff --git a/resources/lang/de/marketing.php b/resources/lang/de/marketing.php index 1fe8835..1f1f74a 100644 --- a/resources/lang/de/marketing.php +++ b/resources/lang/de/marketing.php @@ -262,6 +262,7 @@ return [ 'discount' => 'Rabatt', 'tax' => 'MwSt.', 'total' => 'Gesamtsumme nach Rabatt', + 'expires' => 'Läuft ab', ], 'errors' => [ 'required' => 'Bitte gib einen Gutscheincode ein.', @@ -274,7 +275,10 @@ return [ 'not_synced' => 'Dieser Gutschein ist noch nicht bereit. Bitte versuche es später erneut.', 'package_not_configured' => 'Dieses Package unterstützt aktuell keine Gutscheine.', 'login_required' => 'Bitte melde dich an, um diesen Gutschein zu nutzen.', + 'too_many_attempts' => 'Zu viele Versuche. Bitte kurz warten und erneut versuchen.', 'generic' => 'Der Gutschein konnte nicht angewendet werden. Bitte versuche einen anderen.', ], + 'legal_note' => 'Geschenkgutscheine: 14 Tage Widerrufsrecht bis zur Einlösung; siehe Widerrufsbelehrung.', + 'legal_link' => 'Widerrufsbelehrung öffnen', ], ]; diff --git a/resources/lang/en/emails.php b/resources/lang/en/emails.php index 97f00d2..6e9f91f 100644 --- a/resources/lang/en/emails.php +++ b/resources/lang/en/emails.php @@ -197,4 +197,27 @@ return [ 'footer' => 'Please review and follow up if needed.', ], ], + + 'gift_voucher' => [ + 'purchaser' => [ + 'subject' => 'Your gift voucher (:amount :currency)', + 'greeting' => 'Thank you for your purchase!', + 'body' => 'Here is your Fotospiel gift voucher worth :amount :currency. You can share the code with your recipient: :recipient.', + 'recipient_fallback' => 'your recipient', + ], + 'recipient' => [ + 'subject' => 'You received a Fotospiel gift voucher (:amount :currency)', + 'greeting' => 'You have a gift!', + 'body' => ':purchaser sent you a Fotospiel gift voucher worth :amount :currency. Redeem it with the code below.', + ], + 'code_label' => 'Voucher code', + 'redeem_hint' => 'Redeem this code during checkout for any end customer package.', + 'expiry' => 'Valid until :date.', + 'message_title' => 'Personal message', + 'withdrawal' => 'Withdrawal policy: View details (14 days; expires upon redemption).', + 'footer' => 'Best regards,
The Fotospiel Team', + 'printable' => 'Printable version (with QR)', + 'reminder' => 'Reminder: You still have an unused voucher.', + 'expiry_soon' => 'Heads up: Your voucher will expire soon.', + ], ]; diff --git a/resources/lang/en/marketing.json b/resources/lang/en/marketing.json index 5960cc8..99c09eb 100644 --- a/resources/lang/en/marketing.json +++ b/resources/lang/en/marketing.json @@ -215,7 +215,37 @@ "purchase_complete_title": "Complete Purchase", "purchase_complete_desc": "Log in to continue.", "login": "Login", - "no_account": "No Account? Register" + "no_account": "No Account? Register", + "gift_code_title": "Your gift voucher code", + "gift_code_label": "Voucher code", + "gift_loading": "Loading your voucher…", + "gift_lookup_failed": "We could not load the voucher. Please check your confirmation email.", + "gift_copy": "Copy code", + "gift_copied": "Copied!", + "gift_copy_failed": "Copying failed. Please try again.", + "gift_share": "Share", + "gift_value": "Value: :amount :currency", + "gift_expires": "Valid until :date", + "gift_share_text": "Here is your Fotospiel gift voucher. Code: :code (value :amount :currency)." + }, + "gift": { + "lookup_title": "Check voucher status", + "lookup_subtitle": "Already have a code? See value, validity, and status.", + "lookup_label": "Voucher code", + "lookup_cta": "Check code", + "lookup_not_found": "Voucher not found or no longer valid.", + "lookup_result_code": "Code: :code", + "lookup_result_value": "Value: :amount :currency", + "lookup_result_expires": "Valid until :date", + "lookup_status": { + "issued": "Status: Issued (ready to redeem)", + "redeemed": "Status: Redeemed", + "refunded": "Status: Refunded", + "expired": "Status: Expired", + "reminder": "Reminder scheduled", + "expiry": "Expiry reminder scheduled" + }, + "too_many_attempts": "Too many attempts. Please wait a moment and try again." }, "blog_show": { "title_suffix": " - Fotospiel Blog", diff --git a/resources/lang/en/marketing.php b/resources/lang/en/marketing.php index 77234cf..77647f9 100644 --- a/resources/lang/en/marketing.php +++ b/resources/lang/en/marketing.php @@ -262,6 +262,7 @@ return [ 'discount' => 'Discount', 'tax' => 'Tax', 'total' => 'Total after discount', + 'expires' => 'Expires', ], 'errors' => [ 'required' => 'Please enter a coupon code.', @@ -274,7 +275,10 @@ return [ 'not_synced' => 'This coupon is not ready yet. Please try again later.', 'package_not_configured' => 'This package is not available for coupon redemptions.', 'login_required' => 'Please log in to use this coupon.', + 'too_many_attempts' => 'Too many attempts. Please wait a moment and try again.', 'generic' => 'We could not apply this coupon. Please try another one.', ], + 'legal_note' => 'Gift vouchers: 14-day withdrawal right until redemption; see withdrawal policy.', + 'legal_link' => 'Open withdrawal policy', ], ]; diff --git a/resources/views/emails/gift-voucher.blade.php b/resources/views/emails/gift-voucher.blade.php new file mode 100644 index 0000000..66dd9d4 --- /dev/null +++ b/resources/views/emails/gift-voucher.blade.php @@ -0,0 +1,65 @@ +@php + $withdrawalUrl = app()->getLocale() === 'de' ? url('/de/widerrufsbelehrung') : url('/en/withdrawal'); +@endphp + + + + + {{ $forRecipient ? __('emails.gift_voucher.recipient.subject', ['amount' => $amount, 'currency' => $currency]) : __('emails.gift_voucher.purchaser.subject', ['amount' => $amount, 'currency' => $currency]) }} + + +
+

+ {{ $forRecipient ? __('emails.gift_voucher.recipient.greeting') : __('emails.gift_voucher.purchaser.greeting') }} +

+

+ {!! $forRecipient + ? __('emails.gift_voucher.recipient.body', [ + 'amount' => $amount, + 'currency' => $currency, + 'purchaser' => $voucher->purchaser_email, + ]) + : __('emails.gift_voucher.purchaser.body', [ + 'amount' => $amount, + 'currency' => $currency, + 'recipient' => $voucher->recipient_email ?: __('emails.gift_voucher.purchaser.recipient_fallback'), + ]) + !!} +

+ + @if ($voucher->message) +
+ {{ __('emails.gift_voucher.message_title') }} +

{{ $voucher->message }}

+
+ @endif + +
+

{{ __('emails.gift_voucher.code_label') }}

+
+ {{ $voucher->code }} +
+

+ {{ __('emails.gift_voucher.redeem_hint') }} +

+ @isset($printUrl) +

+ {{ __('emails.gift_voucher.printable') }} +

+ @endisset +
+ +

+ {{ __('emails.gift_voucher.expiry', ['date' => optional($voucher->expires_at)->toFormattedDateString()]) }} +

+ +

+ {!! __('emails.gift_voucher.withdrawal', ['url' => $withdrawalUrl]) !!} +

+ +

+ {!! __('emails.gift_voucher.footer') !!} +

+
+ + diff --git a/resources/views/marketing/gift-voucher-print.blade.php b/resources/views/marketing/gift-voucher-print.blade.php new file mode 100644 index 0000000..1305187 --- /dev/null +++ b/resources/views/marketing/gift-voucher-print.blade.php @@ -0,0 +1,62 @@ +@php + use SimpleSoftwareIO\QrCode\Facades\QrCode; +@endphp + + + + + {{ __('Gift Voucher') }} - {{ $voucher->code }} + + + +
+
{{ __('Gift Voucher') }}
+

{{ config('app.name') }}

+

{{ __('Show or share this page, or scan the QR to redeem the voucher code at checkout.') }}

+ +
+
+

{{ __('Voucher code') }}

+
{{ $voucher->code }}
+

+ {{ __('Value') }}: {{ number_format((float) $voucher->amount, 2) }} {{ $voucher->currency }}
+ @if($voucher->expires_at) + {{ __('Valid until') }}: {{ $voucher->expires_at->toFormattedDateString() }} + @endif +

+
+
+ {!! QrCode::size(180)->generate($voucher->code) !!} +

{{ __('Scan to redeem code at checkout') }}

+
+
+ + @if($voucher->recipient_name || $voucher->recipient_email) +
+ {{ __('Recipient') }}: +

+ {{ $voucher->recipient_name ?? '' }} {{ $voucher->recipient_email ? '('.$voucher->recipient_email.')' : '' }} +

+
+ @endif + + @if($voucher->message) +
+ {{ __('Message') }} +

{{ $voucher->message }}

+
+ @endif +
+ + diff --git a/routes/api.php b/routes/api.php index c9eeb5d..f4a59af 100644 --- a/routes/api.php +++ b/routes/api.php @@ -46,6 +46,12 @@ Route::prefix('v1')->name('api.v1.')->group(function () { Route::get('/gift-vouchers/tiers', [\App\Http\Controllers\Api\Marketing\GiftVoucherCheckoutController::class, 'tiers']) ->middleware('throttle:60,1') ->name('gift-vouchers.tiers'); + Route::get('/gift-vouchers/lookup', [\App\Http\Controllers\Api\Marketing\GiftVoucherCheckoutController::class, 'show']) + ->middleware('throttle:gift-lookup') + ->name('gift-vouchers.lookup'); + Route::post('/gift-vouchers/resend', \App\Http\Controllers\Api\Marketing\GiftVoucherResendController::class) + ->middleware('throttle:gift-resend') + ->name('gift-vouchers.resend'); }); Route::post('/webhooks/revenuecat', [RevenueCatWebhookController::class, 'handle']) diff --git a/routes/web.php b/routes/web.php index c8cc226..c14197e 100644 --- a/routes/web.php +++ b/routes/web.php @@ -8,6 +8,7 @@ use App\Http\Controllers\DashboardController; use App\Http\Controllers\LegalPageController; use App\Http\Controllers\LocaleController; use App\Http\Controllers\MarketingController; +use App\Http\Controllers\Marketing\GiftVoucherPrintController; use App\Http\Controllers\PaddleCheckoutController; use App\Http\Controllers\PaddleWebhookController; use App\Http\Controllers\ProfileAccountController; @@ -112,6 +113,9 @@ Route::prefix('{locale}') Route::get('/success/{packageId?}', [MarketingController::class, 'success'])->name('marketing.success'); Route::get('/gutschein', [MarketingController::class, 'giftVouchers'])->name('marketing.gift-voucher.de'); Route::get('/gift-card', [MarketingController::class, 'giftVouchers'])->name('marketing.gift-voucher.en'); + Route::get('/gift-vouchers/{voucher}/print', GiftVoucherPrintController::class) + ->middleware('signed') + ->name('marketing.gift-vouchers.print'); Route::get('/impressum', [LegalPageController::class, 'show']) ->defaults('slug', 'impressum') diff --git a/tests/Feature/Api/GiftVoucherLookupTest.php b/tests/Feature/Api/GiftVoucherLookupTest.php new file mode 100644 index 0000000..1562e63 --- /dev/null +++ b/tests/Feature/Api/GiftVoucherLookupTest.php @@ -0,0 +1,52 @@ +create([ + 'code' => 'GIFT-TESTCODE', + 'amount' => 59.00, + 'currency' => 'EUR', + 'paddle_checkout_id' => 'chk_look_123', + 'status' => GiftVoucher::STATUS_ISSUED, + ]); + + $response = $this->getJson('/api/v1/marketing/gift-vouchers/lookup?checkout_id=chk_look_123'); + + $response->assertOk() + ->assertJsonPath('data.code', $voucher->code) + ->assertJsonPath('data.amount', 59) + ->assertJsonPath('data.currency', 'EUR'); + } + + public function test_it_returns_voucher_by_code(): void + { + $voucher = GiftVoucher::factory()->create([ + 'code' => 'GIFT-CODE', + 'amount' => 29.00, + 'currency' => 'EUR', + ]); + + $response = $this->getJson('/api/v1/marketing/gift-vouchers/lookup?code=gift-code'); + + $response->assertOk() + ->assertJsonPath('data.code', $voucher->code) + ->assertJsonPath('data.amount', 29); + } + + public function test_it_requires_identifier(): void + { + $response = $this->getJson('/api/v1/marketing/gift-vouchers/lookup'); + + $response->assertStatus(422); + } +} diff --git a/tests/Feature/Api/GiftVoucherResendTest.php b/tests/Feature/Api/GiftVoucherResendTest.php new file mode 100644 index 0000000..34e9ca2 --- /dev/null +++ b/tests/Feature/Api/GiftVoucherResendTest.php @@ -0,0 +1,41 @@ +set('gift-vouchers.reminder_days', 0); + config()->set('gift-vouchers.expiry_reminder_days', 0); + + $voucher = GiftVoucher::factory()->create([ + 'code' => 'GIFT-RESEND', + 'purchaser_email' => 'buyer@example.com', + 'recipient_email' => 'friend@example.com', + ]); + + $response = $this->postJson('/api/v1/marketing/gift-vouchers/resend', [ + 'code' => 'gift-resend', + ]); + + $response->assertOk(); + Mail::assertQueued(GiftVoucherIssued::class, 2); + } + + public function test_it_requires_code(): void + { + $response = $this->postJson('/api/v1/marketing/gift-vouchers/resend', []); + + $response->assertStatus(422); + } +} diff --git a/tests/Unit/GiftVoucherServiceTest.php b/tests/Unit/GiftVoucherServiceTest.php index 843f9fd..08a7dde 100644 --- a/tests/Unit/GiftVoucherServiceTest.php +++ b/tests/Unit/GiftVoucherServiceTest.php @@ -7,9 +7,11 @@ use App\Jobs\SyncCouponToPaddle; use App\Models\Coupon; use App\Models\GiftVoucher; use App\Models\Package; +use App\Mail\GiftVoucherIssued; use App\Services\GiftVouchers\GiftVoucherService; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Bus; +use Illuminate\Support\Facades\Mail; use Tests\TestCase; class GiftVoucherServiceTest extends TestCase @@ -80,4 +82,73 @@ class GiftVoucherServiceTest extends TestCase $this->assertSame(GiftVoucher::STATUS_REDEEMED, $voucher->refresh()->status); $this->assertNotNull($voucher->redeemed_at); } + + public function test_it_sends_notifications_to_purchaser_and_recipient_once(): void + { + Mail::fake(); + Bus::fake([SyncCouponToPaddle::class]); + config()->set('gift-vouchers.reminder_days', 0); + config()->set('gift-vouchers.expiry_reminder_days', 0); + + Package::factory()->create([ + 'type' => 'endcustomer', + 'paddle_price_id' => 'pri_pkg_001', + 'price' => 29, + ]); + + $payload = [ + 'id' => 'txn_456', + 'currency_code' => 'EUR', + 'totals' => [ + 'grand_total' => [ + 'amount' => 2900, + ], + ], + 'metadata' => [ + 'type' => 'gift_voucher', + 'purchaser_email' => 'buyer@example.com', + 'recipient_email' => 'friend@example.com', + 'app_locale' => 'de', + ], + 'checkout_id' => 'chk_notif', + ]; + + $service = $this->app->make(GiftVoucherService::class); + $voucher = $service->issueFromPaddle($payload); + + Mail::assertQueued(GiftVoucherIssued::class, 2); + $this->assertTrue((bool) ($voucher->metadata['notifications_sent'] ?? false)); + + // Second call (duplicate webhook) should not resend + $service->issueFromPaddle($payload); + Mail::assertQueued(GiftVoucherIssued::class, 2); + } + + public function test_it_resolves_amount_from_tier_by_price_id(): void + { + config()->set('gift-vouchers.tiers', [ + [ + 'key' => 'gift-standard-usd', + 'label' => 'Gift Standard (USD)', + 'amount' => 65.00, + 'currency' => 'USD', + 'paddle_price_id' => 'pri_usd_123', + ], + ]); + + Bus::fake([SyncCouponToPaddle::class]); + Mail::fake(); + + $payload = [ + 'id' => 'txn_usd', + 'price_id' => 'pri_usd_123', + 'currency_code' => 'USD', + ]; + + $service = $this->app->make(GiftVoucherService::class); + $voucher = $service->issueFromPaddle($payload); + + $this->assertSame(65.00, (float) $voucher->amount); + $this->assertSame('USD', $voucher->currency); + } }