geschenkgutscheine implementiert ("Paket verschenken"). Neuer Upload-Provider: Sparkbooth.
This commit is contained in:
109
app/Filament/Resources/GiftVoucherResource.php
Normal file
109
app/Filament/Resources/GiftVoucherResource.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\GiftVoucherResource\Pages;
|
||||
use App\Models\GiftVoucher;
|
||||
use App\Services\GiftVouchers\GiftVoucherService;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
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 UnitEnum;
|
||||
|
||||
class GiftVoucherResource extends Resource
|
||||
{
|
||||
protected static ?string $model = GiftVoucher::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-gift';
|
||||
|
||||
protected static ?int $navigationSort = 12;
|
||||
|
||||
public static function getNavigationGroup(): UnitEnum|string|null
|
||||
{
|
||||
return __('admin.nav.billing');
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->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('/'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\GiftVoucherResource\Pages;
|
||||
|
||||
use App\Filament\Resources\GiftVoucherResource;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListGiftVouchers extends ListRecords
|
||||
{
|
||||
protected static string $resource = GiftVoucherResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -2377,7 +2377,7 @@ class EventPublicController extends BaseController
|
||||
|
||||
// MyPhotos filter
|
||||
if ($filter === 'photobooth') {
|
||||
$query->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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Marketing;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\GiftVouchers\GiftVoucherCheckoutService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class GiftVoucherCheckoutController extends Controller
|
||||
{
|
||||
public function __construct(private readonly GiftVoucherCheckoutService $checkout) {}
|
||||
|
||||
public function tiers(): JsonResponse
|
||||
{
|
||||
return response()->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);
|
||||
}
|
||||
}
|
||||
139
app/Http/Controllers/Api/SparkboothUploadController.php
Normal file
139
app/Http/Controllers/Api/SparkboothUploadController.php
Normal file
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Event;
|
||||
use App\Services\Photobooth\Exceptions\SparkboothUploadException;
|
||||
use App\Services\Photobooth\SparkboothUploadService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class SparkboothUploadController extends Controller
|
||||
{
|
||||
public function __construct(private readonly SparkboothUploadService $service) {}
|
||||
|
||||
public function store(Request $request): Response
|
||||
{
|
||||
$media = $this->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('<?xml version="1.0" encoding="UTF-8"?>'."\n".'<rsp status="ok"%s />', $urlAttribute);
|
||||
}
|
||||
|
||||
protected function buildFailureXml(?string $message): string
|
||||
{
|
||||
$escaped = htmlspecialchars($message ?? 'Upload failed', ENT_QUOTES);
|
||||
|
||||
return sprintf(
|
||||
'<?xml version="1.0" encoding="UTF-8"?>'."\n".'<rsp status="fail"><err msg="%s" /></rsp>',
|
||||
$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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
95
app/Models/GiftVoucher.php
Normal file
95
app/Models/GiftVoucher.php
Normal file
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class GiftVoucher extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\GiftVoucherFactory> */
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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'] ?? [];
|
||||
|
||||
@@ -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
|
||||
|
||||
100
app/Services/GiftVouchers/GiftVoucherCheckoutService.php
Normal file
100
app/Services/GiftVouchers/GiftVoucherCheckoutService.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\GiftVouchers;
|
||||
|
||||
use App\Services\Paddle\PaddleClient;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class GiftVoucherCheckoutService
|
||||
{
|
||||
public function __construct(private readonly PaddleClient $client) {}
|
||||
|
||||
/**
|
||||
* @return array<int, array{key:string,label:string,amount:float,currency:string,paddle_price_id?:string|null,can_checkout:bool}>
|
||||
*/
|
||||
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<string, mixed>|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;
|
||||
}
|
||||
}
|
||||
215
app/Services/GiftVouchers/GiftVoucherService.php
Normal file
215
app/Services/GiftVouchers/GiftVoucherService.php
Normal file
@@ -0,0 +1,215 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\GiftVouchers;
|
||||
|
||||
use App\Enums\CouponStatus;
|
||||
use App\Enums\CouponType;
|
||||
use App\Jobs\SyncCouponToPaddle;
|
||||
use App\Models\Coupon;
|
||||
use App\Models\GiftVoucher;
|
||||
use App\Models\Package;
|
||||
use App\Services\Paddle\PaddleTransactionService;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class GiftVoucherService
|
||||
{
|
||||
public function __construct(private readonly PaddleTransactionService $transactions) {}
|
||||
|
||||
/**
|
||||
* Create a voucher from a Paddle transaction payload.
|
||||
*/
|
||||
public function issueFromPaddle(array $payload): GiftVoucher
|
||||
{
|
||||
$metadata = $payload['metadata'] ?? [];
|
||||
$priceId = $this->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<string, mixed>
|
||||
*/
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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<string, mixed>
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
92
app/Services/Paddle/PaddleGiftVoucherCatalogService.php
Normal file
92
app/Services/Paddle/PaddleGiftVoucherCatalogService.php
Normal file
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Paddle;
|
||||
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class PaddleGiftVoucherCatalogService
|
||||
{
|
||||
public function __construct(private readonly PaddleClient $client) {}
|
||||
|
||||
/**
|
||||
* @param array{key:string,label:string,amount:float,currency?:string,paddle_product_id?:string|null,paddle_price_id?:string|null} $tier
|
||||
* @return array{product_id:string,price_id:string}
|
||||
*/
|
||||
public function ensureTier(array $tier): array
|
||||
{
|
||||
$product = $tier['paddle_product_id'] ?? null;
|
||||
$price = $tier['paddle_price_id'] ?? null;
|
||||
|
||||
if (! $product) {
|
||||
$product = $this->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<string, mixed>
|
||||
*/
|
||||
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<string, mixed>
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Photobooth\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class SparkboothUploadException extends RuntimeException
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $reason,
|
||||
string $message,
|
||||
public readonly ?int $statusCode = null
|
||||
) {
|
||||
parent::__construct($message);
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,7 @@ class PhotoboothIngestService
|
||||
$this->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,
|
||||
];
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
138
app/Services/Photobooth/SparkboothUploadService.php
Normal file
138
app/Services/Photobooth/SparkboothUploadService.php
Normal file
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Photobooth;
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Models\Photo;
|
||||
use App\Services\Photobooth\Exceptions\SparkboothUploadException;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class SparkboothUploadService
|
||||
{
|
||||
public function __construct(private readonly PhotoboothIngestService $ingestService) {}
|
||||
|
||||
/**
|
||||
* @return array{event: Event, processed: int, skipped: int}
|
||||
*
|
||||
* @throws SparkboothUploadException
|
||||
*/
|
||||
public function handleUpload(UploadedFile $media, ?string $username, ?string $password): array
|
||||
{
|
||||
$event = $this->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();
|
||||
}
|
||||
}
|
||||
40
config/gift-vouchers.php
Normal file
40
config/gift-vouchers.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'default_valid_years' => 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'],
|
||||
];
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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'),
|
||||
],
|
||||
];
|
||||
|
||||
31
database/factories/GiftVoucherFactory.php
Normal file
31
database/factories/GiftVoucherFactory.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\GiftVoucher>
|
||||
*/
|
||||
class GiftVoucherFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?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::create('gift_vouchers', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('events', function (Blueprint $table) {
|
||||
$table->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',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -16,6 +16,7 @@ class DatabaseSeeder extends Seeder
|
||||
MediaStorageTargetSeeder::class,
|
||||
LegalPagesSeeder::class,
|
||||
PackageSeeder::class,
|
||||
GiftVoucherTierSeeder::class,
|
||||
CouponSeeder::class,
|
||||
PackageAddonSeeder::class,
|
||||
EventTypesSeeder::class,
|
||||
|
||||
70
database/seeders/GiftVoucherTierSeeder.php
Normal file
70
database/seeders/GiftVoucherTierSeeder.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Package;
|
||||
use App\Services\Paddle\PaddleGiftVoucherCatalogService;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class GiftVoucherTierSeeder extends Seeder
|
||||
{
|
||||
public function __construct(private readonly PaddleGiftVoucherCatalogService $catalog) {}
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
if (! config('paddle.api_key')) {
|
||||
$this->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<int, array{key:string,label:string,amount:float,currency?:string,paddle_product_id?:string|null,paddle_price_id?:string|null}>
|
||||
*/
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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: `<rsp status="ok" url="..."/>`
|
||||
- Failure: `<rsp status="fail"><err msg="Invalid credentials" /></rsp>`
|
||||
- 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.
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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<string, JsonValue>;
|
||||
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<PhotoboothStatus> {
|
||||
const response = await authorizedFetch(`${photoboothEndpoint(slug)}${path}`, init);
|
||||
const payload = await jsonOrThrow<JsonValue | { data: JsonValue }>(response, errorMessage);
|
||||
@@ -1648,16 +1707,40 @@ export async function getEventPhotoboothStatus(slug: string): Promise<Photobooth
|
||||
return requestPhotoboothStatus(slug, '', {}, 'Failed to load photobooth status');
|
||||
}
|
||||
|
||||
export async function enableEventPhotobooth(slug: string): Promise<PhotoboothStatus> {
|
||||
return requestPhotoboothStatus(slug, '/enable', { method: 'POST' }, 'Failed to enable photobooth access');
|
||||
export async function enableEventPhotobooth(slug: string, options?: { mode?: 'ftp' | 'sparkbooth' }): Promise<PhotoboothStatus> {
|
||||
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<PhotoboothStatus> {
|
||||
return requestPhotoboothStatus(slug, '/rotate', { method: 'POST' }, 'Failed to rotate credentials');
|
||||
export async function rotateEventPhotobooth(slug: string, options?: { mode?: 'ftp' | 'sparkbooth' }): Promise<PhotoboothStatus> {
|
||||
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<PhotoboothStatus> {
|
||||
return requestPhotoboothStatus(slug, '/disable', { method: 'POST' }, 'Failed to disable photobooth access');
|
||||
export async function disableEventPhotobooth(slug: string, options?: { mode?: 'ftp' | 'sparkbooth' }): Promise<PhotoboothStatus> {
|
||||
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: {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<State>({
|
||||
event: null,
|
||||
status: null,
|
||||
@@ -96,17 +98,27 @@ export default function EventPhotoboothPage() {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
async function handleEnable(): Promise<void> {
|
||||
React.useEffect(() => {
|
||||
if (state.status?.mode) {
|
||||
setMode(state.status.mode);
|
||||
}
|
||||
}, [state.status?.mode]);
|
||||
|
||||
async function handleEnable(targetMode?: 'ftp' | 'sparkbooth'): Promise<void> {
|
||||
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() {
|
||||
<PhotoboothSkeleton />
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-3xl border border-slate-200/80 bg-white/70 p-5 shadow-sm">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-900">
|
||||
{t('management.photobooth.mode.title', 'Photobooth-Typ auswählen')}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{t(
|
||||
'management.photobooth.mode.description',
|
||||
'Wähle zwischen klassischem FTP und Sparkbooth HTTP-Upload. Umschalten generiert neue Zugangsdaten.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant={mode === 'ftp' ? 'default' : 'outline'}
|
||||
onClick={() => handleEnable('ftp')}
|
||||
disabled={updating || mode === 'ftp'}
|
||||
>
|
||||
FTP (Classic)
|
||||
</Button>
|
||||
<Button
|
||||
variant={mode === 'sparkbooth' ? 'default' : 'outline'}
|
||||
onClick={() => handleEnable('sparkbooth')}
|
||||
disabled={updating || mode === 'sparkbooth'}
|
||||
>
|
||||
Sparkbooth (HTTP)
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-slate-500">
|
||||
{t('management.photobooth.mode.active', 'Aktuell: {{mode}}', {
|
||||
mode: mode === 'sparkbooth' ? 'Sparkbooth / HTTP' : 'FTP',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.05fr)_minmax(0,0.95fr)]">
|
||||
<StatusCard status={status} />
|
||||
<SetupChecklistCard status={status} />
|
||||
@@ -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: <PlugZap className="h-5 w-5 text-emerald-500" />,
|
||||
},
|
||||
@@ -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 ? <PlugZap className="h-5 w-5 text-emerald-500" /> : <Power className="h-5 w-5 text-slate-400" />;
|
||||
const modeLabel = status?.mode === 'sparkbooth' ? 'Sparkbooth / HTTP' : 'FTP';
|
||||
|
||||
return (
|
||||
<Card className="rounded-3xl border border-slate-200/80 shadow-sm">
|
||||
@@ -543,13 +595,18 @@ function StatusCard({ status }: { status: PhotoboothStatus | null }) {
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{status?.expires_at ? (
|
||||
<CardContent className="text-sm text-slate-600">
|
||||
{t('photobooth.status.expiresAt', 'Automatisches Abschalten am {{date}}', {
|
||||
date: new Date(status.expires_at).toLocaleString(),
|
||||
})}
|
||||
</CardContent>
|
||||
) : null}
|
||||
<CardContent className="space-y-1 text-sm text-slate-600">
|
||||
<p className="text-xs uppercase tracking-wide text-slate-500">
|
||||
{t('photobooth.status.mode', 'Modus')}: {modeLabel}
|
||||
</p>
|
||||
{status?.expires_at ? (
|
||||
<p>
|
||||
{t('photobooth.status.expiresAt', 'Automatisches Abschalten am {{date}}', {
|
||||
date: new Date(status.expires_at).toLocaleString(),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Card className="rounded-3xl border border-rose-100/80 shadow-lg shadow-rose-100/40">
|
||||
<CardHeader>
|
||||
<CardTitle>{t('photobooth.credentials.heading', 'FTP-Zugangsdaten')}</CardTitle>
|
||||
<CardTitle>
|
||||
{isSparkbooth ? t('photobooth.credentials.sparkboothTitle', 'Sparkbooth-Upload (HTTP)') : t('photobooth.credentials.heading', 'FTP-Zugangsdaten')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{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.'
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field label={t('photobooth.credentials.host', 'Host')} value={status?.ftp.host ?? '—'} />
|
||||
<Field label={t('photobooth.credentials.port', 'Port')} value={String(status?.ftp.port ?? 2121)} />
|
||||
<Field label={t('photobooth.credentials.username', 'Benutzername')} value={status?.username ?? '—'} copyable />
|
||||
<Field label={t('photobooth.credentials.password', 'Passwort')} value={status?.password ?? '—'} copyable sensitive />
|
||||
<Field label={t('photobooth.credentials.path', 'Upload-Pfad')} value={status?.path ?? '/photobooth/...'} copyable />
|
||||
<Field label="FTP-Link" value={status?.ftp_url ?? '—'} copyable className="md:col-span-2" />
|
||||
</div>
|
||||
{isSparkbooth ? (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field label={t('photobooth.credentials.username', 'Benutzername')} value={status?.username ?? '—'} copyable />
|
||||
<Field label={t('photobooth.credentials.password', 'Passwort')} value={status?.password ?? '—'} copyable sensitive />
|
||||
<Field label={t('photobooth.credentials.postUrl', 'Upload-URL')} value={status?.upload_url ?? '—'} copyable className="md:col-span-2" />
|
||||
<Field
|
||||
label={t('photobooth.credentials.responseFormat', 'Antwort-Format')}
|
||||
value={status?.sparkbooth?.response_format === 'xml' ? 'XML' : 'JSON'}
|
||||
className="md:col-span-2"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field label={t('photobooth.credentials.host', 'Host')} value={status?.ftp.host ?? '—'} />
|
||||
<Field label={t('photobooth.credentials.port', 'Port')} value={String(status?.ftp.port ?? 2121)} />
|
||||
<Field label={t('photobooth.credentials.username', 'Benutzername')} value={status?.username ?? '—'} copyable />
|
||||
<Field label={t('photobooth.credentials.password', 'Passwort')} value={status?.password ?? '—'} copyable sensitive />
|
||||
<Field label={t('photobooth.credentials.path', 'Upload-Pfad')} value={status?.path ?? '/photobooth/...'} copyable />
|
||||
<Field label="FTP-Link" value={status?.ftp_url ?? '—'} copyable className="md:col-span-2" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{isActive ? (
|
||||
|
||||
@@ -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 = () => {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<p className="text-sm font-semibold text-gray-900 dark:text-gray-50">
|
||||
{t('marketing:nav.gift', 'Paket verschenken (Gutschein)')}
|
||||
</p>
|
||||
<Link
|
||||
href={links.gift}
|
||||
className="text-sm text-pink-600 transition hover:text-pink-500 dark:text-pink-300 dark:hover:text-pink-200"
|
||||
>
|
||||
{t('marketing:nav.gift', 'Paket verschenken (Gutschein)')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -63,11 +75,6 @@ const Footer: React.FC = () => {
|
||||
{t('legal:widerrufsbelehrung')}
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={links.kontakt} className="transition-colors hover:text-pink-500 dark:hover:text-pink-300">
|
||||
{t('marketing:nav.contact')}
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
@@ -85,6 +92,11 @@ const Footer: React.FC = () => {
|
||||
{t('marketing:footer.social', 'Social')}
|
||||
</h3>
|
||||
<ul className="font-sans-marketing space-y-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
<li>
|
||||
<Link href={links.kontakt} className="transition-colors hover:text-pink-500 dark:hover:text-pink-300">
|
||||
{t('marketing:nav.contact')}
|
||||
</Link>
|
||||
</li>
|
||||
<li><a href="#" className="hover:text-pink-500 dark:hover:text-pink-300">Instagram</a></li>
|
||||
<li><a href="#" className="hover:text-pink-500 dark:hover:text-pink-300">Facebook</a></li>
|
||||
<li><a href="#" className="hover:text-pink-500 dark:hover:text-pink-300">YouTube</a></li>
|
||||
|
||||
63
resources/js/lib/giftVouchers.ts
Normal file
63
resources/js/lib/giftVouchers.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
export type GiftVoucherTier = {
|
||||
key: string;
|
||||
label: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
paddle_price_id?: string | null;
|
||||
can_checkout: boolean;
|
||||
};
|
||||
|
||||
export type GiftVoucherCheckoutRequest = {
|
||||
tier_key: string;
|
||||
purchaser_email: string;
|
||||
recipient_email?: string;
|
||||
recipient_name?: string;
|
||||
message?: string;
|
||||
success_url?: string;
|
||||
return_url?: string;
|
||||
};
|
||||
|
||||
export type GiftVoucherCheckoutResponse = {
|
||||
checkout_url: string | null;
|
||||
expires_at: string | null;
|
||||
id: string | null;
|
||||
};
|
||||
|
||||
export async function fetchGiftVoucherTiers(): Promise<GiftVoucherTier[]> {
|
||||
const response = await fetch('/api/v1/marketing/gift-vouchers/tiers', {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load gift voucher tiers');
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
|
||||
return (payload?.data ?? []) as GiftVoucherTier[];
|
||||
}
|
||||
|
||||
export async function createGiftVoucherCheckout(data: GiftVoucherCheckoutRequest): Promise<GiftVoucherCheckoutResponse> {
|
||||
const response = await fetch('/api/v1/marketing/gift-vouchers/checkout', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
(payload?.errors && typeof payload.errors === 'object' && Object.values(payload.errors)[0]?.[0]) ||
|
||||
payload?.message ||
|
||||
'Unable to start checkout';
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return payload as GiftVoucherCheckoutResponse;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
|
||||
import { useLocale } from '@/hooks/useLocale';
|
||||
import MarketingLayout from '@/layouts/mainWebsite';
|
||||
import { Head, Link } from '@inertiajs/react';
|
||||
import { CheckCircle2, Sparkles } from 'lucide-react';
|
||||
@@ -18,6 +19,7 @@ interface DemoPageProps {
|
||||
const DemoPage: React.FC<DemoPageProps> = ({ demoToken }) => {
|
||||
const { t } = useTranslation('marketing');
|
||||
const { localizedPath } = useLocalizedRoutes();
|
||||
const locale = useLocale();
|
||||
const embedUrl = demoToken ? `/e/${demoToken}` : '/e/demo?demo=1';
|
||||
|
||||
const demo = t('demo_page', { returnObjects: true }) as {
|
||||
@@ -50,6 +52,16 @@ const DemoPage: React.FC<DemoPageProps> = ({ demoToken }) => {
|
||||
<Button asChild size="lg" variant="ghost" className="text-pink-600 hover:text-pink-700 dark:text-pink-300">
|
||||
<Link href={localizedPath('/so-funktionierts')}>{demo.secondaryCta}</Link>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="border-pink-200 text-pink-600 hover:bg-pink-50 dark:border-pink-800 dark:text-pink-200"
|
||||
>
|
||||
<Link href={localizedPath(locale === 'en' ? '/gift-card' : '/gutschein')}>
|
||||
{t('packages.gift_cta', 'Paket verschenken')}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
|
||||
259
resources/js/pages/marketing/GiftVoucher.tsx
Normal file
259
resources/js/pages/marketing/GiftVoucher.tsx
Normal file
@@ -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<GiftVoucherTier[]>(initial);
|
||||
const [loading, setLoading] = React.useState(initial.length === 0);
|
||||
const [error, setError] = React.useState<string | null>(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<string | null>(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<Record<string, string | null>>({});
|
||||
|
||||
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<string, string | null> = {};
|
||||
|
||||
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 (
|
||||
<MarketingLayout title={t('gift.title')}>
|
||||
<section className="relative overflow-hidden bg-gradient-to-b from-background via-muted/40 to-background">
|
||||
<div className="absolute inset-0 opacity-10 blur-3xl bg-[radial-gradient(circle_at_20%_20%,#60a5fa,transparent_30%),radial-gradient(circle_at_80%_10%,#a855f7,transparent_25%),radial-gradient(circle_at_50%_80%,#22c55e,transparent_25%)]" />
|
||||
<div className="relative mx-auto flex max-w-5xl flex-col gap-10 px-4 py-16 sm:px-6 lg:px-8">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="space-y-3">
|
||||
<div className="inline-flex items-center gap-2 rounded-full bg-primary/10 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-primary">
|
||||
<Gift className="h-4 w-4" />
|
||||
{t('gift.badge')}
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold leading-tight text-foreground sm:text-5xl font-display">
|
||||
{t('gift.headline')}
|
||||
</h1>
|
||||
<p className="max-w-3xl text-lg text-muted-foreground">{t('gift.subline')}</p>
|
||||
<p className="text-sm text-muted-foreground">{t('gift.validity')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="rounded-md border border-destructive/50 bg-destructive/5 p-3 text-sm text-destructive">{error}</div>}
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<div className="lg:col-span-2">
|
||||
{loading ? (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{Array.from({ length: 4 }).map((_, idx) => (
|
||||
<div key={idx} className="h-32 animate-pulse rounded-xl bg-muted" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{tiers.map((tier) => (
|
||||
<Card
|
||||
key={tier.key}
|
||||
className={cn(
|
||||
'cursor-pointer transition hover:shadow-md',
|
||||
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>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span>{tier.label}</span>
|
||||
<span className="text-xl font-semibold">
|
||||
{tier.amount.toLocaleString()} {tier.currency}
|
||||
</span>
|
||||
</CardTitle>
|
||||
<CardDescription>{t('gift.card_subline')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm text-muted-foreground space-y-1">
|
||||
<p>{t('gift.card_body')}</p>
|
||||
{!tier.can_checkout && <p className="text-destructive">{t('gift.not_available')}</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{errors.tier_key && <p className="mt-2 text-sm text-destructive">{errors.tier_key}</p>}
|
||||
<div className="mt-6 rounded-xl border border-primary/20 bg-primary/5 px-5 py-4 text-sm text-foreground shadow-sm">
|
||||
<p className="font-semibold">{t('gift.withdrawal.title')}</p>
|
||||
<p className="text-muted-foreground">{t('gift.withdrawal.body')}</p>
|
||||
<Button asChild variant="link" className="px-0 text-primary">
|
||||
<a href={locale === 'en' ? '/en/withdrawal' : '/de/widerrufsbelehrung'}>
|
||||
{t('gift.withdrawal.link', 'Widerrufsbelehrung öffnen')}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="shadow-lg">
|
||||
<CardHeader>
|
||||
<CardTitle>{t('gift.form_title')}</CardTitle>
|
||||
<CardDescription>{t('gift.form_subtitle')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="purchaser_email">{t('gift.purchaser_email')}</Label>
|
||||
<Input
|
||||
id="purchaser_email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
value={form.purchaser_email}
|
||||
onChange={(e) => updateField('purchaser_email', e.target.value)}
|
||||
/>
|
||||
{errors.purchaser_email && <p className="text-sm text-destructive">{errors.purchaser_email}</p>}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="recipient_name">{t('gift.recipient_name')}</Label>
|
||||
<Input
|
||||
id="recipient_name"
|
||||
placeholder={t('gift.recipient_name_placeholder')}
|
||||
value={form.recipient_name}
|
||||
onChange={(e) => updateField('recipient_name', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="recipient_email">{t('gift.recipient_email')}</Label>
|
||||
<Input
|
||||
id="recipient_email"
|
||||
type="email"
|
||||
placeholder="friend@example.com"
|
||||
value={form.recipient_email}
|
||||
onChange={(e) => updateField('recipient_email', e.target.value)}
|
||||
/>
|
||||
{errors.recipient_email && <p className="text-sm text-destructive">{errors.recipient_email}</p>}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="message">{t('gift.message')}</Label>
|
||||
<Textarea
|
||||
id="message"
|
||||
rows={3}
|
||||
placeholder={t('gift.message_placeholder')}
|
||||
value={form.message}
|
||||
onChange={(e) => updateField('message', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="accept_terms"
|
||||
className="mt-1 h-4 w-4 rounded border-muted-foreground/50"
|
||||
checked={form.accept_terms}
|
||||
onChange={(e) => updateField('accept_terms', e.target.checked)}
|
||||
/>
|
||||
<Label htmlFor="accept_terms" className="text-sm leading-tight text-muted-foreground">
|
||||
{t('gift.accept_terms')}
|
||||
</Label>
|
||||
</div>
|
||||
{errors.accept_terms && <p className="text-sm text-destructive">{errors.accept_terms}</p>}
|
||||
{serverError && <p className="text-sm text-destructive">{serverError}</p>}
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button className="w-full" size="lg" disabled={submitting || loading} onClick={onSubmit}>
|
||||
{submitting ? t('gift.processing') : t('gift.cta')}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</MarketingLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default GiftVoucherPage;
|
||||
|
||||
GiftVoucherPage.layout = (page: React.ReactNode) => page;
|
||||
@@ -3,6 +3,7 @@ import { Head, Link } from '@inertiajs/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import MarketingLayout from '@/layouts/mainWebsite';
|
||||
import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
|
||||
import { useLocale } from '@/hooks/useLocale';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -40,6 +41,7 @@ const iconByUseCase: Record<string, React.ReactNode> = {
|
||||
const HowItWorks: React.FC = () => {
|
||||
const { t, ready } = useTranslation('marketing');
|
||||
const { localizedPath } = useLocalizedRoutes();
|
||||
const locale = useLocale();
|
||||
|
||||
if (!ready) {
|
||||
return (
|
||||
@@ -158,6 +160,16 @@ const HowItWorks: React.FC = () => {
|
||||
{hero.secondaryCta}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
variant="ghost"
|
||||
className="text-pink-600 hover:bg-pink-50 dark:text-pink-200 dark:hover:bg-pink-900/30"
|
||||
>
|
||||
<Link href={localizedPath(locale === 'en' ? '/gift-card' : '/gutschein')}>
|
||||
{t('packages.gift_cta', 'Paket verschenken')}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
|
||||
@@ -14,6 +14,7 @@ import MarketingLayout from '@/layouts/mainWebsite';
|
||||
import { useAnalytics } from '@/hooks/useAnalytics';
|
||||
import { useCtaExperiment } from '@/hooks/useCtaExperiment';
|
||||
import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
|
||||
import { useLocale } from '@/hooks/useLocale';
|
||||
import { ArrowRight, Check, Star } from 'lucide-react';
|
||||
|
||||
interface Package {
|
||||
@@ -251,6 +252,7 @@ const Packages: React.FC<PackagesProps> = ({ endcustomerPackages, resellerPackag
|
||||
const mobileEndcustomerRef = useRef<HTMLDivElement | null>(null);
|
||||
const mobileResellerRef = useRef<HTMLDivElement | null>(null);
|
||||
const { localizedPath } = useLocalizedRoutes();
|
||||
const locale = useLocale();
|
||||
const { t } = useTranslation('marketing');
|
||||
const { t: tCommon } = useTranslation('common');
|
||||
const {
|
||||
@@ -883,6 +885,16 @@ const PackageDetailGrid: React.FC<PackageDetailGridProps> = ({
|
||||
{t('packages.contact_us')}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
variant="ghost"
|
||||
className="rounded-full text-gray-900 hover:bg-white/60 dark:text-gray-100 dark:hover:bg-gray-800/70"
|
||||
>
|
||||
<Link href={localizedPath(locale === 'en' ? '/gift-card' : '/gutschein')}>
|
||||
{t('packages.gift_cta', 'Paket verschenken')}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
{t('packages.hero_secondary')}
|
||||
|
||||
@@ -2,67 +2,74 @@ import React from 'react';
|
||||
import { usePage, router } from '@inertiajs/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import MarketingLayout from '@/layouts/mainWebsite';
|
||||
import { Loader } from 'lucide-react';
|
||||
import { Loader, CheckCircle } from 'lucide-react';
|
||||
import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
|
||||
import { useLocale } from '@/hooks/useLocale';
|
||||
import { ADMIN_HOME_PATH } from '@/admin/constants';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
const Success: React.FC = () => {
|
||||
const { auth } = usePage<{ auth: { user?: { email_verified_at?: string | null } } }>().props;
|
||||
type SuccessProps = {
|
||||
type?: string;
|
||||
};
|
||||
|
||||
const GiftSuccess: React.FC = () => {
|
||||
const { t } = useTranslation('marketing');
|
||||
const { localizedPath } = useLocalizedRoutes();
|
||||
const locale = useLocale();
|
||||
|
||||
return (
|
||||
<MarketingLayout title={t('success.gift_title')}>
|
||||
<div className="min-h-screen bg-gradient-to-b from-background via-muted/30 to-background py-16">
|
||||
<div className="mx-auto max-w-3xl space-y-6 px-4 text-center sm:px-6">
|
||||
<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_bullets_title')}</h2>
|
||||
<ul className="mt-3 list-disc space-y-2 pl-5 text-muted-foreground">
|
||||
<li>{t('success.gift_bullet_email')}</li>
|
||||
<li>{t('success.gift_bullet_validity')}</li>
|
||||
<li>{t('success.gift_bullet_redeem')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<Button asChild size="lg">
|
||||
<a href={localizedPath('/', locale || undefined)}>{t('success.gift_cta_home')}</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MarketingLayout>
|
||||
);
|
||||
};
|
||||
|
||||
const AuthRedirectSuccess: React.FC<{ emailVerified?: boolean | null }> = ({ emailVerified }) => {
|
||||
const { t } = useTranslation('success');
|
||||
const { localizedPath } = useLocalizedRoutes();
|
||||
|
||||
if (auth.user && auth.user.email_verified_at) {
|
||||
if (emailVerified) {
|
||||
router.visit(ADMIN_HOME_PATH, { preserveState: false });
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-gray-50">
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<Loader className="animate-spin inline-block w-8 h-8 border border-2 border-blue-600 border-t-transparent rounded-full mx-auto mb-2" />
|
||||
<Loader className="mx-auto mb-2 inline-block h-8 w-8 animate-spin rounded-full border border-2 border-blue-600 border-t-transparent" />
|
||||
<p className="text-gray-600">{t('redirecting')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (auth.user && !auth.user.email_verified_at) {
|
||||
return (
|
||||
<MarketingLayout title={t('verify_email')}>
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full bg-white rounded-lg shadow-md p-8">
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">{t('verify_email')}</h2>
|
||||
<p className="text-gray-600 mb-6">{t('check_email')}</p>
|
||||
<form method="POST" action="/email/verification-notification">
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-md font-medium transition duration-300"
|
||||
>
|
||||
{t('resend_verification')}
|
||||
</button>
|
||||
</form>
|
||||
<p className="mt-4 text-sm text-gray-600">
|
||||
{t('already_registered')}{' '}
|
||||
<a href={localizedPath('/login')} className="text-blue-600 hover:text-blue-500">
|
||||
{t('login')}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MarketingLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MarketingLayout title={t('complete_purchase')}>
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full bg-white rounded-lg shadow-md p-8">
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="w-full max-w-md rounded-lg bg-white p-8 shadow-md">
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">{t('complete_purchase')}</h2>
|
||||
<p className="text-gray-600 mb-6">{t('login_to_continue')}</p>
|
||||
<h2 className="mb-4 text-2xl font-bold text-gray-900">{t('complete_purchase')}</h2>
|
||||
<p className="mb-6 text-gray-600">{t('login_to_continue')}</p>
|
||||
<a
|
||||
href={localizedPath('/login')}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-md font-medium transition duration-300 block mb-2"
|
||||
className="mb-2 block rounded-md bg-blue-600 px-6 py-2 font-medium text-white transition duration-300 hover:bg-blue-700"
|
||||
>
|
||||
{t('login')}
|
||||
</a>
|
||||
@@ -79,6 +86,18 @@ const Success: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const Success: React.FC<SuccessProps> = () => {
|
||||
const { auth } = usePage<{ auth: { user?: { email_verified_at?: string | null } } }>().props;
|
||||
const searchParams = typeof window !== 'undefined' ? new URLSearchParams(window.location.search) : new URLSearchParams();
|
||||
const type = searchParams.get('type');
|
||||
|
||||
if (type === 'gift') {
|
||||
return <GiftSuccess />;
|
||||
}
|
||||
|
||||
return <AuthRedirectSuccess emailVerified={auth.user?.email_verified_at} />;
|
||||
};
|
||||
|
||||
Success.layout = (page: React.ReactNode) => page;
|
||||
|
||||
export default Success;
|
||||
|
||||
@@ -95,6 +95,7 @@ return [
|
||||
'logout' => 'Abmelden',
|
||||
'login' => 'Anmelden',
|
||||
'register' => 'Registrieren',
|
||||
'gift' => 'Gutscheine',
|
||||
],
|
||||
'footer' => [
|
||||
'company' => 'S.E.B. Fotografie',
|
||||
@@ -232,6 +233,13 @@ return [
|
||||
'complete_purchase' => 'Kauf abschließen',
|
||||
'login_to_continue' => 'Melden Sie sich an, um fortzufahren.',
|
||||
'loading' => 'Laden...',
|
||||
'gift_title' => 'Gutschein gekauft',
|
||||
'gift_description' => 'Wir senden den Gutschein mit deiner Nachricht per E-Mail. Widerruf innerhalb von 14 Tagen ab Kauf, erlischt mit (Teil-)Einlösung.',
|
||||
'gift_bullets_title' => 'So geht es weiter',
|
||||
'gift_bullet_email' => 'Gutschein an Empfänger (und dich) per E-Mail mit Code.',
|
||||
'gift_bullet_validity' => '5 Jahre gültig für alle Endkunden-Pakete.',
|
||||
'gift_bullet_redeem' => 'Einlösung im Package-Checkout über den Code.',
|
||||
'gift_cta_home' => 'Zur Startseite',
|
||||
],
|
||||
'register' => [
|
||||
'free' => 'Kostenlos',
|
||||
|
||||
@@ -95,6 +95,7 @@ return [
|
||||
'logout' => 'Sign out',
|
||||
'login' => 'Log in',
|
||||
'register' => 'Register',
|
||||
'gift' => 'Gift cards',
|
||||
],
|
||||
'footer' => [
|
||||
'company' => 'S.E.B. Fotografie',
|
||||
@@ -232,6 +233,13 @@ return [
|
||||
'complete_purchase' => 'Complete Purchase',
|
||||
'login_to_continue' => 'Log in to continue.',
|
||||
'loading' => 'Loading...',
|
||||
'gift_title' => 'Gift card purchased',
|
||||
'gift_description' => 'We will email the voucher with your personal message. Withdrawal: 14 days from purchase, expires once (partially) redeemed.',
|
||||
'gift_bullets_title' => 'What happens next',
|
||||
'gift_bullet_email' => 'Voucher emailed to the recipient (and you) with the code.',
|
||||
'gift_bullet_validity' => '5-year validity on all end-customer packages.',
|
||||
'gift_bullet_redeem' => 'Redeem in the package checkout using the code.',
|
||||
'gift_cta_home' => 'Back to homepage',
|
||||
],
|
||||
'register' => [
|
||||
'free' => 'Free',
|
||||
|
||||
@@ -5,6 +5,7 @@ use App\Http\Controllers\Api\HelpController;
|
||||
use App\Http\Controllers\Api\LegalController;
|
||||
use App\Http\Controllers\Api\Marketing\CouponPreviewController;
|
||||
use App\Http\Controllers\Api\PackageController;
|
||||
use App\Http\Controllers\Api\SparkboothUploadController;
|
||||
use App\Http\Controllers\Api\Tenant\DashboardController;
|
||||
use App\Http\Controllers\Api\Tenant\EmotionController;
|
||||
use App\Http\Controllers\Api\Tenant\EventAddonCatalogController;
|
||||
@@ -39,6 +40,12 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
||||
Route::post('/coupons/preview', CouponPreviewController::class)
|
||||
->middleware('throttle:coupon-preview')
|
||||
->name('coupons.preview');
|
||||
Route::post('/gift-vouchers/checkout', [\App\Http\Controllers\Api\Marketing\GiftVoucherCheckoutController::class, 'store'])
|
||||
->middleware('throttle:60,1')
|
||||
->name('gift-vouchers.checkout');
|
||||
Route::get('/gift-vouchers/tiers', [\App\Http\Controllers\Api\Marketing\GiftVoucherCheckoutController::class, 'tiers'])
|
||||
->middleware('throttle:60,1')
|
||||
->name('gift-vouchers.tiers');
|
||||
});
|
||||
|
||||
Route::post('/webhooks/revenuecat', [RevenueCatWebhookController::class, 'handle'])
|
||||
@@ -107,6 +114,9 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
||||
->where('variant', 'thumbnail|full')
|
||||
->middleware('signed')
|
||||
->name('gallery.photos.asset');
|
||||
|
||||
Route::post('/photobooth/sparkbooth/upload', [SparkboothUploadController::class, 'store'])
|
||||
->name('photobooth.sparkbooth.upload');
|
||||
});
|
||||
|
||||
Route::middleware(['auth:sanctum', 'tenant.collaborator', 'tenant.isolation', 'throttle:tenant-api'])->prefix('tenant')->group(function () {
|
||||
|
||||
@@ -110,6 +110,8 @@ Route::prefix('{locale}')
|
||||
|
||||
Route::get('/demo', [MarketingController::class, 'demo'])->name('demo');
|
||||
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('/impressum', [LegalPageController::class, 'show'])
|
||||
->defaults('slug', 'impressum')
|
||||
|
||||
62
tests/Unit/GiftVoucherCheckoutServiceTest.php
Normal file
62
tests/Unit/GiftVoucherCheckoutServiceTest.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Services\GiftVouchers\GiftVoucherCheckoutService;
|
||||
use App\Services\Paddle\PaddleClient;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Mockery;
|
||||
use Tests\TestCase;
|
||||
|
||||
class GiftVoucherCheckoutServiceTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_it_lists_tiers_with_checkout_flag(): void
|
||||
{
|
||||
config()->set('gift-vouchers.tiers', [
|
||||
['key' => 'gift-a', 'label' => 'A', 'amount' => 10, 'currency' => 'EUR', 'paddle_price_id' => 'pri_a'],
|
||||
['key' => 'gift-b', 'label' => 'B', 'amount' => 20, 'currency' => 'EUR', 'paddle_price_id' => null],
|
||||
]);
|
||||
|
||||
$service = $this->app->make(GiftVoucherCheckoutService::class);
|
||||
|
||||
$tiers = $service->tiers();
|
||||
|
||||
$this->assertCount(2, $tiers);
|
||||
$this->assertTrue($tiers[0]['can_checkout']);
|
||||
$this->assertFalse($tiers[1]['can_checkout']);
|
||||
}
|
||||
|
||||
public function test_it_creates_checkout_link_with_metadata(): void
|
||||
{
|
||||
config()->set('gift-vouchers.tiers', [
|
||||
['key' => 'gift-a', 'label' => 'A', 'amount' => 10, 'currency' => 'EUR', 'paddle_price_id' => 'pri_a'],
|
||||
]);
|
||||
|
||||
$client = Mockery::mock(PaddleClient::class);
|
||||
$client->shouldReceive('post')
|
||||
->once()
|
||||
->with('/checkout/links', Mockery::on(function ($payload) {
|
||||
return $payload['items'][0]['price_id'] === 'pri_a'
|
||||
&& $payload['customer_email'] === 'buyer@example.com'
|
||||
&& $payload['metadata']['type'] === 'gift_voucher';
|
||||
}))
|
||||
->andReturn(['data' => ['url' => 'https://paddle.test/checkout/123', 'expires_at' => '2025-12-31T00:00:00Z', 'id' => 'chk_123']]);
|
||||
|
||||
$this->app->instance(PaddleClient::class, $client);
|
||||
|
||||
$service = $this->app->make(GiftVoucherCheckoutService::class);
|
||||
|
||||
$checkout = $service->create([
|
||||
'tier_key' => 'gift-a',
|
||||
'purchaser_email' => 'buyer@example.com',
|
||||
'recipient_email' => 'friend@example.com',
|
||||
'recipient_name' => 'Friend',
|
||||
'message' => 'Hi',
|
||||
]);
|
||||
|
||||
$this->assertSame('https://paddle.test/checkout/123', $checkout['checkout_url']);
|
||||
$this->assertSame('chk_123', $checkout['id']);
|
||||
}
|
||||
}
|
||||
83
tests/Unit/GiftVoucherServiceTest.php
Normal file
83
tests/Unit/GiftVoucherServiceTest.php
Normal file
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Enums\CouponType;
|
||||
use App\Jobs\SyncCouponToPaddle;
|
||||
use App\Models\Coupon;
|
||||
use App\Models\GiftVoucher;
|
||||
use App\Models\Package;
|
||||
use App\Services\GiftVouchers\GiftVoucherService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Tests\TestCase;
|
||||
|
||||
class GiftVoucherServiceTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_it_issues_voucher_and_coupon_from_paddle_payload(): void
|
||||
{
|
||||
$package = Package::factory()->create([
|
||||
'type' => 'endcustomer',
|
||||
'paddle_price_id' => 'pri_pkg_001',
|
||||
'price' => 59,
|
||||
]);
|
||||
|
||||
$payload = [
|
||||
'id' => 'txn_123',
|
||||
'event_type' => 'transaction.completed',
|
||||
'currency_code' => 'EUR',
|
||||
'totals' => [
|
||||
'grand_total' => [
|
||||
'amount' => 5900,
|
||||
],
|
||||
],
|
||||
'metadata' => [
|
||||
'type' => 'gift_card',
|
||||
'purchaser_email' => 'buyer@example.com',
|
||||
'recipient_email' => 'friend@example.com',
|
||||
'recipient_name' => 'Friend',
|
||||
'message' => 'Happy Day',
|
||||
],
|
||||
'checkout_id' => 'chk_abc',
|
||||
];
|
||||
|
||||
Bus::fake([SyncCouponToPaddle::class]);
|
||||
|
||||
$service = $this->app->make(GiftVoucherService::class);
|
||||
$voucher = $service->issueFromPaddle($payload);
|
||||
|
||||
$this->assertInstanceOf(GiftVoucher::class, $voucher);
|
||||
$this->assertSame(59.00, (float) $voucher->amount);
|
||||
$this->assertNotNull($voucher->coupon);
|
||||
$this->assertSame($voucher->code, $voucher->coupon->code);
|
||||
$this->assertTrue($voucher->expires_at->greaterThan(now()->addYears(4)));
|
||||
$this->assertTrue($voucher->coupon->packages()->whereKey($package->id)->exists());
|
||||
Bus::assertDispatched(SyncCouponToPaddle::class);
|
||||
}
|
||||
|
||||
public function test_redeeming_coupon_marks_voucher_redeemed(): void
|
||||
{
|
||||
$voucher = GiftVoucher::factory()->create([
|
||||
'status' => GiftVoucher::STATUS_ISSUED,
|
||||
'amount' => 29,
|
||||
]);
|
||||
|
||||
$coupon = Coupon::factory()->create([
|
||||
'code' => $voucher->code,
|
||||
'type' => CouponType::FLAT,
|
||||
'amount' => 29,
|
||||
'currency' => 'EUR',
|
||||
'paddle_discount_id' => null,
|
||||
]);
|
||||
|
||||
$voucher->coupon()->associate($coupon)->save();
|
||||
|
||||
$service = $this->app->make(GiftVoucherService::class);
|
||||
$service->markRedeemed($coupon, 'txn_999');
|
||||
|
||||
$this->assertSame(GiftVoucher::STATUS_REDEEMED, $voucher->refresh()->status);
|
||||
$this->assertNotNull($voucher->redeemed_at);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user