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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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.

View File

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

View File

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

View File

@@ -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,17 +1831,55 @@ export function InviteLayoutCustomizerPanel({
className="h-11"
/>
</div>
</div>
{backgroundImages.length ? (
<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 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>
@@ -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'}

View File

@@ -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,21 +244,8 @@ 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 (isScalingAction) {
if (isImage) {
const currentScaleX = target.scaleX ?? 1;
const currentScaleY = target.scaleY ?? 1;
@@ -297,6 +288,13 @@ export function DesignerCanvas({
padding: 10, // Default padding for text
});
}
} else {
// Dragging: keep size, only move
target.set({
left: nextPatch.x,
top: nextPatch.y,
});
}
onChange(elementId, nextPatch);
canvas.requestRenderAll();
@@ -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);
};

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -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">

View File

@@ -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,7 +754,24 @@ 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>

View File

@@ -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',

View File

@@ -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",

View File

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

View File

@@ -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.',
],
];

View File

@@ -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",

View File

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

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

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

View File

@@ -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'])

View File

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

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

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

View File

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