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:
@@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
40
app/Jobs/NotifyGiftVoucherReminder.php
Normal file
40
app/Jobs/NotifyGiftVoucherReminder.php
Normal 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,
|
||||
]));
|
||||
}
|
||||
}
|
||||
}
|
||||
58
app/Mail/GiftVoucherIssued.php
Normal file
58
app/Mail/GiftVoucherIssued.php
Normal 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 [];
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('gift_vouchers', function (Blueprint $table) {
|
||||
$table->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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
@@ -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}
|
||||
|
||||
@@ -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<void>;
|
||||
@@ -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(
|
||||
<div className="space-y-2" key={`${element.id}-binding`}>
|
||||
<Label>{binding.label}</Label>
|
||||
<Input
|
||||
<Textarea
|
||||
value={value}
|
||||
onChange={(event) => updateForm(binding.field, event.target.value as never)}
|
||||
className="min-h-[72px]"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -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({
|
||||
<div className="grid gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invite-headline">{t('invites.customizer.fields.headline', 'Überschrift')}</Label>
|
||||
<Input
|
||||
<Textarea
|
||||
id="invite-headline"
|
||||
value={form.headline ?? ''}
|
||||
onChange={(event) => updateForm('headline', event.target.value)}
|
||||
className="min-h-[68px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invite-subtitle">{t('invites.customizer.fields.subtitle', 'Unterzeile')}</Label>
|
||||
<Input
|
||||
<Textarea
|
||||
id="invite-subtitle"
|
||||
value={form.subtitle ?? ''}
|
||||
onChange={(event) => updateForm('subtitle', event.target.value)}
|
||||
className="min-h-[68px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -1761,39 +1776,23 @@ export function InviteLayoutCustomizerPanel({
|
||||
className="min-h-[96px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invite-badge">{t('invites.customizer.fields.badge', 'Badge-Label')}</Label>
|
||||
<Input
|
||||
id="invite-badge"
|
||||
value={form.badge_label ?? ''}
|
||||
onChange={(event) => updateForm('badge_label', event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invite-cta">{t('invites.customizer.fields.cta', 'Call-to-Action')}</Label>
|
||||
<Input
|
||||
id="invite-cta"
|
||||
value={form.cta_label ?? ''}
|
||||
onChange={(event) => updateForm('cta_label', event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invite-link-heading">{t('invites.customizer.fields.linkHeading', 'Link-Überschrift')}</Label>
|
||||
<Input
|
||||
<Textarea
|
||||
id="invite-link-heading"
|
||||
value={form.link_heading ?? ''}
|
||||
onChange={(event) => updateForm('link_heading', event.target.value)}
|
||||
className="min-h-[68px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invite-link-label">{t('invites.customizer.fields.linkLabel', 'Link/Begleittext')}</Label>
|
||||
<Input
|
||||
<Textarea
|
||||
id="invite-link-label"
|
||||
value={form.link_label ?? ''}
|
||||
onChange={(event) => updateForm('link_label', event.target.value)}
|
||||
className="min-h-[68px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1832,18 +1831,56 @@ export function InviteLayoutCustomizerPanel({
|
||||
className="h-11"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invite-badge-color">{t('invites.customizer.fields.badgeColor', 'Badge')}</Label>
|
||||
<Input
|
||||
id="invite-badge-color"
|
||||
type="color"
|
||||
value={form.badge_color ?? '#2563EB'}
|
||||
onChange={(event) => updateForm('badge_color', event.target.value)}
|
||||
className="h-11"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{backgroundImages.length ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label>{t('invites.customizer.fields.backgroundImage', 'Hintergrundbild')}</Label>
|
||||
{form.background_image ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
updateForm('background_image', null as never);
|
||||
updateForm('background_gradient', null as never);
|
||||
}}
|
||||
>
|
||||
{t('invites.customizer.actions.removeBackgroundImage', 'Bild entfernen')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('invites.customizer.fields.backgroundImageHint', 'Wähle ein Bild. Es ersetzt den Farbverlauf und füllt den ganzen Hintergrund.')}
|
||||
</p>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
{backgroundImages.map((item) => {
|
||||
const isActive = form.background_image === item.url;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
updateForm('background_image', item.url as never);
|
||||
updateForm('background_gradient', null as never);
|
||||
}}
|
||||
className={cn(
|
||||
'group overflow-hidden rounded-lg border text-left shadow-sm transition focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2',
|
||||
isActive ? 'border-primary ring-2 ring-primary/50' : 'border-[var(--tenant-border-strong)]'
|
||||
)}
|
||||
>
|
||||
<div className="aspect-[3/4] w-full overflow-hidden bg-[var(--tenant-surface-muted)]">
|
||||
<img src={item.url} alt={item.label} className="h-full w-full object-cover transition group-hover:scale-105" />
|
||||
</div>
|
||||
<div className="p-2 text-xs text-muted-foreground line-clamp-1">{item.label}</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t('invites.customizer.fields.logo', 'Logo')}</Label>
|
||||
{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'}
|
||||
|
||||
@@ -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<LayoutElement> = {
|
||||
@@ -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<void> {
|
||||
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<fabric.Image | null>((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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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<string, string> = {
|
||||
...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<string, BackgroundImageOption>()),
|
||||
).map(([, value]) => value);
|
||||
@@ -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<LayoutElementType, { width: number; height: nu
|
||||
const DEFAULT_PRESET: LayoutPreset = [
|
||||
// Basierend auf dem zentrierten, modernen "confetti-bash"-Layout
|
||||
{ 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',
|
||||
@@ -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;
|
||||
|
||||
35
resources/js/hooks/useRateLimitHelper.ts
Normal file
35
resources/js/hooks/useRateLimitHelper.ts
Normal file
@@ -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]);
|
||||
}
|
||||
@@ -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<GiftVoucherTier[]> {
|
||||
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<GiftVoucherLookupResponse | null> {
|
||||
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<GiftVoucherLookupResponse | null> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<GiftVoucherTier[]>(initial);
|
||||
const [loading, setLoading] = React.useState(initial.length === 0);
|
||||
const [error, setError] = React.useState<string | null>(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<Record<string, string | null>>({});
|
||||
const [lookupCode, setLookupCode] = React.useState('');
|
||||
const [lookupResult, setLookupResult] = React.useState<GiftVoucherLookupResponse | null>(null);
|
||||
const [lookupError, setLookupError] = React.useState<string | null>(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 (
|
||||
<MarketingLayout title={t('gift.title')}>
|
||||
<section className="relative overflow-hidden bg-gradient-to-b from-background via-muted/40 to-background">
|
||||
@@ -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)}
|
||||
>
|
||||
<CardHeader>
|
||||
@@ -247,6 +293,53 @@ function GiftVoucherPage({ tiers: initialTiers = [] }: { tiers: GiftVoucherTier[
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<Card className="lg:col-span-3">
|
||||
<CardHeader>
|
||||
<CardTitle>{t('gift.lookup_title')}</CardTitle>
|
||||
<CardDescription>{t('gift.lookup_subtitle')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 md:grid-cols-[2fr,1fr,auto]">
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
<Label htmlFor="lookup_code">{t('gift.lookup_label')}</Label>
|
||||
<Input
|
||||
id="lookup_code"
|
||||
placeholder="GIFT-XXXXXX"
|
||||
value={lookupCode}
|
||||
onChange={(e) => setLookupCode(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<Button onClick={onLookup} disabled={lookupLoading || !lookupCode.trim()}>
|
||||
{lookupLoading ? t('gift.processing') : t('gift.lookup_cta')}
|
||||
</Button>
|
||||
</div>
|
||||
{lookupError && <p className="md:col-span-3 text-sm text-destructive">{lookupError}</p>}
|
||||
{lookupResult && (
|
||||
<div className="md:col-span-3 space-y-1 rounded-lg border bg-muted/40 p-4">
|
||||
<p className="text-sm font-semibold">
|
||||
{t('gift.lookup_result_code', { code: lookupResult.code })}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('gift.lookup_result_value', {
|
||||
amount: lookupResult.amount.toFixed(2),
|
||||
currency: lookupResult.currency,
|
||||
})}
|
||||
</p>
|
||||
{lookupResult.expires_at && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('gift.lookup_result_expires', {
|
||||
date: new Date(lookupResult.expires_at).toLocaleDateString(locale || undefined),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(`gift.lookup_status.${lookupResult.status}`, lookupResult.status)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -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<GiftVoucherLookupResponse | null>(null);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(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 (
|
||||
<MarketingLayout title={t('success.gift_title')}>
|
||||
@@ -24,6 +77,42 @@ const GiftSuccess: React.FC = () => {
|
||||
<CheckCircle className="mx-auto h-12 w-12 text-green-500" />
|
||||
<h1 className="text-3xl font-bold text-foreground">{t('success.gift_title')}</h1>
|
||||
<p className="text-muted-foreground">{t('success.gift_description')}</p>
|
||||
<div className="rounded-xl border bg-card p-6 text-left shadow-sm">
|
||||
<h2 className="text-lg font-semibold">{t('success.gift_code_title')}</h2>
|
||||
{loading && <p className="text-muted-foreground">{t('success.gift_loading')}</p>}
|
||||
{error && <p className="text-destructive">{error}</p>}
|
||||
{voucher && (
|
||||
<div className="mt-3 space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border bg-muted/50 px-4 py-3">
|
||||
<div className="text-left">
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">{t('success.gift_code_label')}</p>
|
||||
<p className="font-mono text-lg font-bold">{voucher.code}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={onCopy}>
|
||||
{copied ? t('success.gift_copied') : t('success.gift_copy')}
|
||||
</Button>
|
||||
<Button size="sm" onClick={onShare}>
|
||||
{t('success.gift_share')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('success.gift_value', {
|
||||
amount: voucher.amount.toFixed(2),
|
||||
currency: voucher.currency,
|
||||
})}
|
||||
</p>
|
||||
{voucher.expires_at && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('success.gift_expires', {
|
||||
date: new Date(voucher.expires_at).toLocaleDateString(locale || undefined),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-xl border bg-card p-6 text-left shadow-sm">
|
||||
<h2 className="text-lg font-semibold">{t('success.gift_bullets_title')}</h2>
|
||||
<ul className="mt-3 list-disc space-y-2 pl-5 text-muted-foreground">
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [withdrawalLoading, setWithdrawalLoading] = useState(false);
|
||||
const [withdrawalError, setWithdrawalError] = useState<string | null>(null);
|
||||
const RateLimitHelper = useRateLimitHelper('coupon');
|
||||
const [voucherExpiry, setVoucherExpiry] = useState<string | null>(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 = () => {
|
||||
<span>{t('coupon.fields.total')}</span>
|
||||
<span>{couponPreview.pricing.formatted.total}</span>
|
||||
</div>
|
||||
{voucherExpiry && (
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>{t('coupon.fields.expires')}</span>
|
||||
<span>{new Date(voucherExpiry).toLocaleDateString(i18n.language)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isGiftVoucher && (
|
||||
<div className="rounded-md border border-primary/30 bg-primary/5 p-3 text-xs text-muted-foreground">
|
||||
<span>{t('coupon.legal_note')}{' '}</span>
|
||||
<a
|
||||
href={i18n.language === 'de' ? '/de/widerrufsbelehrung' : '/en/withdrawal'}
|
||||
className="text-primary underline"
|
||||
>
|
||||
{t('coupon.legal_link')}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!inlineActive && (
|
||||
|
||||
@@ -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: <a href=":url">Details ansehen</a> (14 Tage; erlischt mit Einlösung).',
|
||||
'footer' => 'Viele Grüße,<br>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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -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: <a href=":url">View details</a> (14 days; expires upon redemption).',
|
||||
'footer' => 'Best regards,<br>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.',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
];
|
||||
|
||||
65
resources/views/emails/gift-voucher.blade.php
Normal file
65
resources/views/emails/gift-voucher.blade.php
Normal file
@@ -0,0 +1,65 @@
|
||||
@php
|
||||
$withdrawalUrl = app()->getLocale() === 'de' ? url('/de/widerrufsbelehrung') : url('/en/withdrawal');
|
||||
@endphp
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{{ $forRecipient ? __('emails.gift_voucher.recipient.subject', ['amount' => $amount, 'currency' => $currency]) : __('emails.gift_voucher.purchaser.subject', ['amount' => $amount, 'currency' => $currency]) }}</title>
|
||||
</head>
|
||||
<body style="font-family: Arial, sans-serif; background-color: #f7f7f7; padding: 20px; color: #111827;">
|
||||
<div style="max-width: 640px; margin: 0 auto; background: #ffffff; border-radius: 10px; padding: 28px; box-shadow: 0 10px 30px rgba(0,0,0,0.05);">
|
||||
<h1 style="margin-top: 0; font-size: 22px;">
|
||||
{{ $forRecipient ? __('emails.gift_voucher.recipient.greeting') : __('emails.gift_voucher.purchaser.greeting') }}
|
||||
</h1>
|
||||
<p style="font-size: 15px; line-height: 1.6; margin-bottom: 16px;">
|
||||
{!! $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'),
|
||||
])
|
||||
!!}
|
||||
</p>
|
||||
|
||||
@if ($voucher->message)
|
||||
<div style="margin: 18px 0; padding: 14px 16px; background: #f3f4f6; border-left: 4px solid #2563eb; border-radius: 8px;">
|
||||
<strong>{{ __('emails.gift_voucher.message_title') }}</strong>
|
||||
<p style="margin: 8px 0 0; white-space: pre-line;">{{ $voucher->message }}</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div style="margin: 18px 0; padding: 16px; border: 1px dashed #d1d5db; border-radius: 10px; background: #f9fafb;">
|
||||
<p style="margin: 0 0 6px; font-size: 14px; color: #6b7280;">{{ __('emails.gift_voucher.code_label') }}</p>
|
||||
<div style="display: inline-block; padding: 10px 14px; background: #111827; color: #ffffff; border-radius: 8px; font-weight: bold; letter-spacing: 1px;">
|
||||
{{ $voucher->code }}
|
||||
</div>
|
||||
<p style="margin: 10px 0 0; font-size: 14px; color: #4b5563;">
|
||||
{{ __('emails.gift_voucher.redeem_hint') }}
|
||||
</p>
|
||||
@isset($printUrl)
|
||||
<p style="margin: 8px 0 0; font-size: 14px;">
|
||||
<a href="{{ $printUrl }}">{{ __('emails.gift_voucher.printable') }}</a>
|
||||
</p>
|
||||
@endisset
|
||||
</div>
|
||||
|
||||
<p style="font-size: 14px; color: #4b5563; margin: 12px 0;">
|
||||
{{ __('emails.gift_voucher.expiry', ['date' => optional($voucher->expires_at)->toFormattedDateString()]) }}
|
||||
</p>
|
||||
|
||||
<p style="font-size: 14px; color: #4b5563; margin: 12px 0;">
|
||||
{!! __('emails.gift_voucher.withdrawal', ['url' => $withdrawalUrl]) !!}
|
||||
</p>
|
||||
|
||||
<p style="font-size: 14px; color: #4b5563; margin-top: 20px;">
|
||||
{!! __('emails.gift_voucher.footer') !!}
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
62
resources/views/marketing/gift-voucher-print.blade.php
Normal file
62
resources/views/marketing/gift-voucher-print.blade.php
Normal file
@@ -0,0 +1,62 @@
|
||||
@php
|
||||
use SimpleSoftwareIO\QrCode\Facades\QrCode;
|
||||
@endphp
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{{ __('Gift Voucher') }} - {{ $voucher->code }}</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; background: #f7f7f7; margin: 0; padding: 20px; color: #111827; }
|
||||
.wrap { max-width: 720px; margin: 0 auto; background: #fff; padding: 28px; border-radius: 14px; box-shadow: 0 10px 30px rgba(0,0,0,0.06); }
|
||||
.badge { display: inline-block; padding: 6px 10px; background: #eef2ff; color: #4338ca; border-radius: 10px; font-weight: 700; font-size: 12px; text-transform: uppercase; letter-spacing: .08em; }
|
||||
.code { display: inline-block; padding: 10px 14px; background: #111827; color: #fff; border-radius: 10px; font-weight: bold; letter-spacing: 1px; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fit,minmax(240px,1fr)); gap: 16px; margin-top: 18px; }
|
||||
.card { border: 1px solid #e5e7eb; border-radius: 12px; padding: 14px; background: #f9fafb; }
|
||||
.muted { color: #6b7280; font-size: 14px; }
|
||||
.title { font-size: 24px; margin: 10px 0; }
|
||||
.qr { text-align: center; }
|
||||
.qr svg { max-width: 180px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="badge">{{ __('Gift Voucher') }}</div>
|
||||
<h1 class="title">{{ config('app.name') }}</h1>
|
||||
<p class="muted">{{ __('Show or share this page, or scan the QR to redeem the voucher code at checkout.') }}</p>
|
||||
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<p class="muted">{{ __('Voucher code') }}</p>
|
||||
<div class="code">{{ $voucher->code }}</div>
|
||||
<p class="muted" style="margin-top:12px;">
|
||||
{{ __('Value') }}: {{ number_format((float) $voucher->amount, 2) }} {{ $voucher->currency }}<br>
|
||||
@if($voucher->expires_at)
|
||||
{{ __('Valid until') }}: {{ $voucher->expires_at->toFormattedDateString() }}
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
<div class="card qr">
|
||||
{!! QrCode::size(180)->generate($voucher->code) !!}
|
||||
<p class="muted">{{ __('Scan to redeem code at checkout') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if($voucher->recipient_name || $voucher->recipient_email)
|
||||
<div class="card" style="margin-top:16px;">
|
||||
<strong>{{ __('Recipient') }}:</strong>
|
||||
<p class="muted" style="margin:6px 0 0;">
|
||||
{{ $voucher->recipient_name ?? '' }} {{ $voucher->recipient_email ? '('.$voucher->recipient_email.')' : '' }}
|
||||
</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if($voucher->message)
|
||||
<div class="card" style="margin-top:16px;">
|
||||
<strong>{{ __('Message') }}</strong>
|
||||
<p style="margin:8px 0 0; white-space: pre-line;">{{ $voucher->message }}</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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'])
|
||||
|
||||
@@ -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')
|
||||
|
||||
52
tests/Feature/Api/GiftVoucherLookupTest.php
Normal file
52
tests/Feature/Api/GiftVoucherLookupTest.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Api;
|
||||
|
||||
use App\Models\GiftVoucher;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class GiftVoucherLookupTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_it_returns_voucher_by_checkout_id(): void
|
||||
{
|
||||
$voucher = GiftVoucher::factory()->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);
|
||||
}
|
||||
}
|
||||
41
tests/Feature/Api/GiftVoucherResendTest.php
Normal file
41
tests/Feature/Api/GiftVoucherResendTest.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Api;
|
||||
|
||||
use App\Models\GiftVoucher;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Tests\TestCase;
|
||||
use App\Mail\GiftVoucherIssued;
|
||||
|
||||
class GiftVoucherResendTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_it_resends_voucher_emails(): void
|
||||
{
|
||||
Mail::fake();
|
||||
config()->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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user