From 046e2fe3ec056ccfcdb26ae5f9f76ab3fd5f1699 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Sun, 7 Dec 2025 16:54:58 +0100 Subject: [PATCH] geschenkgutscheine implementiert ("Paket verschenken"). Neuer Upload-Provider: Sparkbooth. --- .../Resources/GiftVoucherResource.php | 109 ++++++++ .../Pages/ListGiftVouchers.php | 16 ++ .../Controllers/Api/EventPublicController.php | 2 +- .../GiftVoucherCheckoutController.php | 44 +++ .../Api/SparkboothUploadController.php | 139 ++++++++++ .../Api/Tenant/PhotoboothController.php | 22 +- app/Http/Controllers/MarketingController.php | 11 + .../Tenant/PhotoboothStatusResource.php | 38 ++- app/Models/Coupon.php | 6 + app/Models/Event.php | 26 ++ app/Models/GiftVoucher.php | 95 +++++++ app/Models/Photo.php | 2 + .../Checkout/CheckoutWebhookService.php | 31 +++ .../Coupons/CouponRedemptionService.php | 5 + .../GiftVoucherCheckoutService.php | 100 +++++++ .../GiftVouchers/GiftVoucherService.php | 215 +++++++++++++++ app/Services/Paddle/PaddleCatalogService.php | 22 +- .../PaddleGiftVoucherCatalogService.php | 92 +++++++ .../Exceptions/SparkboothUploadException.php | 16 ++ .../Photobooth/PhotoboothIngestService.php | 9 +- .../Photobooth/PhotoboothProvisioner.php | 74 +++++ .../Photobooth/SparkboothUploadService.php | 138 ++++++++++ config/gift-vouchers.php | 40 +++ config/paddle.php | 4 +- config/photobooth.php | 9 + database/factories/GiftVoucherFactory.php | 31 +++ ...2_07_130315_create_gift_vouchers_table.php | 53 ++++ ...add_sparkbooth_columns_to_events_table.php | 59 ++++ database/seeders/DatabaseSeeder.php | 1 + database/seeders/GiftVoucherTierSeeder.php | 70 +++++ docs/ops/photobooth/README.md | 49 +++- public/lang/de/marketing.json | 56 +++- public/lang/en/marketing.json | 55 +++- resources/js/admin/api.ts | 95 ++++++- .../js/admin/i18n/locales/de/management.json | 18 +- .../js/admin/i18n/locales/en/management.json | 16 +- .../js/admin/pages/EventPhotoboothPage.tsx | 132 +++++++-- resources/js/layouts/app/Footer.tsx | 22 +- resources/js/lib/giftVouchers.ts | 63 +++++ resources/js/pages/marketing/Demo.tsx | 12 + resources/js/pages/marketing/GiftVoucher.tsx | 259 ++++++++++++++++++ resources/js/pages/marketing/HowItWorks.tsx | 12 + resources/js/pages/marketing/Packages.tsx | 12 + resources/js/pages/marketing/Success.tsx | 99 ++++--- resources/lang/de/marketing.php | 8 + resources/lang/en/marketing.php | 8 + routes/api.php | 10 + routes/web.php | 2 + tests/Unit/GiftVoucherCheckoutServiceTest.php | 62 +++++ tests/Unit/GiftVoucherServiceTest.php | 83 ++++++ 50 files changed, 2422 insertions(+), 130 deletions(-) create mode 100644 app/Filament/Resources/GiftVoucherResource.php create mode 100644 app/Filament/Resources/GiftVoucherResource/Pages/ListGiftVouchers.php create mode 100644 app/Http/Controllers/Api/Marketing/GiftVoucherCheckoutController.php create mode 100644 app/Http/Controllers/Api/SparkboothUploadController.php create mode 100644 app/Models/GiftVoucher.php create mode 100644 app/Services/GiftVouchers/GiftVoucherCheckoutService.php create mode 100644 app/Services/GiftVouchers/GiftVoucherService.php create mode 100644 app/Services/Paddle/PaddleGiftVoucherCatalogService.php create mode 100644 app/Services/Photobooth/Exceptions/SparkboothUploadException.php create mode 100644 app/Services/Photobooth/SparkboothUploadService.php create mode 100644 config/gift-vouchers.php create mode 100644 database/factories/GiftVoucherFactory.php create mode 100644 database/migrations/2025_12_07_130315_create_gift_vouchers_table.php create mode 100644 database/migrations/2025_12_07_164119_add_sparkbooth_columns_to_events_table.php create mode 100644 database/seeders/GiftVoucherTierSeeder.php create mode 100644 resources/js/lib/giftVouchers.ts create mode 100644 resources/js/pages/marketing/GiftVoucher.tsx create mode 100644 tests/Unit/GiftVoucherCheckoutServiceTest.php create mode 100644 tests/Unit/GiftVoucherServiceTest.php diff --git a/app/Filament/Resources/GiftVoucherResource.php b/app/Filament/Resources/GiftVoucherResource.php new file mode 100644 index 0000000..07f7933 --- /dev/null +++ b/app/Filament/Resources/GiftVoucherResource.php @@ -0,0 +1,109 @@ +defaultSort('created_at', 'desc') + ->columns([ + TextColumn::make('code') + ->label('Code') + ->searchable() + ->copyable(), + TextColumn::make('amount') + ->label('Betrag') + ->money(fn (GiftVoucher $record) => $record->currency ?? 'EUR'), + BadgeColumn::make('status') + ->label('Status') + ->colors([ + 'success' => GiftVoucher::STATUS_REDEEMED, + 'warning' => GiftVoucher::STATUS_ISSUED, + 'danger' => [GiftVoucher::STATUS_REFUNDED, GiftVoucher::STATUS_EXPIRED], + ]), + TextColumn::make('purchaser_email') + ->label('Käufer') + ->toggleable() + ->searchable(), + TextColumn::make('recipient_email') + ->label('Empfänger') + ->toggleable() + ->searchable(), + TextColumn::make('paddle_transaction_id') + ->label('Paddle Tx') + ->toggleable() + ->copyable() + ->wrap(), + TextColumn::make('expires_at') + ->label('Gültig bis') + ->dateTime(), + TextColumn::make('redeemed_at') + ->label('Eingelöst am') + ->dateTime(), + TextColumn::make('refunded_at') + ->label('Erstattet am') + ->dateTime(), + TextColumn::make('created_at') + ->label('Erstellt') + ->dateTime() + ->sortable(), + ]) + ->filters([ + SelectFilter::make('status') + ->options([ + GiftVoucher::STATUS_ISSUED => 'Ausgestellt', + GiftVoucher::STATUS_REDEEMED => 'Eingelöst', + GiftVoucher::STATUS_REFUNDED => 'Erstattet', + GiftVoucher::STATUS_EXPIRED => 'Abgelaufen', + ]), + ]) + ->recordActions([ + Action::make('refund') + ->label('Refund') + ->requiresConfirmation() + ->visible(fn (GiftVoucher $record): bool => $record->canBeRefunded()) + ->action(function (GiftVoucher $record, GiftVoucherService $service): void { + $service->refund($record, 'customer_request'); + }) + ->successNotificationTitle('Gutschein erstattet'), + ]); + } + + public static function form(Schema $schema): Schema + { + return $schema->schema([]); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListGiftVouchers::route('/'), + ]; + } +} diff --git a/app/Filament/Resources/GiftVoucherResource/Pages/ListGiftVouchers.php b/app/Filament/Resources/GiftVoucherResource/Pages/ListGiftVouchers.php new file mode 100644 index 0000000..0d0fa4b --- /dev/null +++ b/app/Filament/Resources/GiftVoucherResource/Pages/ListGiftVouchers.php @@ -0,0 +1,16 @@ +where('photos.ingest_source', Photo::SOURCE_PHOTOBOOTH); + $query->whereIn('photos.ingest_source', [Photo::SOURCE_PHOTOBOOTH, Photo::SOURCE_SPARKBOOTH]); } elseif ($filter === 'myphotos' && $deviceId !== 'anon') { $query->where('guest_name', $deviceId); } diff --git a/app/Http/Controllers/Api/Marketing/GiftVoucherCheckoutController.php b/app/Http/Controllers/Api/Marketing/GiftVoucherCheckoutController.php new file mode 100644 index 0000000..b692806 --- /dev/null +++ b/app/Http/Controllers/Api/Marketing/GiftVoucherCheckoutController.php @@ -0,0 +1,44 @@ +json([ + 'data' => $this->checkout->tiers(), + ]); + } + + public function store(Request $request): JsonResponse + { + $data = $this->validate($request, [ + 'tier_key' => ['required', 'string'], + 'purchaser_email' => ['required', 'email:rfc,dns'], + 'recipient_email' => ['nullable', 'email:rfc,dns'], + 'recipient_name' => ['nullable', 'string', 'max:191'], + 'message' => ['nullable', 'string', 'max:500'], + 'success_url' => ['nullable', 'url'], + 'return_url' => ['nullable', 'url'], + ]); + + $checkout = $this->checkout->create($data); + + if (! $checkout['checkout_url']) { + throw ValidationException::withMessages([ + 'tier_key' => __('Unable to create Paddle checkout.'), + ]); + } + + return response()->json($checkout); + } +} diff --git a/app/Http/Controllers/Api/SparkboothUploadController.php b/app/Http/Controllers/Api/SparkboothUploadController.php new file mode 100644 index 0000000..279cdc3 --- /dev/null +++ b/app/Http/Controllers/Api/SparkboothUploadController.php @@ -0,0 +1,139 @@ +resolveMedia($request); + + if (! $media) { + return $this->respond(null, false, 'Media is required', null, 400, $request); + } + + try { + $result = $this->service->handleUpload( + $media, + $request->input('username'), + $request->input('password') + ); + + /** @var Event $event */ + $event = $result['event']; + + return $this->respond($event, true, null, null, 200, $request); + } catch (SparkboothUploadException $exception) { + return $this->respond(null, false, $exception->getMessage(), null, $exception->statusCode ?? 400, $request); + } catch (\Throwable) { + return $this->respond(null, false, 'Upload failed, please retry.', null, 500, $request); + } + } + + protected function respond(?Event $event, bool $ok, ?string $message, ?string $url, int $status, Request $request): Response + { + $format = $this->resolveFormat($event, $request); + + if ($format === 'xml') { + $payload = $ok + ? $this->buildSuccessXml($url) + : $this->buildFailureXml($message); + + return response($payload, $status, ['Content-Type' => 'application/xml']); + } + + return response()->json([ + 'status' => $ok, + 'error' => $ok ? null : $message, + 'url' => $url, + ], $status); + } + + protected function resolveFormat(?Event $event, Request $request): string + { + $preferred = $request->input('format'); + + if ($preferred && in_array($preferred, ['json', 'xml'], true)) { + return $preferred; + } + + $configured = $event?->photobooth_metadata['sparkbooth_response_format'] ?? null; + + if ($configured && in_array($configured, ['json', 'xml'], true)) { + return $configured; + } + + return config('photobooth.sparkbooth.response_format', 'json') === 'xml' ? 'xml' : 'json'; + } + + protected function buildSuccessXml(?string $url): string + { + $urlAttribute = $url ? ' url="'.htmlspecialchars($url, ENT_QUOTES).'"' : ''; + + return sprintf(''."\n".'', $urlAttribute); + } + + protected function buildFailureXml(?string $message): string + { + $escaped = htmlspecialchars($message ?? 'Upload failed', ENT_QUOTES); + + return sprintf( + ''."\n".'', + $escaped + ); + } + + protected function resolveMedia(Request $request): ?UploadedFile + { + $file = $request->file('media'); + + if ($file instanceof UploadedFile) { + return $file; + } + + $raw = $request->input('media'); + + if (is_string($raw) && $raw !== '') { + return $this->createUploadedFileFromBase64($raw); + } + + return null; + } + + protected function createUploadedFileFromBase64(string $raw): ?UploadedFile + { + $payload = $raw; + + if (Str::startsWith($raw, 'data:')) { + $segments = explode(',', $raw, 2); + $payload = $segments[1] ?? ''; + } + + $decoded = base64_decode($payload, true); + + if ($decoded === false) { + return null; + } + + $tmpPath = tempnam(sys_get_temp_dir(), 'sparkbooth-'); + + if (! $tmpPath) { + return null; + } + + file_put_contents($tmpPath, $decoded); + + return new UploadedFile($tmpPath, 'upload.jpg', null, null, true); + } +} diff --git a/app/Http/Controllers/Api/Tenant/PhotoboothController.php b/app/Http/Controllers/Api/Tenant/PhotoboothController.php index f75293f..9fcd91c 100644 --- a/app/Http/Controllers/Api/Tenant/PhotoboothController.php +++ b/app/Http/Controllers/Api/Tenant/PhotoboothController.php @@ -26,7 +26,10 @@ class PhotoboothController extends Controller $this->assertEventBelongsToTenant($request, $event); $event->loadMissing('tenant'); - $updated = $this->provisioner->enable($event); + $mode = $this->resolveMode($request); + $updated = $mode === 'sparkbooth' + ? $this->provisioner->enableSparkbooth($event) + : $this->provisioner->enable($event); return response()->json([ 'message' => __('Photobooth-Zugang aktiviert.'), @@ -39,7 +42,10 @@ class PhotoboothController extends Controller $this->assertEventBelongsToTenant($request, $event); $event->loadMissing('tenant'); - $updated = $this->provisioner->rotate($event); + $mode = $this->resolveMode($request); + $updated = $mode === 'sparkbooth' + ? $this->provisioner->rotateSparkbooth($event) + : $this->provisioner->rotate($event); return response()->json([ 'message' => __('Zugangsdaten neu generiert.'), @@ -52,7 +58,10 @@ class PhotoboothController extends Controller $this->assertEventBelongsToTenant($request, $event); $event->loadMissing('tenant'); - $updated = $this->provisioner->disable($event); + $mode = $this->resolveMode($request); + $updated = $mode === 'sparkbooth' + ? $this->provisioner->disableSparkbooth($event) + : $this->provisioner->disable($event); return response()->json([ 'message' => __('Photobooth-Zugang deaktiviert.'), @@ -76,4 +85,11 @@ class PhotoboothController extends Controller abort(403, 'Event gehört nicht zu diesem Tenant.'); } } + + protected function resolveMode(Request $request): string + { + $mode = strtolower((string) $request->input('mode', $request->input('type', 'ftp'))); + + return in_array($mode, ['sparkbooth', 'ftp'], true) ? $mode : 'ftp'; + } } diff --git a/app/Http/Controllers/MarketingController.php b/app/Http/Controllers/MarketingController.php index ffe8d41..48d5add 100644 --- a/app/Http/Controllers/MarketingController.php +++ b/app/Http/Controllers/MarketingController.php @@ -11,6 +11,7 @@ use App\Models\PackagePurchase; use App\Models\TenantPackage; use App\Services\Checkout\CheckoutSessionService; use App\Services\Coupons\CouponService; +use App\Services\GiftVouchers\GiftVoucherCheckoutService; use App\Services\Paddle\PaddleCheckoutService; use App\Support\Concerns\PresentsPackages; use Illuminate\Http\Request; @@ -39,6 +40,7 @@ class MarketingController extends Controller private readonly CheckoutSessionService $checkoutSessions, private readonly PaddleCheckoutService $paddleCheckout, private readonly CouponService $coupons, + private readonly GiftVoucherCheckoutService $giftVouchers, ) {} public function index() @@ -124,6 +126,15 @@ class MarketingController extends Controller return Inertia::render('marketing/Kontakt'); } + public function giftVouchers() + { + $tiers = $this->giftVouchers->tiers(); + + return Inertia::render('marketing/GiftVoucher', [ + 'tiers' => $tiers, + ]); + } + /** * Handle package purchase flow. */ diff --git a/app/Http/Resources/Tenant/PhotoboothStatusResource.php b/app/Http/Resources/Tenant/PhotoboothStatusResource.php index 7805d08..719d373 100644 --- a/app/Http/Resources/Tenant/PhotoboothStatusResource.php +++ b/app/Http/Resources/Tenant/PhotoboothStatusResource.php @@ -20,22 +20,50 @@ class PhotoboothStatusResource extends JsonResource /** @var PhotoboothSetting $settings */ $settings = $payload['settings']; - $password = $event->getAttribute('plain_photobooth_password') ?? $event->photobooth_password; + $mode = $event->photobooth_mode ?? 'ftp'; + $isSparkbooth = $mode === 'sparkbooth'; + + $password = $isSparkbooth + ? $event->getAttribute('plain_sparkbooth_password') ?? $event->sparkbooth_password + : $event->getAttribute('plain_photobooth_password') ?? $event->photobooth_password; + + $activeUsername = $isSparkbooth ? $event->sparkbooth_username : $event->photobooth_username; + $activeStatus = $isSparkbooth ? $event->sparkbooth_status : $event->photobooth_status; + $activeExpires = $isSparkbooth ? $event->sparkbooth_expires_at : $event->photobooth_expires_at; + + $sparkMetrics = [ + 'last_upload_at' => optional($event->sparkbooth_last_upload_at)->toIso8601String(), + 'uploads_24h' => (int) ($event->sparkbooth_uploads_last_24h ?? 0), + 'uploads_total' => (int) ($event->sparkbooth_uploads_total ?? 0), + ]; return [ + 'mode' => $mode, 'enabled' => (bool) $event->photobooth_enabled, - 'status' => $event->photobooth_status, - 'username' => $event->photobooth_username, + 'status' => $activeStatus, + 'username' => $activeUsername, 'password' => $password, 'path' => $event->photobooth_path, - 'ftp_url' => $this->buildFtpUrl($event, $settings, $password), - 'expires_at' => optional($event->photobooth_expires_at)->toIso8601String(), + 'ftp_url' => $isSparkbooth ? null : $this->buildFtpUrl($event, $settings, $password), + 'upload_url' => $isSparkbooth ? route('api.v1.photobooth.sparkbooth.upload') : null, + 'expires_at' => optional($activeExpires)->toIso8601String(), 'rate_limit_per_minute' => (int) $settings->rate_limit_per_minute, 'ftp' => [ 'host' => config('photobooth.ftp.host'), 'port' => $settings->ftp_port, 'require_ftps' => (bool) $settings->require_ftps, ], + 'metrics' => $isSparkbooth ? $sparkMetrics : null, + 'sparkbooth' => [ + 'enabled' => $mode === 'sparkbooth' && $event->photobooth_enabled, + 'status' => $event->sparkbooth_status, + 'username' => $event->sparkbooth_username, + 'password' => $event->getAttribute('plain_sparkbooth_password') ?? $event->sparkbooth_password, + 'expires_at' => optional($event->sparkbooth_expires_at)->toIso8601String(), + 'upload_url' => route('api.v1.photobooth.sparkbooth.upload'), + 'response_format' => $event->photobooth_metadata['sparkbooth_response_format'] ?? config('photobooth.sparkbooth.response_format', 'json'), + 'metrics' => $sparkMetrics, + ], ]; } diff --git a/app/Models/Coupon.php b/app/Models/Coupon.php index 82ab153..c7442d5 100644 --- a/app/Models/Coupon.php +++ b/app/Models/Coupon.php @@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Str; @@ -97,6 +98,11 @@ class Coupon extends Model return $this->hasMany(CouponRedemption::class); } + public function giftVoucher(): HasOne + { + return $this->hasOne(GiftVoucher::class); + } + public function scopeActive($query) { return $query->where('status', CouponStatus::ACTIVE) diff --git a/app/Models/Event.php b/app/Models/Event.php index 999e0dc..4f2b242 100644 --- a/app/Models/Event.php +++ b/app/Models/Event.php @@ -25,12 +25,16 @@ class Event extends Model 'name' => 'array', 'description' => 'array', 'photobooth_enabled' => 'boolean', + 'photobooth_mode' => 'string', 'photobooth_expires_at' => 'datetime', 'photobooth_metadata' => 'array', + 'sparkbooth_expires_at' => 'datetime', + 'sparkbooth_last_upload_at' => 'datetime', ]; protected $hidden = [ 'photobooth_password_encrypted', + 'sparkbooth_password_encrypted', ]; protected static function booted(): void @@ -196,4 +200,26 @@ class Event extends Model ? Crypt::encryptString($value) : null; } + + public function getSparkboothPasswordAttribute(): ?string + { + $encrypted = $this->attributes['sparkbooth_password_encrypted'] ?? null; + + if (! $encrypted) { + return null; + } + + try { + return Crypt::decryptString($encrypted); + } catch (DecryptException) { + return null; + } + } + + public function setSparkboothPasswordAttribute(?string $value): void + { + $this->attributes['sparkbooth_password_encrypted'] = $value + ? Crypt::encryptString($value) + : null; + } } diff --git a/app/Models/GiftVoucher.php b/app/Models/GiftVoucher.php new file mode 100644 index 0000000..18d4c6b --- /dev/null +++ b/app/Models/GiftVoucher.php @@ -0,0 +1,95 @@ + */ + use HasFactory; + + use SoftDeletes; + + public const STATUS_ISSUED = 'issued'; + + public const STATUS_REDEEMED = 'redeemed'; + + public const STATUS_REFUNDED = 'refunded'; + + public const STATUS_EXPIRED = 'expired'; + + protected $fillable = [ + 'code', + 'amount', + 'currency', + 'status', + 'purchaser_email', + 'recipient_email', + 'recipient_name', + 'message', + 'paddle_transaction_id', + 'paddle_checkout_id', + 'paddle_price_id', + 'coupon_id', + 'expires_at', + 'redeemed_at', + 'refunded_at', + 'metadata', + ]; + + protected $casts = [ + 'amount' => 'decimal:2', + 'expires_at' => 'datetime', + 'redeemed_at' => 'datetime', + 'refunded_at' => 'datetime', + 'metadata' => 'array', + ]; + + protected static function booted(): void + { + static::saving(function (self $voucher): void { + if ($voucher->code) { + $voucher->code = Str::upper($voucher->code); + } + + if ($voucher->currency) { + $voucher->currency = Str::upper($voucher->currency); + } + }); + } + + public function coupon(): BelongsTo + { + return $this->belongsTo(Coupon::class); + } + + public function isRedeemed(): bool + { + return $this->status === self::STATUS_REDEEMED; + } + + public function isRefunded(): bool + { + return $this->status === self::STATUS_REFUNDED; + } + + public function isExpired(): bool + { + return $this->status === self::STATUS_EXPIRED || ($this->expires_at && $this->expires_at->isPast()); + } + + public function canBeRedeemed(): bool + { + return ! $this->isRedeemed() && ! $this->isRefunded() && ! $this->isExpired(); + } + + public function canBeRefunded(): bool + { + return ! $this->isRedeemed() && ! $this->isRefunded(); + } +} diff --git a/app/Models/Photo.php b/app/Models/Photo.php index eed2032..2004592 100644 --- a/app/Models/Photo.php +++ b/app/Models/Photo.php @@ -21,6 +21,8 @@ class Photo extends Model public const SOURCE_PHOTOBOOTH = 'photobooth'; + public const SOURCE_SPARKBOOTH = 'sparkbooth'; + public const SOURCE_UNKNOWN = 'unknown'; protected static ?array $columnCache = null; diff --git a/app/Services/Checkout/CheckoutWebhookService.php b/app/Services/Checkout/CheckoutWebhookService.php index 8b695a1..5c532bf 100644 --- a/app/Services/Checkout/CheckoutWebhookService.php +++ b/app/Services/Checkout/CheckoutWebhookService.php @@ -7,6 +7,7 @@ use App\Models\Package; use App\Models\Tenant; use App\Models\TenantPackage; use App\Services\Coupons\CouponRedemptionService; +use App\Services\GiftVouchers\GiftVoucherService; use App\Services\Paddle\PaddleSubscriptionService; use Carbon\Carbon; use Illuminate\Support\Arr; @@ -21,6 +22,7 @@ class CheckoutWebhookService private readonly CheckoutAssignmentService $assignment, private readonly PaddleSubscriptionService $paddleSubscriptions, private readonly CouponRedemptionService $couponRedemptions, + private readonly GiftVoucherService $giftVouchers, ) {} public function handleStripeEvent(array $event): bool @@ -93,6 +95,16 @@ class CheckoutWebhookService return $this->handlePaddleSubscriptionEvent($eventType, $data); } + if ($this->isGiftVoucherEvent($data)) { + if ($eventType === 'transaction.completed') { + $this->giftVouchers->issueFromPaddle($data); + + return true; + } + + return in_array($eventType, ['transaction.processing', 'transaction.created', 'transaction.failed', 'transaction.cancelled'], true); + } + $session = $this->locatePaddleSession($data); if (! $session) { @@ -429,6 +441,25 @@ class CheckoutWebhookService return null; } + protected function isGiftVoucherEvent(array $data): bool + { + $metadata = $data['metadata'] ?? []; + + $type = is_array($metadata) ? ($metadata['type'] ?? $metadata['kind'] ?? $metadata['category'] ?? null) : null; + + if ($type && in_array(strtolower($type), ['gift_card', 'gift_voucher'], true)) { + return true; + } + + $priceId = $data['price_id'] ?? Arr::get($metadata, 'paddle_price_id'); + $tiers = collect(config('gift-vouchers.tiers', [])) + ->pluck('paddle_price_id') + ->filter() + ->all(); + + return $priceId && in_array($priceId, $tiers, true); + } + protected function locatePaddleSession(array $data): ?CheckoutSession { $metadata = $data['metadata'] ?? []; diff --git a/app/Services/Coupons/CouponRedemptionService.php b/app/Services/Coupons/CouponRedemptionService.php index 170e9ad..7aa14bf 100644 --- a/app/Services/Coupons/CouponRedemptionService.php +++ b/app/Services/Coupons/CouponRedemptionService.php @@ -4,10 +4,13 @@ namespace App\Services\Coupons; use App\Models\CheckoutSession; use App\Models\CouponRedemption; +use App\Services\GiftVouchers\GiftVoucherService; use Illuminate\Support\Arr; class CouponRedemptionService { + public function __construct(private readonly GiftVoucherService $giftVouchers) {} + public function recordSuccess(CheckoutSession $session, array $payload = []): void { if (! $session->coupon_id) { @@ -41,6 +44,8 @@ class CouponRedemptionService ); $session->coupon?->increment('redemptions_count'); + + $this->giftVouchers->markRedeemed($session->coupon, $transactionId); } public function recordFailure(CheckoutSession $session, string $reason): void diff --git a/app/Services/GiftVouchers/GiftVoucherCheckoutService.php b/app/Services/GiftVouchers/GiftVoucherCheckoutService.php new file mode 100644 index 0000000..64e0007 --- /dev/null +++ b/app/Services/GiftVouchers/GiftVoucherCheckoutService.php @@ -0,0 +1,100 @@ + + */ + public function tiers(): array + { + return collect(config('gift-vouchers.tiers', [])) + ->map(function (array $tier): array { + $currency = Str::upper($tier['currency'] ?? 'EUR'); + $priceId = $tier['paddle_price_id'] ?? null; + + return [ + 'key' => $tier['key'], + 'label' => $tier['label'], + 'amount' => (float) $tier['amount'], + 'currency' => $currency, + 'paddle_price_id' => $priceId, + 'can_checkout' => ! empty($priceId), + ]; + }) + ->values() + ->all(); + } + + /** + * @param array{tier_key:string,purchaser_email:string,recipient_email?:string|null,recipient_name?:string|null,message?:string|null,success_url?:string|null,return_url?:string|null} $data + * @return array{checkout_url:?string,expires_at:?string,id:?string} + */ + public function create(array $data): array + { + $tier = $this->findTier($data['tier_key']); + + if (! $tier || empty($tier['paddle_price_id'])) { + throw ValidationException::withMessages([ + 'tier_key' => __('Gift voucher is not available right now.'), + ]); + } + + $payload = [ + 'items' => [ + [ + 'price_id' => $tier['paddle_price_id'], + 'quantity' => 1, + ], + ], + 'customer_email' => $data['purchaser_email'], + 'metadata' => array_filter([ + 'type' => 'gift_voucher', + 'tier_key' => $tier['key'], + 'purchaser_email' => $data['purchaser_email'], + 'recipient_email' => $data['recipient_email'] ?? null, + 'recipient_name' => $data['recipient_name'] ?? null, + 'message' => $data['message'] ?? null, + 'app_locale' => App::getLocale(), + ]), + 'success_url' => $data['success_url'] ?? route('marketing.success', ['locale' => App::getLocale(), 'type' => 'gift']), + 'cancel_url' => $data['return_url'] ?? route('packages', ['locale' => App::getLocale()]), + ]; + + $response = $this->client->post('/checkout/links', $payload); + + return [ + 'checkout_url' => Arr::get($response, 'data.url') ?? Arr::get($response, 'url'), + 'expires_at' => Arr::get($response, 'data.expires_at') ?? Arr::get($response, 'expires_at'), + 'id' => Arr::get($response, 'data.id') ?? Arr::get($response, 'id'), + ]; + } + + /** + * @return array|null + */ + protected function findTier(string $key): ?array + { + $tiers = collect(config('gift-vouchers.tiers', [])) + ->keyBy('key'); + + $tier = $tiers->get($key); + + if (! $tier) { + return null; + } + + $tier['currency'] = Str::upper($tier['currency'] ?? 'EUR'); + + return $tier; + } +} diff --git a/app/Services/GiftVouchers/GiftVoucherService.php b/app/Services/GiftVouchers/GiftVoucherService.php new file mode 100644 index 0000000..4c8d0ba --- /dev/null +++ b/app/Services/GiftVouchers/GiftVoucherService.php @@ -0,0 +1,215 @@ +resolvePriceId($payload); + $amount = $this->resolveAmount($payload); + $currency = Str::upper($this->resolveCurrency($payload)); + + $expiresAt = now()->addYears((int) config('gift-vouchers.default_valid_years', 5)); + + $voucher = GiftVoucher::query()->updateOrCreate( + [ + 'paddle_transaction_id' => $payload['id'] ?? null, + ], + [ + 'code' => $metadata['gift_code'] ?? $this->generateCode(), + 'amount' => $amount, + 'currency' => $currency, + 'status' => GiftVoucher::STATUS_ISSUED, + 'purchaser_email' => $metadata['purchaser_email'] ?? Arr::get($payload, 'customer.email'), + 'recipient_email' => $metadata['recipient_email'] ?? null, + 'recipient_name' => $metadata['recipient_name'] ?? null, + 'message' => $metadata['message'] ?? null, + 'paddle_checkout_id' => $payload['checkout_id'] ?? Arr::get($payload, 'details.checkout_id'), + 'paddle_price_id' => $priceId, + 'metadata' => $metadata, + 'expires_at' => $expiresAt, + 'refunded_at' => null, + 'redeemed_at' => null, + ] + ); + + if (! $voucher->coupon_id) { + $coupon = $this->createCouponForVoucher($voucher); + $voucher->forceFill(['coupon_id' => $coupon->id])->save(); + SyncCouponToPaddle::dispatch($coupon); + } + + return $voucher; + } + + public function markRedeemed(?Coupon $coupon, ?string $transactionId = null): void + { + if (! $coupon?->giftVoucher) { + return; + } + + $voucher = $coupon->giftVoucher; + + if ($voucher->isRedeemed()) { + return; + } + + $voucher->forceFill([ + 'status' => GiftVoucher::STATUS_REDEEMED, + 'redeemed_at' => now(), + 'metadata' => array_merge($voucher->metadata ?? [], array_filter([ + 'redeemed_transaction_id' => $transactionId, + ])), + ])->save(); + } + + /** + * @return array + */ + public function refund(GiftVoucher $voucher, ?string $reason = null): array + { + if (! $voucher->canBeRefunded()) { + throw ValidationException::withMessages([ + 'voucher' => __('Voucher cannot be refunded after redemption or refund.'), + ]); + } + + if (! $voucher->paddle_transaction_id) { + throw ValidationException::withMessages([ + 'voucher' => __('Missing Paddle transaction for refund.'), + ]); + } + + $response = $this->transactions->refund($voucher->paddle_transaction_id, array_filter([ + 'reason' => $reason, + ])); + + $voucher->forceFill([ + 'status' => GiftVoucher::STATUS_REFUNDED, + 'refunded_at' => now(), + ])->save(); + + if ($voucher->coupon) { + $voucher->coupon->forceFill([ + 'status' => CouponStatus::ARCHIVED, + 'enabled_for_checkout' => false, + ])->save(); + } + + return $response; + } + + protected function createCouponForVoucher(GiftVoucher $voucher): Coupon + { + $packages = $this->eligiblePackages(); + + $coupon = Coupon::create([ + 'name' => 'Gutschein '.$voucher->code, + 'code' => $voucher->code, + 'type' => CouponType::FLAT, + 'amount' => $voucher->amount, + 'currency' => $voucher->currency, + 'status' => CouponStatus::ACTIVE, + 'enabled_for_checkout' => true, + 'is_stackable' => false, + 'usage_limit' => 1, + 'per_customer_limit' => 1, + 'auto_apply' => false, + 'description' => 'Geschenkgutschein '.number_format((float) $voucher->amount, 2).' '.$voucher->currency.' für Endkunden-Pakete.', + 'starts_at' => now(), + 'ends_at' => $voucher->expires_at, + ]); + + if ($packages->isNotEmpty()) { + $coupon->packages()->sync($packages->pluck('id')); + } + + return $coupon; + } + + protected function eligiblePackages(): Collection + { + $types = (array) config('gift-vouchers.package_types', ['endcustomer']); + + return Package::query() + ->whereIn('type', $types) + ->whereNotNull('paddle_price_id') + ->get(['id']); + } + + protected function resolvePriceId(array $payload): ?string + { + $metadata = $payload['metadata'] ?? []; + + if (is_array($metadata) && ! empty($metadata['paddle_price_id'])) { + return $metadata['paddle_price_id']; + } + + $items = Arr::get($payload, 'items', Arr::get($payload, 'details.items', [])); + if (is_array($items) && isset($items[0]['price_id'])) { + return $items[0]['price_id']; + } + + return $payload['price_id'] ?? null; + } + + protected function resolveAmount(array $payload): float + { + $tiers = Collection::make(config('gift-vouchers.tiers', [])) + ->keyBy(fn ($tier) => $tier['paddle_price_id'] ?? null); + + $priceId = $this->resolvePriceId($payload); + if ($priceId && $tiers->has($priceId)) { + return (float) $tiers->get($priceId)['amount']; + } + + $amount = Arr::get($payload, 'totals.grand_total.amount') + ?? Arr::get($payload, 'totals.grand_total') + ?? Arr::get($payload, 'details.totals.grand_total.amount') + ?? Arr::get($payload, 'details.totals.grand_total') + ?? Arr::get($payload, 'amount'); + + if (is_numeric($amount)) { + $value = (float) $amount; + + return $value >= 100 ? round($value / 100, 2) : round($value, 2); + } + + Log::warning('[GiftVoucher] Unable to resolve amount, defaulting to 0', ['payload' => $payload]); + + return 0.0; + } + + protected function resolveCurrency(array $payload): string + { + return $payload['currency_code'] + ?? Arr::get($payload, 'details.totals.currency_code') + ?? Arr::get($payload, 'currency') + ?? 'EUR'; + } + + protected function generateCode(): string + { + return 'GIFT-'.Str::upper(Str::random(8)); + } +} diff --git a/app/Services/Paddle/PaddleCatalogService.php b/app/Services/Paddle/PaddleCatalogService.php index 756c7c2..63d3f4d 100644 --- a/app/Services/Paddle/PaddleCatalogService.php +++ b/app/Services/Paddle/PaddleCatalogService.php @@ -51,7 +51,7 @@ class PaddleCatalogService */ public function createPrice(Package $package, string $productId, array $overrides = []): array { - $payload = $this->buildPricePayload($package, $productId, $overrides); + $payload = $this->buildPricePayload($package, $productId, $overrides, includeProduct: true); return $this->extractEntity($this->client->post('/prices', $payload)); } @@ -61,7 +61,12 @@ class PaddleCatalogService */ public function updatePrice(string $priceId, Package $package, array $overrides = []): array { - $payload = $this->buildPricePayload($package, $overrides['product_id'] ?? $package->paddle_product_id, $overrides); + $payload = $this->buildPricePayload( + $package, + $overrides['product_id'] ?? $package->paddle_product_id, + $overrides, + includeProduct: false + ); return $this->extractEntity($this->client->patch("/prices/{$priceId}", $payload)); } @@ -85,19 +90,24 @@ class PaddleCatalogService /** * @return array */ - public function buildPricePayload(Package $package, string $productId, array $overrides = []): array + public function buildPricePayload(Package $package, string $productId, array $overrides = [], bool $includeProduct = true): array { $unitPrice = $overrides['unit_price'] ?? [ 'amount' => (string) $this->priceToMinorUnits($package->price), 'currency_code' => Str::upper((string) ($package->currency ?? 'EUR')), ]; - $payload = array_merge([ - 'product_id' => $productId, + $base = [ 'description' => $this->resolvePriceDescription($package, $overrides), 'unit_price' => $unitPrice, 'custom_data' => $this->buildCustomData($package, $overrides['custom_data'] ?? []), - ], Arr::except($overrides, ['unit_price', 'description', 'custom_data'])); + ]; + + if ($includeProduct) { + $base['product_id'] = $productId; + } + + $payload = array_merge($base, Arr::except($overrides, ['unit_price', 'description', 'custom_data', 'product_id'])); return $this->cleanPayload($payload); } diff --git a/app/Services/Paddle/PaddleGiftVoucherCatalogService.php b/app/Services/Paddle/PaddleGiftVoucherCatalogService.php new file mode 100644 index 0000000..a7d9976 --- /dev/null +++ b/app/Services/Paddle/PaddleGiftVoucherCatalogService.php @@ -0,0 +1,92 @@ +createProduct($tier)['id']; + } + + if (! $price) { + $price = $this->createPrice($tier, $product)['id']; + } + + return [ + 'product_id' => $product, + 'price_id' => $price, + ]; + } + + /** + * @param array{key:string,label:string,amount:float,currency?:string} $tier + * @return array + */ + public function createProduct(array $tier): array + { + $payload = [ + 'name' => $tier['label'], + 'description' => sprintf('Geschenkgutschein im Wert von %.2f %s für Fotospiel Pakete.', $tier['amount'], $this->currency($tier)), + 'type' => 'standard', + 'tax_category' => 'standard', + 'custom_data' => [ + 'kind' => 'gift_voucher', + 'tier_key' => $tier['key'], + 'amount' => $tier['amount'], + 'currency' => $this->currency($tier), + ], + ]; + + $response = $this->client->post('/products', $payload); + + return Arr::get($response, 'data', $response); + } + + /** + * @param array{key:string,label:string,amount:float,currency?:string} $tier + * @return array + */ + public function createPrice(array $tier, string $productId): array + { + $payload = [ + 'product_id' => $productId, + 'description' => sprintf('Geschenkgutschein %.2f %s', $tier['amount'], $this->currency($tier)), + 'unit_price' => [ + 'amount' => (string) $this->toMinorUnits($tier['amount']), + 'currency_code' => $this->currency($tier), + ], + 'custom_data' => [ + 'kind' => 'gift_voucher', + 'tier_key' => $tier['key'], + ], + ]; + + $response = $this->client->post('/prices', $payload); + + return Arr::get($response, 'data', $response); + } + + protected function currency(array $tier): string + { + return Str::upper($tier['currency'] ?? 'EUR'); + } + + protected function toMinorUnits(float $amount): int + { + return (int) round($amount * 100); + } +} diff --git a/app/Services/Photobooth/Exceptions/SparkboothUploadException.php b/app/Services/Photobooth/Exceptions/SparkboothUploadException.php new file mode 100644 index 0000000..52e509c --- /dev/null +++ b/app/Services/Photobooth/Exceptions/SparkboothUploadException.php @@ -0,0 +1,16 @@ +hasPathColumn = Schema::hasColumn('photos', 'path'); } - public function ingest(Event $event, ?int $maxFiles = null): array + public function ingest(Event $event, ?int $maxFiles = null, string $ingestSource = Photo::SOURCE_PHOTOBOOTH): array { $tenant = $event->tenant; @@ -90,7 +90,7 @@ class PhotoboothIngestService } try { - $result = $this->importFile($event, $eventPackage, $disk, $importDisk, $file); + $result = $this->importFile($event, $eventPackage, $disk, $importDisk, $file, $ingestSource); if ($result) { $processed++; Storage::disk($importDisk)->delete($file); @@ -116,6 +116,7 @@ class PhotoboothIngestService string $destinationDisk, string $importDisk, string $file, + string $ingestSource, ): bool { $stream = Storage::disk($importDisk)->readStream($file); @@ -191,8 +192,8 @@ class PhotoboothIngestService 'file_path' => $watermarkedPath, 'thumbnail_path' => $watermarkedThumb, 'status' => 'pending', - 'guest_name' => Photo::SOURCE_PHOTOBOOTH, - 'ingest_source' => Photo::SOURCE_PHOTOBOOTH, + 'guest_name' => $ingestSource, + 'ingest_source' => $ingestSource, 'ip_address' => null, ]; diff --git a/app/Services/Photobooth/PhotoboothProvisioner.php b/app/Services/Photobooth/PhotoboothProvisioner.php index 2f2b410..f156f15 100644 --- a/app/Services/Photobooth/PhotoboothProvisioner.php +++ b/app/Services/Photobooth/PhotoboothProvisioner.php @@ -124,6 +124,79 @@ class PhotoboothProvisioner }); } + public function enableSparkbooth(Event $event, ?PhotoboothSetting $settings = null): Event + { + $settings ??= PhotoboothSetting::current(); + $event->loadMissing('tenant'); + + return DB::transaction(function () use ($event, $settings) { + $username = $this->generateUniqueUsername($event, $settings); + $password = $this->credentialGenerator->generatePassword(); + $path = $this->buildPath($event); + $expiresAt = $this->resolveExpiry($event, $settings); + + $event->forceFill([ + 'photobooth_enabled' => true, + 'photobooth_mode' => 'sparkbooth', + 'sparkbooth_username' => $username, + 'sparkbooth_password' => $password, + 'sparkbooth_expires_at' => $expiresAt, + 'sparkbooth_status' => 'active', + 'photobooth_path' => $path, + 'sparkbooth_uploads_last_24h' => 0, + ])->save(); + + return tap($event->refresh(), function (Event $refreshed) use ($password) { + $refreshed->setAttribute('plain_sparkbooth_password', $password); + }); + }); + } + + public function rotateSparkbooth(Event $event, ?PhotoboothSetting $settings = null): Event + { + $settings ??= PhotoboothSetting::current(); + + if ($event->photobooth_mode !== 'sparkbooth' || ! $event->sparkbooth_username) { + return $this->enableSparkbooth($event, $settings); + } + + return DB::transaction(function () use ($event, $settings) { + $password = $this->credentialGenerator->generatePassword(); + $expiresAt = $this->resolveExpiry($event, $settings); + + $event->forceFill([ + 'sparkbooth_password' => $password, + 'sparkbooth_expires_at' => $expiresAt, + 'sparkbooth_status' => 'active', + 'photobooth_enabled' => true, + 'photobooth_mode' => 'sparkbooth', + ])->save(); + + return tap($event->refresh(), function (Event $refreshed) use ($password) { + $refreshed->setAttribute('plain_sparkbooth_password', $password); + }); + }); + } + + public function disableSparkbooth(Event $event): Event + { + return DB::transaction(function () use ($event) { + $event->forceFill([ + 'photobooth_enabled' => false, + 'photobooth_mode' => 'ftp', + 'sparkbooth_username' => null, + 'sparkbooth_password' => null, + 'sparkbooth_expires_at' => null, + 'sparkbooth_status' => 'inactive', + 'sparkbooth_last_upload_at' => null, + 'sparkbooth_uploads_last_24h' => 0, + 'sparkbooth_uploads_total' => 0, + ])->save(); + + return $event->refresh(); + }); + } + protected function resolveExpiry(Event $event, PhotoboothSetting $settings): CarbonInterface { $eventEnd = $event->date ? Carbon::parse($event->date) : now(); @@ -143,6 +216,7 @@ class PhotoboothProvisioner $exists = Event::query() ->where('photobooth_username', $username) + ->orWhere('sparkbooth_username', $username) ->whereKeyNot($event->getKey()) ->exists(); diff --git a/app/Services/Photobooth/SparkboothUploadService.php b/app/Services/Photobooth/SparkboothUploadService.php new file mode 100644 index 0000000..5327b30 --- /dev/null +++ b/app/Services/Photobooth/SparkboothUploadService.php @@ -0,0 +1,138 @@ +authenticate($username, $password); + + $this->enforceExpiry($event); + $this->enforceRateLimit($event); + $this->assertValidFile($media); + + $importDisk = config('photobooth.import.disk', 'photobooth'); + $basePath = ltrim((string) ($event->photobooth_path ?: $this->buildPath($event)), '/'); + $extension = strtolower($media->getClientOriginalExtension() ?: $media->extension() ?: 'jpg'); + $filename = Str::uuid().'.'.$extension; + $relativePath = "{$basePath}/{$filename}"; + + if (! $event->photobooth_path) { + $event->forceFill([ + 'photobooth_path' => $basePath, + ])->save(); + } + + Storage::disk($importDisk)->makeDirectory($basePath); + Storage::disk($importDisk)->putFileAs($basePath, $media, $filename); + + $summary = $this->ingestService->ingest($event->fresh(), 1, Photo::SOURCE_SPARKBOOTH); + + if (($summary['processed'] ?? 0) < 1) { + throw new SparkboothUploadException('ingest_failed', 'Upload failed, please retry.', 500); + } + + $event->forceFill([ + 'sparkbooth_last_upload_at' => now(), + 'sparkbooth_uploads_last_24h' => ($event->sparkbooth_uploads_last_24h ?? 0) + 1, + 'sparkbooth_uploads_total' => ($event->sparkbooth_uploads_total ?? 0) + 1, + ])->save(); + + return [ + 'event' => $event->fresh(), + 'processed' => $summary['processed'] ?? 0, + 'skipped' => $summary['skipped'] ?? 0, + ]; + } + + protected function authenticate(?string $username, ?string $password): Event + { + if (! $username || ! $password) { + throw new SparkboothUploadException('missing_credentials', 'Invalid credentials', 401); + } + + $normalizedUsername = strtolower(trim($username)); + + /** @var Event|null $event */ + $event = Event::query() + ->whereRaw('LOWER(sparkbooth_username) = ?', [$normalizedUsername]) + ->first(); + + if (! $event) { + throw new SparkboothUploadException('invalid_credentials', 'Invalid credentials', 401); + } + + if ($event->photobooth_mode !== 'sparkbooth' || ! $event->photobooth_enabled) { + throw new SparkboothUploadException('disabled', 'Upload not active for this event', 403); + } + + if (! hash_equals($event->sparkbooth_password ?? '', $password ?? '')) { + throw new SparkboothUploadException('invalid_credentials', 'Invalid credentials', 401); + } + + return $event; + } + + protected function enforceExpiry(Event $event): void + { + if ($event->sparkbooth_expires_at && $event->sparkbooth_expires_at->isPast()) { + throw new SparkboothUploadException('expired', 'Upload access has expired', 403); + } + } + + protected function enforceRateLimit(Event $event): void + { + $limit = (int) (config('photobooth.sparkbooth.rate_limit_per_minute') ?? 0); + if ($limit <= 0) { + return; + } + + $key = sprintf('sparkbooth:event:%d', $event->id); + + if (RateLimiter::tooManyAttempts($key, $limit)) { + throw new SparkboothUploadException('rate_limited', 'Upload limit reached; try again in a moment.', 429); + } + + RateLimiter::hit($key, 60); + } + + protected function assertValidFile(UploadedFile $file): void + { + $allowed = config('photobooth.sparkbooth.allowed_extensions', ['jpg', 'jpeg', 'png', 'webp']); + $extension = strtolower($file->getClientOriginalExtension() ?: $file->extension() ?: ''); + + if (! $extension || ! in_array($extension, $allowed, true)) { + throw new SparkboothUploadException('invalid_file', 'Unsupported file type', 400); + } + + $maxSize = (int) config('photobooth.sparkbooth.max_size_kb', 8192) * 1024; + $size = $file->getSize() ?? 0; + + if ($maxSize > 0 && $size > $maxSize) { + throw new SparkboothUploadException('file_too_large', 'File too large', 400); + } + } + + protected function buildPath(Event $event): string + { + $tenantKey = $event->tenant?->slug ?? $event->tenant_id; + + return trim((string) $tenantKey, '/').'/'.$event->getKey(); + } +} diff --git a/config/gift-vouchers.php b/config/gift-vouchers.php new file mode 100644 index 0000000..6d4d70c --- /dev/null +++ b/config/gift-vouchers.php @@ -0,0 +1,40 @@ + 5, + + // Map voucher tiers to Paddle price IDs (create matching prices in Paddle Billing). + 'tiers' => [ + [ + 'key' => 'gift-starter', + 'label' => 'Geschenk Starter', + 'amount' => 29.00, + 'currency' => 'EUR', + 'paddle_price_id' => env('PADDLE_GIFT_PRICE_STARTER', 'pri_01kbwccfe1mpwh7hh60eygemx6'), + ], + [ + 'key' => 'gift-standard', + 'label' => 'Geschenk Standard', + 'amount' => 59.00, + 'currency' => 'EUR', + 'paddle_price_id' => env('PADDLE_GIFT_PRICE_STANDARD', 'pri_01kbwccfvzrf4z2f1r62vns7gh'), + ], + [ + 'key' => 'gift-premium', + 'label' => 'Geschenk Premium', + 'amount' => 129.00, + 'currency' => 'EUR', + 'paddle_price_id' => env('PADDLE_GIFT_PRICE_PREMIUM', 'pri_01kbwccg8vjc5cwz0kftfvf9wm'), + ], + [ + 'key' => 'gift-premium-plus', + 'label' => 'Geschenk Premium Plus', + 'amount' => 149.00, + 'currency' => 'EUR', + 'paddle_price_id' => env('PADDLE_GIFT_PRICE_PREMIUM_PLUS', 'pri_01kbwccgnjzwrjy5xg1yp981p6'), + ], + ], + + // Package types a voucher coupon should apply to. + 'package_types' => ['endcustomer'], +]; diff --git a/config/paddle.php b/config/paddle.php index 9102af6..cb9b88b 100644 --- a/config/paddle.php +++ b/config/paddle.php @@ -6,7 +6,9 @@ $environment = env('PADDLE_ENVIRONMENT', $sandbox ? 'sandbox' : 'production'); $apiKey = env('PADDLE_API_KEY') ?: ($sandbox ? env('PADDLE_SANDBOX_API_KEY') : null); -$clientToken = env('PADDLE_CLIENT_TOKEN') ?: env('PADDLE_CLIENT_ID') ?: ($sandbox ? (env('PADDLE_SANDBOX_CLIENT_TOKEN') ?: env('PADDLE_SANDBOX_CLIENT_ID')) : null); +$clientToken = $sandbox + ? (env('PADDLE_SANDBOX_CLIENT_TOKEN') ?: env('PADDLE_SANDBOX_CLIENT_ID') ?: env('PADDLE_CLIENT_TOKEN') ?: env('PADDLE_CLIENT_ID')) + : (env('PADDLE_CLIENT_TOKEN') ?: env('PADDLE_CLIENT_ID')); $webhookSecret = env('PADDLE_WEBHOOK_SECRET') ?: ($sandbox ? env('PADDLE_SANDBOX_WEBHOOK_SECRET') : null); diff --git a/config/photobooth.php b/config/photobooth.php index b294c68..f95ece7 100644 --- a/config/photobooth.php +++ b/config/photobooth.php @@ -25,4 +25,13 @@ return [ explode(',', env('PHOTOBOOTH_ALLOWED_EXTENSIONS', 'jpg,jpeg,png,webp')) ))), ], + 'sparkbooth' => [ + 'allowed_extensions' => array_values(array_filter(array_map( + fn ($ext) => strtolower(trim($ext)), + explode(',', env('SPARKBOOTH_ALLOWED_EXTENSIONS', env('PHOTOBOOTH_ALLOWED_EXTENSIONS', 'jpg,jpeg,png,webp'))) + ))), + 'max_size_kb' => (int) env('SPARKBOOTH_MAX_SIZE_KB', 8192), + 'rate_limit_per_minute' => (int) env('SPARKBOOTH_RATE_LIMIT_PER_MINUTE', env('PHOTOBOOTH_RATE_LIMIT_PER_MINUTE', 20)), + 'response_format' => env('SPARKBOOTH_RESPONSE_FORMAT', 'json'), + ], ]; diff --git a/database/factories/GiftVoucherFactory.php b/database/factories/GiftVoucherFactory.php new file mode 100644 index 0000000..719b2d2 --- /dev/null +++ b/database/factories/GiftVoucherFactory.php @@ -0,0 +1,31 @@ + + */ +class GiftVoucherFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'code' => strtoupper('GIFT-'.$this->faker->bothify('##??##??')), + 'amount' => $this->faker->randomElement([29, 59, 129, 149]), + 'currency' => 'EUR', + 'status' => \App\Models\GiftVoucher::STATUS_ISSUED, + 'purchaser_email' => $this->faker->safeEmail(), + 'recipient_email' => $this->faker->safeEmail(), + 'recipient_name' => $this->faker->name(), + 'message' => $this->faker->sentence(8), + 'expires_at' => now()->addYears(5), + ]; + } +} diff --git a/database/migrations/2025_12_07_130315_create_gift_vouchers_table.php b/database/migrations/2025_12_07_130315_create_gift_vouchers_table.php new file mode 100644 index 0000000..e9d707b --- /dev/null +++ b/database/migrations/2025_12_07_130315_create_gift_vouchers_table.php @@ -0,0 +1,53 @@ +id(); + $table->string('code', 64)->unique(); + + $table->decimal('amount', 10, 2); + $table->char('currency', 3)->default('EUR'); + $table->string('status', 32)->default('issued'); + + $table->string('purchaser_email')->nullable(); + $table->string('recipient_email')->nullable(); + $table->string('recipient_name')->nullable(); + $table->string('message', 500)->nullable(); + + $table->string('paddle_transaction_id')->nullable()->unique(); + $table->string('paddle_checkout_id')->nullable()->unique(); + $table->string('paddle_price_id')->nullable(); + + $table->foreignIdFor(\App\Models\Coupon::class)->nullable()->constrained()->nullOnDelete(); + + $table->timestamp('expires_at')->nullable(); + $table->timestamp('redeemed_at')->nullable(); + $table->timestamp('refunded_at')->nullable(); + + $table->json('metadata')->nullable(); + + $table->timestamps(); + $table->softDeletes(); + + $table->index(['status', 'expires_at']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('gift_vouchers'); + } +}; diff --git a/database/migrations/2025_12_07_164119_add_sparkbooth_columns_to_events_table.php b/database/migrations/2025_12_07_164119_add_sparkbooth_columns_to_events_table.php new file mode 100644 index 0000000..b1715a0 --- /dev/null +++ b/database/migrations/2025_12_07_164119_add_sparkbooth_columns_to_events_table.php @@ -0,0 +1,59 @@ +string('photobooth_mode', 16) + ->default('ftp') + ->after('photobooth_enabled'); + + $table->string('sparkbooth_username', 32) + ->nullable() + ->after('photobooth_path'); + $table->text('sparkbooth_password_encrypted') + ->nullable() + ->after('sparkbooth_username'); + $table->timestamp('sparkbooth_expires_at') + ->nullable() + ->after('sparkbooth_password_encrypted'); + $table->string('sparkbooth_status', 32) + ->default('inactive') + ->after('sparkbooth_expires_at'); + $table->timestamp('sparkbooth_last_upload_at') + ->nullable() + ->after('sparkbooth_status'); + $table->unsignedInteger('sparkbooth_uploads_last_24h') + ->default(0) + ->after('sparkbooth_last_upload_at'); + $table->unsignedBigInteger('sparkbooth_uploads_total') + ->default(0) + ->after('sparkbooth_uploads_last_24h'); + + $table->unique('sparkbooth_username'); + }); + } + + public function down(): void + { + Schema::table('events', function (Blueprint $table) { + $table->dropUnique(['sparkbooth_username']); + + $table->dropColumn([ + 'photobooth_mode', + 'sparkbooth_username', + 'sparkbooth_password_encrypted', + 'sparkbooth_expires_at', + 'sparkbooth_status', + 'sparkbooth_last_upload_at', + 'sparkbooth_uploads_last_24h', + 'sparkbooth_uploads_total', + ]); + }); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index be92a1e..9a709e8 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -16,6 +16,7 @@ class DatabaseSeeder extends Seeder MediaStorageTargetSeeder::class, LegalPagesSeeder::class, PackageSeeder::class, + GiftVoucherTierSeeder::class, CouponSeeder::class, PackageAddonSeeder::class, EventTypesSeeder::class, diff --git a/database/seeders/GiftVoucherTierSeeder.php b/database/seeders/GiftVoucherTierSeeder.php new file mode 100644 index 0000000..c25da7b --- /dev/null +++ b/database/seeders/GiftVoucherTierSeeder.php @@ -0,0 +1,70 @@ +command?->warn('Skipping gift voucher Paddle sync: paddle.api_key not configured.'); + + return; + } + + $tiers = $this->buildTiers(); + + foreach ($tiers as $tier) { + $result = $this->catalog->ensureTier($tier); + + $this->command?->info(sprintf( + '%s → product %s, price %s', + $tier['key'], + $result['product_id'], + $result['price_id'] + )); + } + } + + /** + * @return array + */ + protected function buildTiers(): array + { + $packages = Package::query() + ->where('type', 'endcustomer') + ->whereNotNull('price') + ->get(['slug', 'name', 'price', 'currency']) + ->unique(fn (Package $package) => $package->price.'|'.$package->currency); + + return $packages->map(function (Package $package): array { + $amount = (float) $package->price; + $currency = $package->currency ?? 'EUR'; + + return [ + 'key' => 'gift-'.$package->slug, + 'label' => 'Gutschein '.$package->name, + 'amount' => $amount, + 'currency' => $currency, + 'paddle_price_id' => $this->lookupPaddlePriceId($package->slug), + ]; + })->values()->all(); + } + + protected function lookupPaddlePriceId(string $slug): ?string + { + return match ($slug) { + 'starter' => 'pri_01kbwccfe1mpwh7hh60eygemx6', + 'standard' => 'pri_01kbwccfvzrf4z2f1r62vns7gh', + 'pro' => 'pri_01kbwccg8vjc5cwz0kftfvf9wm', + 'premium' => 'pri_01kbwccgnjzwrjy5xg1yp981p6', + default => null, + }; + } +} diff --git a/docs/ops/photobooth/README.md b/docs/ops/photobooth/README.md index a165559..ee3cd74 100644 --- a/docs/ops/photobooth/README.md +++ b/docs/ops/photobooth/README.md @@ -8,13 +8,15 @@ This guide explains how to operate the Photobooth FTP workflow end‑to‑end: p 2. **Control Service** (REST) provisions FTP accounts. Laravel calls it during enable/rotate/disable actions. 3. **Photobooth settings** (Filament SuperAdmin) define global port, rate limit, expiry grace, and Control Service connection. 4. **Ingest command** copies uploaded files into the event’s storage disk, generates thumbnails, records `photos.ingest_source = photobooth`, and respects package quotas. -5. **Guest PWA filter** consumes `/api/v1/events/{token}/photos?filter=photobooth` to render the “Fotobox” tab. +5. **Guest PWA filter** consumes `/api/v1/events/{token}/photos?filter=photobooth` to render the “Fotobox” tab. Sparkbooth uploads reuse this filter via `ingest_source = sparkbooth`. ``` Photobooth -> FTP (vsftpd) -> photobooth disk photobooth:ingest (queue/scheduler) -> Event media storage (public disk/S3) -> packages_usage, thumbnails, security scan + +Sparkbooth -> HTTP upload endpoint -> ingest (direct, no staging disk) ``` ## Environment Variables @@ -40,6 +42,12 @@ PHOTOBOOTH_IMPORT_DISK=photobooth PHOTOBOOTH_IMPORT_ROOT=/var/www/storage/app/photobooth PHOTOBOOTH_IMPORT_MAX_FILES=50 PHOTOBOOTH_ALLOWED_EXTENSIONS=jpg,jpeg,png,webp + +# Sparkbooth defaults (optional overrides) +SPARKBOOTH_ALLOWED_EXTENSIONS=jpg,jpeg,png,webp +SPARKBOOTH_MAX_SIZE_KB=8192 +SPARKBOOTH_RATE_LIMIT_PER_MINUTE=20 +SPARKBOOTH_RESPONSE_FORMAT=json ``` ### Filesystem Disk @@ -72,13 +80,49 @@ You can run the ingest job manually for a specific event: php artisan photobooth:ingest --event=123 --max-files=20 ``` +## Sparkbooth HTTP Uploads (Custom Upload) + +Use this when Sparkbooth runs in “Custom Upload” mode instead of FTP. + +- Endpoint: `POST /api/v1/photobooth/sparkbooth/upload` +- Auth: per-event username/password (set in Event Admin → Fotobox-Uploads; switch mode to “Sparkbooth”). +- Body (multipart/form-data): `media` (file or base64), `username`, `password`, optionally `name`, `email`, `message`. +- Response: + - JSON success: `{"status":true,"error":null,"url":null}` + - JSON failure: `{"status":false,"error":"Invalid credentials"}` + - XML (if `format=xml` or event preference is XML): + - Success: `` + - Failure: `` +- Limits: allowed extensions reuse photobooth defaults; max size `SPARKBOOTH_MAX_SIZE_KB` (default 8 MB); per-event rate limit `SPARKBOOTH_RATE_LIMIT_PER_MINUTE` (fallback to photobooth rate limit). +- Ingest: writes straight to the event’s hot storage, applies thumbnail/watermark/security scan, sets `photos.ingest_source = sparkbooth`. + +Example cURL (JSON response): + +```bash +curl -X POST https://app.example.com/api/v1/photobooth/sparkbooth/upload \ + -F "media=@/path/to/photo.jpg" \ + -F "username=PB123" \ + -F "password=SECRET" \ + -F "message=Wedding booth" +``` + +Example cURL (request XML response): + +```bash +curl -X POST https://app.example.com/api/v1/photobooth/sparkbooth/upload \ + -F "media=@/path/to/photo.jpg" \ + -F "username=PB123" \ + -F "password=SECRET" \ + -F "format=xml" +``` + ## Tenant Admin UX Inside the Event Admin PWA, go to **Event → Fotobox-Uploads** to: 1. Enable/disable the Photobooth link. 2. Rotate credentials (max 10-char usernames, 8-char passwords). -3. View rate limit + expiry info and copy the ftp:// link. +3. Switch mode (FTP or Sparkbooth), view rate limit + expiry info, copy ftp:// or POST URL + creds. ## Guest PWA Filter @@ -100,3 +144,4 @@ Response items contain `ingest_source`, allowing the frontend to toggle photoboo 5. **Seed default storage target** (e.g., `MediaStorageTarget::create([... 'key' => 'public', ...])`) in non-test environments if not present. 6. **Verify scheduler** (Horizon or cron) is running commands `photobooth:ingest` and `photobooth:cleanup-expired`. 7. **Test end-to-end**: enable Photobooth on a staging event, upload a file via FTP, wait for ingest, and confirm it appears under the Fotobox filter in the PWA. +8. **Test Sparkbooth**: switch event mode to Sparkbooth, copy Upload URL/user/pass, send a sample POST (or real Sparkbooth upload), verify it appears under the Fotobox filter. diff --git a/public/lang/de/marketing.json b/public/lang/de/marketing.json index 237c15c..64a87aa 100644 --- a/public/lang/de/marketing.json +++ b/public/lang/de/marketing.json @@ -222,10 +222,10 @@ "currency": { "euro": "€" }, - "view_details": "Details ansehen", "feature": "Feature", "paddle_not_configured": "Dieses Package ist noch nicht für den Paddle-Checkout konfiguriert. Bitte kontaktiere den Support.", - "paddle_checkout_failed": "Der Paddle-Checkout konnte nicht gestartet werden. Bitte versuche es später erneut." + "paddle_checkout_failed": "Der Paddle-Checkout konnte nicht gestartet werden. Bitte versuche es später erneut.", + "gift_cta": "Paket verschenken" }, "blog": { "title": "Fotospiel - Blog", @@ -373,21 +373,20 @@ }, "nav": { "home": "Startseite", - "how_it_works": "So funktioniert es", + "how_it_works": "So funktioniert's", "features": "Features", "occasions": "Anlässe", - "blog": "Blog", - "packages": "Pakete", - "contact": "Kontakt", - "discover_packages": "Pakete entdecken", - "privacy": "Datenschutz", - "impressum": "Impressum", "occasions_types": { "weddings": "Hochzeiten", "birthdays": "Geburtstage", "corporate": "Firmenevents", - "confirmation": "Konfirmation & Jugendweihe" + "confirmation": "Konfirmation & Jugendweihe", + "family": "Familienfeiern" }, + "blog": "Blog", + "packages": "Packages", + "contact": "Kontakt", + "discover_packages": "Packages entdecken", "language": "Sprache", "open_menu": "Menü öffnen", "close_menu": "Menü schließen", @@ -399,7 +398,8 @@ "dashboard": "Zum Admin-Bereich", "logout": "Abmelden", "login": "Anmelden", - "register": "Registrieren" + "register": "Registrieren", + "gift": "Gutscheine" }, "footer": { "company": "S.E.B. Fotografie", @@ -950,6 +950,38 @@ } ] }, + "gift": { + "title": "Geschenkgutscheine", + "badge": "Pakete verschenken", + "headline": "Schenke das passende Fotospiel-Paket", + "subline": "Wähle einen Wert passend zu unseren Paketen und überrasche Freund:innen, Familie oder Kund:innen. Digitale Zustellung mit persönlicher Nachricht.", + "validity": "5 Jahre gültig. Einlösbar auf alle Endkunden-Pakete.", + "withdrawal": { + "title": "Widerrufsrecht", + "body": "14 Tage Widerrufsrecht ab Kauf. Es erlischt, sobald der Gutschein (auch teilweise) eingelöst wurde. Nach 14 Tagen kein Widerruf mehr möglich.", + "link": "Widerrufsbelehrung anzeigen" + }, + "card_subline": "Einlösbar auf alle Endkunden-Pakete.", + "card_body": "Digitale Zustellung inklusive persönlicher Nachricht.", + "not_available": "Aktuell nicht verfügbar.", + "form_title": "Gutschein versenden", + "form_subtitle": "Wir senden den Gutschein nach erfolgreicher Zahlung per E-Mail.", + "purchaser_email": "Deine E-Mail", + "recipient_name": "Name der beschenkten Person (optional)", + "recipient_name_placeholder": "Alex Beispiel", + "recipient_email": "E-Mail der beschenkten Person (optional)", + "message": "Nachricht (optional)", + "message_placeholder": "Ein kleines Geschenk für euer Event!", + "accept_terms": "Ich habe die Widerrufsbelehrung gelesen: 14 Tage Widerruf ab Kauf, erlischt mit (Teil-)Einlösung.", + "accept_terms_required": "Bitte bestätige den Hinweis zum Widerruf.", + "cta": "Weiter mit Paddle", + "processing": "Paddle-Checkout wird geöffnet …", + "error_select_tier": "Bitte wähle einen Gutscheinbetrag.", + "error_purchaser_email": "Bitte gib eine gültige E-Mail ein.", + "error_recipient_email": "Bitte gib eine gültige Empfänger-E-Mail ein.", + "error_checkout": "Checkout konnte nicht gestartet werden. Bitte versuche es erneut.", + "error": "Etwas ist schiefgelaufen. Bitte versuche es erneut." + }, "not_found": { "title": "Seite nicht gefunden", "subtitle": "Ups! Diese Seite existiert nicht mehr.", @@ -970,4 +1002,4 @@ "privacy": "Datenschutz", "terms": "AGB" } -} +} \ No newline at end of file diff --git a/public/lang/en/marketing.json b/public/lang/en/marketing.json index aa4dda4..2905200 100644 --- a/public/lang/en/marketing.json +++ b/public/lang/en/marketing.json @@ -210,7 +210,8 @@ "euro": "€" }, "paddle_not_configured": "This package is not ready for Paddle checkout. Please contact support.", - "paddle_checkout_failed": "We could not start the Paddle checkout. Please try again later." + "paddle_checkout_failed": "We could not start the Paddle checkout. Please try again later.", + "gift_cta": "Gift a package" }, "blog": { "title": "Fotospiel - Blog", @@ -358,21 +359,20 @@ }, "nav": { "home": "Home", - "how_it_works": "How it Works", + "how_it_works": "How it works", "features": "Features", "occasions": "Occasions", - "blog": "Blog", - "packages": "Packages", - "contact": "Contact", - "discover_packages": "Discover Packages", - "privacy": "Privacy", - "impressum": "Imprint", "occasions_types": { "weddings": "Weddings", "birthdays": "Birthdays", "corporate": "Corporate Events", - "confirmation": "Confirmations" + "confirmation": "Confirmations", + "family": "Family Celebrations" }, + "blog": "Blog", + "packages": "Packages", + "contact": "Contact", + "discover_packages": "Discover Packages", "language": "Language", "open_menu": "Open menu", "close_menu": "Close menu", @@ -384,7 +384,8 @@ "dashboard": "Go to Admin", "logout": "Sign out", "login": "Log in", - "register": "Register" + "register": "Register", + "gift": "Gift cards" }, "header": { "home": "Home", @@ -942,6 +943,38 @@ } ] }, + "gift": { + "title": "Gift cards", + "badge": "Gift a package", + "headline": "Gift the perfect Fotospiel package", + "subline": "Choose a value that matches our packages and surprise friends, family, or clients. Digital delivery with your personal message.", + "validity": "Valid for 5 years. Usable on all end-customer packages.", + "withdrawal": { + "title": "Right of withdrawal", + "body": "14 days from purchase. It expires once the voucher is (even partially) redeemed. No withdrawal after 14 days.", + "link": "View withdrawal policy" + }, + "card_subline": "Redeemable on all end-customer packages.", + "card_body": "Instant digital delivery with personalized message.", + "not_available": "Currently not available for checkout.", + "form_title": "Send a gift card", + "form_subtitle": "We email the voucher after successful payment.", + "purchaser_email": "Your email", + "recipient_name": "Recipient name (optional)", + "recipient_name_placeholder": "Alex Example", + "recipient_email": "Recipient email (optional)", + "message": "Message (optional)", + "message_placeholder": "A little something for your event!", + "accept_terms": "I have read the withdrawal policy: 14 days from purchase, expires upon (partial) redemption.", + "accept_terms_required": "Please confirm the withdrawal note.", + "cta": "Continue with Paddle", + "processing": "Opening Paddle checkout …", + "error_select_tier": "Please select a voucher amount.", + "error_purchaser_email": "Please enter a valid email.", + "error_recipient_email": "Please enter a valid recipient email.", + "error_checkout": "Unable to start the checkout. Please try again.", + "error": "Something went wrong. Please try again." + }, "not_found": { "title": "Page not found", "subtitle": "Oops! This page is nowhere to be found.", @@ -962,4 +995,4 @@ "privacy": "Privacy", "terms": "Terms & Conditions" } -} +} \ No newline at end of file diff --git a/resources/js/admin/api.ts b/resources/js/admin/api.ts index d59cc11..44a8bb6 100644 --- a/resources/js/admin/api.ts +++ b/resources/js/admin/api.ts @@ -149,13 +149,26 @@ export type PhotoboothStatusMetrics = { last_upload_at?: string | null; }; +export type SparkboothStatus = { + enabled: boolean; + status: string | null; + username: string | null; + password: string | null; + expires_at: string | null; + upload_url: string | null; + response_format: 'json' | 'xml'; + metrics?: PhotoboothStatusMetrics | null; +}; + export type PhotoboothStatus = { + mode: 'ftp' | 'sparkbooth'; enabled: boolean; status: string | null; username: string | null; password: string | null; path: string | null; ftp_url: string | null; + upload_url: string | null; expires_at: string | null; rate_limit_per_minute: number; ftp: { @@ -163,6 +176,7 @@ export type PhotoboothStatus = { port: number; require_ftps: boolean; }; + sparkbooth?: SparkboothStatus | null; metrics?: PhotoboothStatusMetrics | null; }; @@ -1215,13 +1229,34 @@ function normalizePhotoboothStatus(payload: JsonValue): PhotoboothStatus { }; } + const sparkRaw = (payload.sparkbooth ?? null) as JsonValue | null; + let sparkbooth: SparkboothStatus | null = null; + + if (sparkRaw && typeof sparkRaw === 'object') { + sparkbooth = { + enabled: Boolean((sparkRaw as JsonValue).enabled), + status: typeof (sparkRaw as JsonValue).status === 'string' ? (sparkRaw as JsonValue).status : null, + username: typeof (sparkRaw as JsonValue).username === 'string' ? (sparkRaw as JsonValue).username : null, + password: typeof (sparkRaw as JsonValue).password === 'string' ? (sparkRaw as JsonValue).password : null, + expires_at: typeof (sparkRaw as JsonValue).expires_at === 'string' ? (sparkRaw as JsonValue).expires_at : null, + upload_url: typeof (sparkRaw as JsonValue).upload_url === 'string' ? (sparkRaw as JsonValue).upload_url : null, + response_format: + (sparkRaw as JsonValue).response_format === 'xml' ? 'xml' : 'json', + metrics: normalizePhotoboothMetrics((sparkRaw as JsonValue).metrics), + }; + } + + const modeValue = typeof payload.mode === 'string' && payload.mode === 'sparkbooth' ? 'sparkbooth' : 'ftp'; + return { + mode: modeValue, enabled: Boolean(payload.enabled), status: typeof payload.status === 'string' ? payload.status : null, username: typeof payload.username === 'string' ? payload.username : null, password: typeof payload.password === 'string' ? payload.password : null, path: typeof payload.path === 'string' ? payload.path : null, ftp_url: typeof payload.ftp_url === 'string' ? payload.ftp_url : null, + upload_url: typeof payload.upload_url === 'string' ? payload.upload_url : null, expires_at: typeof payload.expires_at === 'string' ? payload.expires_at : null, rate_limit_per_minute: Number(payload.rate_limit_per_minute ?? ftp.rate_limit_per_minute ?? 0), ftp: { @@ -1229,10 +1264,34 @@ function normalizePhotoboothStatus(payload: JsonValue): PhotoboothStatus { port: Number(ftp.port ?? payload.ftp_port ?? 0) || 0, require_ftps: Boolean(ftp.require_ftps ?? payload.require_ftps), }, + sparkbooth, metrics, }; } +function normalizePhotoboothMetrics(raw: JsonValue | null | undefined): PhotoboothStatusMetrics | null { + if (!raw || typeof raw !== 'object') { + return null; + } + + const record = raw as Record; + const readNumber = (key: string): number | null => { + const value = record[key]; + if (value === null || value === undefined) { + return null; + } + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; + }; + + return { + uploads_last_hour: readNumber('uploads_last_hour') ?? readNumber('last_hour') ?? readNumber('hour'), + uploads_today: readNumber('uploads_today') ?? readNumber('today'), + uploads_total: readNumber('uploads_total') ?? readNumber('total'), + last_upload_at: typeof record.last_upload_at === 'string' ? record.last_upload_at : null, + }; +} + async function requestPhotoboothStatus(slug: string, path = '', init: RequestInit = {}, errorMessage = 'Failed to fetch photobooth status'): Promise { const response = await authorizedFetch(`${photoboothEndpoint(slug)}${path}`, init); const payload = await jsonOrThrow(response, errorMessage); @@ -1648,16 +1707,40 @@ export async function getEventPhotoboothStatus(slug: string): Promise { - return requestPhotoboothStatus(slug, '/enable', { method: 'POST' }, 'Failed to enable photobooth access'); +export async function enableEventPhotobooth(slug: string, options?: { mode?: 'ftp' | 'sparkbooth' }): Promise { + const body = options?.mode ? JSON.stringify({ mode: options.mode }) : undefined; + const headers = body ? { 'Content-Type': 'application/json' } : undefined; + + return requestPhotoboothStatus( + slug, + '/enable', + { method: 'POST', body, headers }, + 'Failed to enable photobooth access' + ); } -export async function rotateEventPhotobooth(slug: string): Promise { - return requestPhotoboothStatus(slug, '/rotate', { method: 'POST' }, 'Failed to rotate credentials'); +export async function rotateEventPhotobooth(slug: string, options?: { mode?: 'ftp' | 'sparkbooth' }): Promise { + const body = options?.mode ? JSON.stringify({ mode: options.mode }) : undefined; + const headers = body ? { 'Content-Type': 'application/json' } : undefined; + + return requestPhotoboothStatus( + slug, + '/rotate', + { method: 'POST', body, headers }, + 'Failed to rotate credentials' + ); } -export async function disableEventPhotobooth(slug: string): Promise { - return requestPhotoboothStatus(slug, '/disable', { method: 'POST' }, 'Failed to disable photobooth access'); +export async function disableEventPhotobooth(slug: string, options?: { mode?: 'ftp' | 'sparkbooth' }): Promise { + const body = options?.mode ? JSON.stringify({ mode: options.mode }) : undefined; + const headers = body ? { 'Content-Type': 'application/json' } : undefined; + + return requestPhotoboothStatus( + slug, + '/disable', + { method: 'POST', body, headers }, + 'Failed to disable photobooth access' + ); } export async function submitTenantFeedback(payload: { diff --git a/resources/js/admin/i18n/locales/de/management.json b/resources/js/admin/i18n/locales/de/management.json index 9c033dd..e55addf 100644 --- a/resources/js/admin/i18n/locales/de/management.json +++ b/resources/js/admin/i18n/locales/de/management.json @@ -937,16 +937,26 @@ "inactive": "Noch keine Photobooth-Uploads angebunden.", "badgeActive": "AKTIV", "badgeInactive": "INAKTIV", - "expiresAt": "Automatisches Abschalten am {{date}}" + "expiresAt": "Automatisches Abschalten am {{date}}", + "mode": "Modus" + }, + "mode": { + "title": "Photobooth-Typ auswählen", + "description": "Wähle zwischen klassischem FTP und Sparkbooth HTTP-Upload. Umschalten generiert neue Zugangsdaten.", + "active": "Aktuell: {{mode}}" }, "credentials": { "heading": "FTP-Zugangsdaten", "description": "Teile die Zugangsdaten mit eurer Photobooth-Software.", + "sparkboothTitle": "Sparkbooth-Upload (HTTP)", + "sparkboothDescription": "Trage URL, Benutzername und Passwort in Sparkbooth ein. Antworten sind JSON (optional XML).", "host": "Host", "port": "Port", "username": "Benutzername", "password": "Passwort", - "path": "Upload-Pfad" + "path": "Upload-Pfad", + "postUrl": "Upload-URL", + "responseFormat": "Antwort-Format" }, "actions": { "enable": "Photobooth aktivieren", @@ -992,7 +1002,7 @@ "planTitle": "Planungsmodus", "planDescription": "Zugang bleibt deaktiviert, um Tests vorzubereiten.", "liveTitle": "Live-Modus", - "liveDescription": "FTP ist aktiv und Uploads werden direkt angenommen.", + "liveDescription": "Zugang bleibt aktiv (FTP oder Sparkbooth) und Uploads werden direkt verarbeitet.", "badgePlan": "Planung", "badgeLive": "Live", "current": "Aktiv", @@ -1598,4 +1608,4 @@ "ctaFallback": "Events ansehen" } } -} \ No newline at end of file +} diff --git a/resources/js/admin/i18n/locales/en/management.json b/resources/js/admin/i18n/locales/en/management.json index 0b1f098..fb00f6c 100644 --- a/resources/js/admin/i18n/locales/en/management.json +++ b/resources/js/admin/i18n/locales/en/management.json @@ -702,16 +702,26 @@ "inactive": "No photobooth uploads connected yet.", "badgeActive": "ACTIVE", "badgeInactive": "INACTIVE", - "expiresAt": "Will switch off automatically on {{date}}" + "expiresAt": "Will switch off automatically on {{date}}", + "mode": "Mode" + }, + "mode": { + "title": "Choose your photobooth type", + "description": "Pick classic FTP or Sparkbooth HTTP upload. Switching regenerates credentials.", + "active": "Current: {{mode}}" }, "credentials": { "heading": "FTP credentials", "description": "Share these credentials with your photobooth software.", + "sparkboothTitle": "Sparkbooth upload (HTTP)", + "sparkboothDescription": "Enter URL, username and password in Sparkbooth. Responses default to JSON (XML optional).", "host": "Host", "port": "Port", "username": "Username", "password": "Password", - "path": "Upload path" + "path": "Upload path", + "postUrl": "Upload URL", + "responseFormat": "Response format" }, "actions": { "enable": "Activate photobooth", @@ -757,7 +767,7 @@ "planTitle": "Planning mode", "planDescription": "Keep the FTP account disabled while preparing the booth.", "liveTitle": "Live mode", - "liveDescription": "FTP access stays enabled and uploads are processed instantly.", + "liveDescription": "Access stays enabled and uploads are processed instantly (FTP or Sparkbooth).", "badgePlan": "Planning", "badgeLive": "Live", "current": "Active", diff --git a/resources/js/admin/pages/EventPhotoboothPage.tsx b/resources/js/admin/pages/EventPhotoboothPage.tsx index 215d7b6..42ac87b 100644 --- a/resources/js/admin/pages/EventPhotoboothPage.tsx +++ b/resources/js/admin/pages/EventPhotoboothPage.tsx @@ -40,6 +40,8 @@ export default function EventPhotoboothPage() { const navigate = useNavigate(); const { t } = useTranslation(['management', 'common']); + const [mode, setMode] = React.useState<'ftp' | 'sparkbooth'>('ftp'); + const [state, setState] = React.useState({ event: null, status: null, @@ -96,17 +98,27 @@ export default function EventPhotoboothPage() { void load(); }, [load]); - async function handleEnable(): Promise { + React.useEffect(() => { + if (state.status?.mode) { + setMode(state.status.mode); + } + }, [state.status?.mode]); + + async function handleEnable(targetMode?: 'ftp' | 'sparkbooth'): Promise { if (!slug) return; setState((prev) => ({ ...prev, updating: true, error: null })); try { - const result = await enableEventPhotobooth(slug); + const selectedMode = targetMode ?? mode; + const result = await enableEventPhotobooth(slug, { mode: selectedMode }); setState((prev) => ({ ...prev, status: result, updating: false, })); + if (result.mode) { + setMode(result.mode); + } } catch (error) { if (!isAuthError(error)) { setState((prev) => ({ @@ -125,7 +137,7 @@ export default function EventPhotoboothPage() { setState((prev) => ({ ...prev, updating: true, error: null })); try { - const result = await rotateEventPhotobooth(slug); + const result = await rotateEventPhotobooth(slug, { mode }); setState((prev) => ({ ...prev, status: result, @@ -153,7 +165,7 @@ export default function EventPhotoboothPage() { setState((prev) => ({ ...prev, updating: true, error: null })); try { - const result = await disableEventPhotobooth(slug); + const result = await disableEventPhotobooth(slug, { mode }); setState((prev) => ({ ...prev, status: result, @@ -178,7 +190,7 @@ export default function EventPhotoboothPage() { : t('management.photobooth.title', 'Fotobox-Uploads'); const subtitle = t( 'management.photobooth.subtitle', - 'Erstelle einen einfachen FTP-Link für Photobooth-Software. Rate-Limit: 20 Fotos/Minute.' + 'Erstelle einen einfachen Photobooth-Link per FTP oder Sparkbooth-Upload. Rate-Limit: 20 Fotos/Minute.' ); const eventTabs = React.useMemo(() => { if (!event || !slug) { @@ -192,7 +204,10 @@ export default function EventPhotoboothPage() { }, [event, slug, t]); const recentPhotos = React.useMemo(() => toolkit?.photos?.recent ?? [], [toolkit?.photos?.recent]); - const photoboothRecent = React.useMemo(() => recentPhotos.filter((photo) => photo.ingest_source === 'photobooth'), [recentPhotos]); + const photoboothRecent = React.useMemo( + () => recentPhotos.filter((photo) => photo.ingest_source === 'photobooth' || photo.ingest_source === 'sparkbooth'), + [recentPhotos] + ); const effectiveRecentPhotos = React.useMemo( () => (photoboothRecent.length > 0 ? photoboothRecent : recentPhotos), [photoboothRecent, recentPhotos], @@ -260,6 +275,42 @@ export default function EventPhotoboothPage() { ) : (
+
+
+
+

+ {t('management.photobooth.mode.title', 'Photobooth-Typ auswählen')} +

+

+ {t( + 'management.photobooth.mode.description', + 'Wähle zwischen klassischem FTP und Sparkbooth HTTP-Upload. Umschalten generiert neue Zugangsdaten.' + )} +

+
+
+ + +
+
+

+ {t('management.photobooth.mode.active', 'Aktuell: {{mode}}', { + mode: mode === 'sparkbooth' ? 'Sparkbooth / HTTP' : 'FTP', + })} +

+
@@ -338,7 +389,7 @@ function ModePresetsCard({ status, updating, onEnable, onDisable, onRotate }: Mo { key: 'live' as const, title: t('photobooth.presets.liveTitle', 'Live-Modus'), - description: t('photobooth.presets.liveDescription', 'FTP ist aktiv und Uploads werden direkt entgegen genommen.'), + description: t('photobooth.presets.liveDescription', 'Uploads sind aktiv (FTP oder Sparkbooth) und werden direkt verarbeitet.'), badge: t('photobooth.presets.badgeLive', 'Live'), icon: , }, @@ -524,6 +575,7 @@ function StatusCard({ status }: { status: PhotoboothStatus | null }) { const isActive = Boolean(status?.enabled); const badgeColor = isActive ? 'bg-emerald-600 text-white' : 'bg-slate-300 text-slate-800'; const icon = isActive ? : ; + const modeLabel = status?.mode === 'sparkbooth' ? 'Sparkbooth / HTTP' : 'FTP'; return ( @@ -543,13 +595,18 @@ function StatusCard({ status }: { status: PhotoboothStatus | null }) {
- {status?.expires_at ? ( - - {t('photobooth.status.expiresAt', 'Automatisches Abschalten am {{date}}', { - date: new Date(status.expires_at).toLocaleString(), - })} - - ) : null} + +

+ {t('photobooth.status.mode', 'Modus')}: {modeLabel} +

+ {status?.expires_at ? ( +

+ {t('photobooth.status.expiresAt', 'Automatisches Abschalten am {{date}}', { + date: new Date(status.expires_at).toLocaleString(), + })} +

+ ) : null} +
); } @@ -565,27 +622,48 @@ type CredentialCardProps = { function CredentialsCard({ status, updating, onEnable, onRotate, onDisable }: CredentialCardProps) { const { t } = useTranslation('management'); const isActive = Boolean(status?.enabled); + const isSparkbooth = status?.mode === 'sparkbooth'; return ( - {t('photobooth.credentials.heading', 'FTP-Zugangsdaten')} + + {isSparkbooth ? t('photobooth.credentials.sparkboothTitle', 'Sparkbooth-Upload (HTTP)') : t('photobooth.credentials.heading', 'FTP-Zugangsdaten')} + - {t( - 'photobooth.credentials.description', - 'Teile die Zugangsdaten mit der Photobooth-Software. Passwörter werden max. 8 Zeichen lang generiert.' - )} + {isSparkbooth + ? t( + 'photobooth.credentials.sparkboothDescription', + 'Trage URL, Benutzername und Passwort in Sparkbooth ein. Antworten erfolgen als JSON (optional XML).' + ) + : t( + 'photobooth.credentials.description', + 'Teile die Zugangsdaten mit der Photobooth-Software. Passwörter werden max. 8 Zeichen lang generiert.' + )} -
- - - - - - -
+ {isSparkbooth ? ( +
+ + + + +
+ ) : ( +
+ + + + + + +
+ )}
{isActive ? ( diff --git a/resources/js/layouts/app/Footer.tsx b/resources/js/layouts/app/Footer.tsx index 28194da..42f6035 100644 --- a/resources/js/layouts/app/Footer.tsx +++ b/resources/js/layouts/app/Footer.tsx @@ -16,6 +16,7 @@ const Footer: React.FC = () => { agb: localizedPath('/agb'), widerruf: localizedPath('/widerrufsbelehrung'), kontakt: localizedPath('/kontakt'), + gift: localizedPath('/gutschein'), }), [localizedPath]); const currentYear = new Date().getFullYear(); @@ -36,6 +37,17 @@ const Footer: React.FC = () => {

+
+

+ {t('marketing:nav.gift', 'Paket verschenken (Gutschein)')} +

+ + {t('marketing:nav.gift', 'Paket verschenken (Gutschein)')} + +
@@ -63,11 +75,6 @@ const Footer: React.FC = () => { {t('legal:widerrufsbelehrung')} -
  • - - {t('marketing:nav.contact')} - -
  • +
  • diff --git a/resources/js/pages/marketing/GiftVoucher.tsx b/resources/js/pages/marketing/GiftVoucher.tsx new file mode 100644 index 0000000..45e3bcd --- /dev/null +++ b/resources/js/pages/marketing/GiftVoucher.tsx @@ -0,0 +1,259 @@ +import React from 'react'; +import MarketingLayout from '@/layouts/mainWebsite'; +import { useTranslation } from 'react-i18next'; +import { Gift } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; +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 { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes'; +import { cn } from '@/lib/utils'; + +function useGiftVoucherTiers(initial: GiftVoucherTier[] = []) { + const [tiers, setTiers] = React.useState(initial); + const [loading, setLoading] = React.useState(initial.length === 0); + const [error, setError] = React.useState(null); + + React.useEffect(() => { + if (initial.length > 0) { + setLoading(false); + return; + } + fetchGiftVoucherTiers() + .then(setTiers) + .catch((err) => setError(err?.message || 'Failed to load tiers')) + .finally(() => setLoading(false)); + }, [initial]); + + return { tiers, loading, error }; +} + +function GiftVoucherPage({ tiers: initialTiers = [] }: { tiers: GiftVoucherTier[] }) { + const { t } = useTranslation('marketing'); + const { locale } = useLocalizedRoutes(); + const { tiers, loading, error } = useGiftVoucherTiers(initialTiers); + const [submitting, setSubmitting] = React.useState(false); + const [serverError, setServerError] = React.useState(null); + const [form, setForm] = React.useState({ + tier_key: initialTiers.find((t) => t.can_checkout)?.key ?? '', + purchaser_email: '', + recipient_email: '', + recipient_name: '', + message: '', + accept_terms: false, + }); + const [errors, setErrors] = React.useState>({}); + + const selectedTierKey = form.tier_key; + + const updateField = (key: keyof typeof form, value: string | boolean) => { + setForm((prev) => ({ ...prev, [key]: value })); + }; + + const validate = (): boolean => { + const nextErrors: Record = {}; + + if (!form.tier_key) { + nextErrors.tier_key = t('gift.error_select_tier'); + } + + if (!form.purchaser_email || !form.purchaser_email.includes('@')) { + nextErrors.purchaser_email = t('gift.error_purchaser_email', 'Please enter a valid email.'); + } + + if (form.recipient_email && !form.recipient_email.includes('@')) { + nextErrors.recipient_email = t('gift.error_recipient_email', 'Please enter a valid email.'); + } + + if (!form.accept_terms) { + nextErrors.accept_terms = t('gift.accept_terms_required'); + } + + setErrors(nextErrors); + + return Object.keys(nextErrors).length === 0; + }; + + const onSubmit = async () => { + if (!validate()) { + return; + } + + setSubmitting(true); + setServerError(null); + try { + const successUrl = window.location.origin + `/${locale}/success?type=gift`; + const returnUrl = window.location.origin + `/${locale}/gift-card`; + const response = await createGiftVoucherCheckout({ + tier_key: form.tier_key, + purchaser_email: form.purchaser_email, + recipient_email: form.recipient_email || undefined, + recipient_name: form.recipient_name || undefined, + message: form.message || undefined, + success_url: successUrl, + return_url: returnUrl, + }); + + if (response.checkout_url) { + window.location.assign(response.checkout_url); + } else { + setServerError(t('gift.error_checkout')); + } + } catch (err: any) { + setServerError(err?.message || t('gift.error_checkout')); + } finally { + setSubmitting(false); + } + }; + + return ( + +
    +
    +
    +
    +
    +
    + + {t('gift.badge')} +
    +

    + {t('gift.headline')} +

    +

    {t('gift.subline')}

    +

    {t('gift.validity')}

    +
    +
    + + {error &&
    {error}
    } + +
    +
    + {loading ? ( +
    + {Array.from({ length: 4 }).map((_, idx) => ( +
    + ))} +
    + ) : ( +
    + {tiers.map((tier) => ( + tier.can_checkout && setValue('tier_key', tier.key, { shouldValidate: true })} + onClick={() => tier.can_checkout && updateField('tier_key', tier.key)} + > + + + {tier.label} + + {tier.amount.toLocaleString()} {tier.currency} + + + {t('gift.card_subline')} + + +

    {t('gift.card_body')}

    + {!tier.can_checkout &&

    {t('gift.not_available')}

    } +
    +
    + ))} +
    + )} + {errors.tier_key &&

    {errors.tier_key}

    } +
    +

    {t('gift.withdrawal.title')}

    +

    {t('gift.withdrawal.body')}

    + +
    +
    + + + + {t('gift.form_title')} + {t('gift.form_subtitle')} + + +
    + + updateField('purchaser_email', e.target.value)} + /> + {errors.purchaser_email &&

    {errors.purchaser_email}

    } +
    +
    + + updateField('recipient_name', e.target.value)} + /> +
    +
    + + updateField('recipient_email', e.target.value)} + /> + {errors.recipient_email &&

    {errors.recipient_email}

    } +
    +
    + +