widerrufsbelehrung hinzugefügt und in den checkout mit eingebunden. refund ins backend eingebaut.

This commit is contained in:
Codex Agent
2025-12-07 11:57:05 +01:00
parent e092f72475
commit 1d3d49e05a
44 changed files with 1143 additions and 71 deletions

View File

@@ -55,6 +55,7 @@ MAIL_USERNAME=null
MAIL_PASSWORD=null MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com" MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}" MAIL_FROM_NAME="${APP_NAME}"
MAIL_OPS=info@fotospiel.app
AWS_ACCESS_KEY_ID= AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY= AWS_SECRET_ACCESS_KEY=

View File

@@ -23,7 +23,12 @@ use Filament\Tables\Filters\Filter;
use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Notification;
use App\Notifications\Ops\RefundProcessed;
use App\Notifications\Customer\RefundReceipt;
use App\Services\Paddle\PaddleTransactionService;
class PurchaseResource extends Resource class PurchaseResource extends Resource
{ {
@@ -125,6 +130,21 @@ class PurchaseResource extends Resource
TextColumn::make('provider_id') TextColumn::make('provider_id')
->copyable() ->copyable()
->toggleable(), ->toggleable(),
TextColumn::make('metadata.consents.legal_version')
->label('Legal Version')
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('metadata.consents.accepted_terms_at')
->label('Terms accepted')
->dateTime()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('metadata.consents.accepted_withdrawal_notice_at')
->label('Withdrawal notice')
->dateTime()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('metadata.consents.digital_content_waiver_at')
->label('Waiver (digital)')
->dateTime()
->toggleable(isToggledHiddenByDefault: true),
]) ])
->filters([ ->filters([
SelectFilter::make('type') SelectFilter::make('type')
@@ -162,10 +182,55 @@ class PurchaseResource extends Resource
->icon('heroicon-o-arrow-uturn-left') ->icon('heroicon-o-arrow-uturn-left')
->requiresConfirmation() ->requiresConfirmation()
->visible(fn (PackagePurchase $record): bool => ! $record->refunded) ->visible(fn (PackagePurchase $record): bool => ! $record->refunded)
->action(function (PackagePurchase $record) { ->form([
$record->update(['refunded' => true]); Textarea::make('reason')
// TODO: Call Stripe/Paddle API for actual refund ->label('Refund reason (optional)')
Log::info('Refund processed for purchase ID: '.$record->id); ->rows(2),
])
->action(function (PackagePurchase $record, array $data) {
$reason = $data['reason'] ?? null;
$refundSuccess = true;
$errorMessage = null;
if ($record->provider === 'paddle' && $record->provider_id) {
try {
/** @var PaddleTransactionService $paddle */
$paddle = App::make(PaddleTransactionService::class);
$paddle->refund($record->provider_id, ['reason' => $reason]);
} catch (\Throwable $exception) {
$refundSuccess = false;
$errorMessage = $exception->getMessage();
Log::warning('Paddle refund failed', [
'purchase_id' => $record->id,
'provider_id' => $record->provider_id,
'error' => $exception->getMessage(),
]);
}
}
$metadata = $record->metadata ?? [];
$metadata['refund_reason'] = $reason ?: ($metadata['refund_reason'] ?? null);
$record->update([
'refunded' => true,
'metadata' => $metadata,
]);
Log::info('Refund processed for purchase ID: '.$record->id, [
'provider' => $record->provider,
'provider_id' => $record->provider_id,
'reason' => $reason,
]);
$customerEmail = $record->tenant->contact_email ?? $record->tenant?->user?->email;
if ($customerEmail) {
Notification::route('mail', $customerEmail)->notify(new RefundReceipt($record, $reason));
}
$opsEmail = config('mail.ops_address');
if ($opsEmail) {
Notification::route('mail', $opsEmail)->notify(new RefundProcessed($record, $refundSuccess, $reason, $errorMessage));
}
}), }),
]) ])
->bulkActions([ ->bulkActions([

View File

@@ -38,18 +38,10 @@ class LegalController extends BaseController
public function show(Request $request, string $slug) public function show(Request $request, string $slug)
{ {
$locale = $request->query('lang', 'de'); $locale = $request->query('lang', 'de');
$slugs = $this->resolveSlugs($slug);
// Support common English aliases as fallbacks
$s = strtolower($slug);
$aliasMap = [
'imprint' => 'impressum',
'privacy' => 'datenschutz',
'terms' => 'agb',
];
$resolved = $aliasMap[$s] ?? $s;
$page = LegalPage::query() $page = LegalPage::query()
->where('slug', $resolved) ->whereIn('slug', $slugs)
->where('is_published', true) ->where('is_published', true)
->orderByDesc('version') ->orderByDesc('version')
->first(); ->first();
@@ -59,7 +51,7 @@ class LegalController extends BaseController
'Legal Page Not Found', 'Legal Page Not Found',
'The requested legal document does not exist.', 'The requested legal document does not exist.',
Response::HTTP_NOT_FOUND, Response::HTTP_NOT_FOUND,
['slug' => $resolved] ['slug' => $slugs[0]]
); );
} }
@@ -77,6 +69,28 @@ class LegalController extends BaseController
])->header('Cache-Control', 'no-store'); ])->header('Cache-Control', 'no-store');
} }
protected function resolveSlugs(string $slug): array
{
$s = strtolower($slug);
$aliasMap = [
'imprint' => 'impressum',
'privacy' => 'datenschutz',
'terms' => 'agb',
'withdrawal' => 'widerrufsbelehrung',
'cancellation' => 'widerrufsbelehrung',
'cancellation-policy' => 'widerrufsbelehrung',
'widerruf' => 'widerrufsbelehrung',
];
$canonical = $aliasMap[$s] ?? $s;
return array_values(array_unique(
$canonical === 'widerrufsbelehrung'
? ['widerrufsbelehrung', 'widerruf']
: [$canonical]
));
}
protected function convertMarkdownToHtml(string $markdown): string protected function convertMarkdownToHtml(string $markdown): string
{ {
return trim((string) $this->markdown->convert($markdown)); return trim((string) $this->markdown->convert($markdown));

View File

@@ -20,12 +20,12 @@ class LegalPageController extends Controller
{ {
public function show(Request $request, string $locale, ?string $slug = null): Response public function show(Request $request, string $locale, ?string $slug = null): Response
{ {
$resolvedSlug = $this->resolveSlug($slug); $slugCandidates = $this->resolveSlugs($slug);
$page = null; $page = null;
try { try {
$page = LegalPage::query() $page = LegalPage::query()
->where('slug', $resolvedSlug) ->whereIn('slug', $slugCandidates)
->where('is_published', true) ->where('is_published', true)
->orderByDesc('version') ->orderByDesc('version')
->first(); ->first();
@@ -37,7 +37,7 @@ class LegalPageController extends Controller
$locale = $request->route('locale', app()->getLocale()); $locale = $request->route('locale', app()->getLocale());
if (! $page) { if (! $page) {
$fallback = $this->loadFallbackDocument($resolvedSlug, $locale); $fallback = $this->loadFallbackDocument($slugCandidates, $locale);
if (! $fallback) { if (! $fallback) {
abort(404); abort(404);
@@ -50,7 +50,7 @@ class LegalPageController extends Controller
'effectiveFrom' => null, 'effectiveFrom' => null,
'effectiveFromLabel' => null, 'effectiveFromLabel' => null,
'versionLabel' => null, 'versionLabel' => null,
'slug' => $resolvedSlug, 'slug' => $slugCandidates[0],
]); ]);
} }
@@ -58,7 +58,8 @@ class LegalPageController extends Controller
?? $page->title[$page->locale_fallback] ?? $page->title[$page->locale_fallback]
?? $page->title['de'] ?? $page->title['de']
?? $page->title['en'] ?? $page->title['en']
?? Str::title($resolvedSlug); ?? Str::title($slugCandidates[0]);
$title = Str::ucfirst($title);
$bodyMarkdown = $page->body_markdown[$locale] $bodyMarkdown = $page->body_markdown[$locale]
?? $page->body_markdown[$page->locale_fallback] ?? $page->body_markdown[$page->locale_fallback]
@@ -76,11 +77,11 @@ class LegalPageController extends Controller
? __('legal.effective_from', ['date' => $effectiveFrom->translatedFormat('d. F Y')]) ? __('legal.effective_from', ['date' => $effectiveFrom->translatedFormat('d. F Y')])
: null, : null,
'versionLabel' => __('legal.version', ['version' => $page->version]), 'versionLabel' => __('legal.version', ['version' => $page->version]),
'slug' => $resolvedSlug, 'slug' => $slugCandidates[0],
]); ]);
} }
private function resolveSlug(?string $slug): string private function resolveSlugs(?string $slug): array
{ {
$slug = strtolower($slug ?? ''); $slug = strtolower($slug ?? '');
@@ -88,9 +89,20 @@ class LegalPageController extends Controller
'imprint' => 'impressum', 'imprint' => 'impressum',
'privacy' => 'datenschutz', 'privacy' => 'datenschutz',
'terms' => 'agb', 'terms' => 'agb',
'withdrawal' => 'widerrufsbelehrung',
'cancellation' => 'widerrufsbelehrung',
'cancellation-policy' => 'widerrufsbelehrung',
'widerruf' => 'widerrufsbelehrung',
]; ];
return $aliases[$slug] ?? $slug ?: 'impressum'; $canonical = $aliases[$slug] ?? $slug ?: 'impressum';
// Support both slugs for withdrawal in case DB uses a shorter slug
$fallbacks = $canonical === 'widerrufsbelehrung'
? ['widerrufsbelehrung', 'widerruf']
: [$canonical];
return array_values(array_unique($fallbacks));
} }
private function convertMarkdownToHtml(string $markdown): string private function convertMarkdownToHtml(string $markdown): string
@@ -111,7 +123,7 @@ class LegalPageController extends Controller
return trim((string) $converter->convert($markdown)); return trim((string) $converter->convert($markdown));
} }
private function loadFallbackDocument(string $slug, string $locale): ?array private function loadFallbackDocument(array $slugCandidates, string $locale): ?array
{ {
$candidates = array_unique([ $candidates = array_unique([
strtolower($locale), strtolower($locale),
@@ -120,20 +132,22 @@ class LegalPageController extends Controller
'en', 'en',
]); ]);
foreach ($candidates as $candidateLocale) { foreach ($slugCandidates as $slug) {
$path = base_path("docs/legal/{$slug}-{$candidateLocale}.md"); foreach ($candidates as $candidateLocale) {
$path = base_path("docs/legal/{$slug}-{$candidateLocale}.md");
if (! is_file($path)) { if (! is_file($path)) {
continue; continue;
}
$markdown = (string) file_get_contents($path);
$title = $this->extractTitleFromMarkdown($markdown) ?? Str::title($slug);
return [
'markdown' => $markdown,
'title' => $title,
];
} }
$markdown = (string) file_get_contents($path);
$title = $this->extractTitleFromMarkdown($markdown) ?? Str::title($slug);
return [
'markdown' => $markdown,
'title' => $title,
];
} }
return null; return null;

View File

@@ -132,6 +132,13 @@ class MarketingController extends Controller
Log::info('Buy packages called', ['auth' => Auth::check(), 'locale' => $locale, 'package_id' => $packageId]); Log::info('Buy packages called', ['auth' => Auth::check(), 'locale' => $locale, 'package_id' => $packageId]);
$package = Package::findOrFail($packageId); $package = Package::findOrFail($packageId);
$requiresWaiver = (bool) ($package->activates_immediately ?? true);
$request->validate([
'accepted_terms' => ['sometimes', 'boolean', 'accepted'],
'accepted_waiver' => ['sometimes', 'boolean'],
]);
$couponCode = $this->rememberCouponFromRequest($request, $package); $couponCode = $this->rememberCouponFromRequest($request, $package);
if (! Auth::check()) { if (! Auth::check()) {
@@ -194,6 +201,16 @@ class MarketingController extends Controller
$this->checkoutSessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE); $this->checkoutSessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE);
$now = now();
$session->forceFill([
'accepted_terms_at' => $request->boolean('accepted_terms') ? $now : null,
'accepted_privacy_at' => $request->boolean('accepted_terms') ? $now : null,
'accepted_withdrawal_notice_at' => $request->boolean('accepted_terms') ? $now : null,
'digital_content_waiver_at' => $requiresWaiver && $request->boolean('accepted_waiver') ? $now : null,
'legal_version' => $this->resolveLegalVersion(),
])->save();
$appliedDiscountId = null; $appliedDiscountId = null;
if ($couponCode) { if ($couponCode) {
@@ -219,6 +236,9 @@ class MarketingController extends Controller
'metadata' => [ 'metadata' => [
'checkout_session_id' => $session->id, 'checkout_session_id' => $session->id,
'coupon_code' => $couponCode, 'coupon_code' => $couponCode,
'legal_version' => $session->legal_version,
'accepted_terms' => (bool) $session->accepted_terms_at,
'accepted_waiver' => $requiresWaiver && (bool) $session->digital_content_waiver_at,
], ],
'discount_id' => $appliedDiscountId, 'discount_id' => $appliedDiscountId,
]); ]);
@@ -628,4 +648,9 @@ class MarketingController extends Controller
'excerpt_html' => $this->convertMarkdownToHtml($excerpt), 'excerpt_html' => $this->convertMarkdownToHtml($excerpt),
]; ];
} }
protected function resolveLegalVersion(): string
{
return config('app.legal_version', now()->toDateString());
}
} }

View File

@@ -29,6 +29,8 @@ class PaddleCheckoutController extends Controller
'return_url' => ['nullable', 'url'], 'return_url' => ['nullable', 'url'],
'inline' => ['sometimes', 'boolean'], 'inline' => ['sometimes', 'boolean'],
'coupon_code' => ['nullable', 'string', 'max:64'], 'coupon_code' => ['nullable', 'string', 'max:64'],
'accepted_terms' => ['required', 'boolean', 'accepted'],
'accepted_waiver' => ['sometimes', 'boolean'],
]); ]);
$user = Auth::user(); $user = Auth::user();
@@ -44,12 +46,30 @@ class PaddleCheckoutController extends Controller
throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Paddle price.']); throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Paddle price.']);
} }
$requiresWaiver = (bool) ($package->activates_immediately ?? true);
if ($requiresWaiver && ! $request->boolean('accepted_waiver')) {
throw ValidationException::withMessages([
'accepted_waiver' => 'Ein sofortiger Beginn der digitalen Dienstleistung erfordert Ihre ausdrückliche Zustimmung.',
]);
}
$session = $this->sessions->createOrResume($user, $package, [ $session = $this->sessions->createOrResume($user, $package, [
'tenant' => $tenant, 'tenant' => $tenant,
]); ]);
$this->sessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE); $this->sessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE);
$now = now();
$session->forceFill([
'accepted_terms_at' => $now,
'accepted_privacy_at' => $now,
'accepted_withdrawal_notice_at' => $now,
'digital_content_waiver_at' => $requiresWaiver ? $now : null,
'legal_version' => $this->resolveLegalVersion(),
])->save();
$couponCode = Str::upper(trim((string) ($data['coupon_code'] ?? ''))); $couponCode = Str::upper(trim((string) ($data['coupon_code'] ?? '')));
$discountId = null; $discountId = null;
@@ -80,6 +100,9 @@ class PaddleCheckoutController extends Controller
'tenant_id' => (string) $tenant->id, 'tenant_id' => (string) $tenant->id,
'package_id' => (string) $package->id, 'package_id' => (string) $package->id,
'checkout_session_id' => (string) $session->id, 'checkout_session_id' => (string) $session->id,
'legal_version' => $session->legal_version,
'accepted_terms' => '1',
'accepted_waiver' => $requiresWaiver && $request->boolean('accepted_waiver') ? '1' : '0',
], ],
'customer' => array_filter([ 'customer' => array_filter([
'email' => $user->email, 'email' => $user->email,
@@ -94,6 +117,9 @@ class PaddleCheckoutController extends Controller
'metadata' => [ 'metadata' => [
'checkout_session_id' => $session->id, 'checkout_session_id' => $session->id,
'coupon_code' => $couponCode ?: null, 'coupon_code' => $couponCode ?: null,
'legal_version' => $session->legal_version,
'accepted_terms' => true,
'accepted_waiver' => $requiresWaiver && $request->boolean('accepted_waiver'),
], ],
'discount_id' => $discountId, 'discount_id' => $discountId,
]); ]);
@@ -109,4 +135,9 @@ class PaddleCheckoutController extends Controller
return response()->json($checkout); return response()->json($checkout);
} }
protected function resolveLegalVersion(): string
{
return config('app.legal_version', now()->toDateString());
}
} }

View File

@@ -27,6 +27,9 @@ class EventAddonCheckoutRequest extends FormRequest
'quantity' => ['nullable', 'integer', 'min:1', 'max:50'], 'quantity' => ['nullable', 'integer', 'min:1', 'max:50'],
'success_url' => ['nullable', 'url'], 'success_url' => ['nullable', 'url'],
'cancel_url' => ['nullable', 'url'], 'cancel_url' => ['nullable', 'url'],
'accepted_terms' => ['required', 'boolean', 'accepted'],
// Add-ons werden sofort erbracht -> Widerruf erlischt nur mit ausdrücklicher Zustimmung
'accepted_waiver' => ['required', 'boolean', 'accepted'],
]; ];
} }
} }

View File

@@ -62,6 +62,10 @@ class CheckoutSession extends Model
'discount_breakdown' => 'array', 'discount_breakdown' => 'array',
'expires_at' => 'datetime', 'expires_at' => 'datetime',
'completed_at' => 'datetime', 'completed_at' => 'datetime',
'accepted_terms_at' => 'datetime',
'accepted_privacy_at' => 'datetime',
'accepted_withdrawal_notice_at' => 'datetime',
'digital_content_waiver_at' => 'datetime',
'amount_subtotal' => 'decimal:2', 'amount_subtotal' => 'decimal:2',
'amount_total' => 'decimal:2', 'amount_total' => 'decimal:2',
'amount_discount' => 'decimal:2', 'amount_discount' => 'decimal:2',

View File

@@ -57,6 +57,10 @@ class Package extends Model
'paddle_snapshot' => 'array', 'paddle_snapshot' => 'array',
]; ];
protected $appends = [
'activates_immediately',
];
protected function features(): Attribute protected function features(): Attribute
{ {
return Attribute::make( return Attribute::make(
@@ -140,4 +144,10 @@ class Package extends Model
'max_events_per_year' => $this->max_events_per_year, 'max_events_per_year' => $this->max_events_per_year,
]; ];
} }
public function getActivatesImmediatelyAttribute(): bool
{
// Default: Pakete werden nach Kauf sofort freigeschaltet (digitale Dienstleistung).
return true;
}
} }

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Notifications\Customer;
use App\Models\PackagePurchase;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class RefundReceipt extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
private readonly PackagePurchase $purchase,
private readonly ?string $reason = null,
) {}
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
$tenant = $this->purchase->tenant;
$package = $this->purchase->package;
$amount = number_format((float) $this->purchase->price, 2);
$mail = (new MailMessage)
->subject(__('emails.refund.subject', ['package' => $package?->name ?? __('emails.package_limits.package_fallback')]))
->greeting(__('emails.refund.greeting', ['name' => $tenant?->name ?? __('emails.package_limits.team_fallback')]))
->line(__('emails.refund.body', [
'amount' => $amount,
'currency' => '€',
'provider_id' => $this->purchase->provider_id ?? '—',
]));
if ($this->reason) {
$mail->line(__('emails.refund.reason', ['reason' => $this->reason]));
}
return $mail->line(__('emails.refund.footer'));
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Notifications\Ops;
use App\Models\EventPackageAddon;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class AddonPurchased extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(private readonly EventPackageAddon $addon) {}
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
$event = $this->addon->event;
$tenant = $event?->tenant;
$label = $this->addon->metadata['label'] ?? $this->addon->addon_key;
$amount = $this->addon->amount ? number_format((float) $this->addon->amount, 2) : null;
$currency = $this->addon->currency ?? 'EUR';
return (new MailMessage)
->subject(__('emails.ops.addon.subject', ['addon' => $label]))
->greeting(__('emails.ops.addon.greeting'))
->line(__('emails.ops.addon.tenant', ['tenant' => $tenant?->name ?? __('emails.tenant_feedback.unknown_tenant')]))
->line(__('emails.ops.addon.event', ['event' => $event?->name['de'] ?? $event?->name['en'] ?? $event?->name ?? __('emails.package_limits.event_fallback')]))
->line(__('emails.ops.addon.addon', ['addon' => $label, 'quantity' => $this->addon->quantity]))
->when($amount, fn ($mail) => $mail->line(__('emails.ops.addon.amount', ['amount' => $amount, 'currency' => $currency])))
->line(__('emails.ops.addon.provider', [
'checkout' => $this->addon->checkout_id ?? '—',
'transaction' => $this->addon->transaction_id ?? '—',
]))
->line(__('emails.ops.addon.footer'));
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Notifications\Ops;
use App\Models\PackagePurchase;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class PurchaseCreated extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(private readonly PackagePurchase $purchase) {}
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
$tenant = $this->purchase->tenant;
$package = $this->purchase->package;
$amount = number_format((float) $this->purchase->price, 2);
$consents = $this->purchase->metadata['consents'] ?? [];
return (new MailMessage)
->subject(__('emails.ops.purchase.subject', ['package' => $package?->name ?? __('emails.package_limits.package_fallback')]))
->greeting(__('emails.ops.purchase.greeting'))
->line(__('emails.ops.purchase.tenant', ['tenant' => $tenant?->name ?? __('emails.tenant_feedback.unknown_tenant')]))
->line(__('emails.ops.purchase.package', ['package' => $package?->name ?? __('emails.package_limits.package_fallback')]))
->line(__('emails.ops.purchase.amount', ['amount' => $amount, 'currency' => '€']))
->line(__('emails.ops.purchase.provider', ['provider' => $this->purchase->provider, 'id' => $this->purchase->provider_id ?? '—']))
->line(__('emails.ops.purchase.consents', [
'legal' => $consents['legal_version'] ?? 'n/a',
'terms' => $consents['accepted_terms_at'] ?? 'n/a',
'waiver' => $consents['digital_content_waiver_at'] ?? 'n/a',
]))
->line(__('emails.ops.purchase.footer'));
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Notifications\Ops;
use App\Models\PackagePurchase;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class RefundProcessed extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
private readonly PackagePurchase $purchase,
private readonly bool $success,
private readonly ?string $reason = null,
private readonly ?string $error = null,
) {}
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
$tenant = $this->purchase->tenant;
$package = $this->purchase->package;
$amount = number_format((float) $this->purchase->price, 2);
$mail = (new MailMessage)
->subject(__('emails.ops.refund.subject', ['package' => $package?->name ?? __('emails.package_limits.package_fallback')]))
->greeting(__('emails.ops.refund.greeting'))
->line(__('emails.ops.refund.tenant', ['tenant' => $tenant?->name ?? __('emails.tenant_feedback.unknown_tenant')]))
->line(__('emails.ops.refund.package', ['package' => $package?->name ?? __('emails.package_limits.package_fallback')]))
->line(__('emails.ops.refund.amount', ['amount' => $amount, 'currency' => '€']))
->line(__('emails.ops.refund.provider', ['provider' => $this->purchase->provider, 'id' => $this->purchase->provider_id ?? '—']))
->line($this->success ? __('emails.ops.refund.status_success') : __('emails.ops.refund.status_failed'));
if ($this->reason) {
$mail->line(__('emails.ops.refund.reason', ['reason' => $this->reason]));
}
if ($this->error) {
$mail->line(__('emails.ops.refund.error', ['error' => $this->error]));
}
return $mail->line(__('emails.ops.refund.footer'));
}
}

View File

@@ -26,6 +26,8 @@ class EventAddonCheckoutService
{ {
$addonKey = $payload['addon_key'] ?? null; $addonKey = $payload['addon_key'] ?? null;
$quantity = max(1, (int) ($payload['quantity'] ?? 1)); $quantity = max(1, (int) ($payload['quantity'] ?? 1));
$acceptedWaiver = (bool) ($payload['accepted_waiver'] ?? false);
$acceptedTerms = (bool) ($payload['accepted_terms'] ?? false);
if (! $addonKey || ! $this->catalog->find($addonKey)) { if (! $addonKey || ! $this->catalog->find($addonKey)) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
@@ -60,6 +62,9 @@ class EventAddonCheckoutService
'addon_key' => $addonKey, 'addon_key' => $addonKey,
'addon_intent' => $addonIntent, 'addon_intent' => $addonIntent,
'quantity' => $quantity, 'quantity' => $quantity,
'legal_version' => $this->resolveLegalVersion(),
'accepted_terms' => $acceptedTerms ? '1' : '0',
'accepted_waiver' => $acceptedWaiver ? '1' : '0',
]; ];
$requestPayload = array_filter([ $requestPayload = array_filter([
@@ -97,6 +102,11 @@ class EventAddonCheckoutService
'metadata' => array_merge($metadata, [ 'metadata' => array_merge($metadata, [
'increments' => $increments, 'increments' => $increments,
'provider_payload' => $response, 'provider_payload' => $response,
'consents' => [
'legal_version' => $metadata['legal_version'],
'accepted_terms_at' => $acceptedTerms ? now()->toIso8601String() : null,
'digital_content_waiver_at' => $acceptedWaiver ? now()->toIso8601String() : null,
],
]), ]),
'extra_photos' => ($increments['extra_photos'] ?? 0) * $quantity, 'extra_photos' => ($increments['extra_photos'] ?? 0) * $quantity,
'extra_guests' => ($increments['extra_guests'] ?? 0) * $quantity, 'extra_guests' => ($increments['extra_guests'] ?? 0) * $quantity,
@@ -109,4 +119,9 @@ class EventAddonCheckoutService
'id' => $checkoutId, 'id' => $checkoutId,
]; ];
} }
protected function resolveLegalVersion(): string
{
return config('app.legal_version', now()->toDateString());
}
} }

View File

@@ -5,6 +5,7 @@ namespace App\Services\Addons;
use App\Models\EventPackage; use App\Models\EventPackage;
use App\Models\EventPackageAddon; use App\Models\EventPackageAddon;
use App\Notifications\Addons\AddonPurchaseReceipt; use App\Notifications\Addons\AddonPurchaseReceipt;
use App\Notifications\Ops\AddonPurchased;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
@@ -98,6 +99,11 @@ class EventAddonWebhookService
if ($tenant) { if ($tenant) {
Notification::route('mail', [$tenant->contact_email ?? $tenant->user?->email]) Notification::route('mail', [$tenant->contact_email ?? $tenant->user?->email])
->notify(new AddonPurchaseReceipt($addon)); ->notify(new AddonPurchaseReceipt($addon));
$opsEmail = config('mail.ops_address');
if ($opsEmail) {
Notification::route('mail', $opsEmail)->notify(new AddonPurchased($addon));
}
} }
}); });

View File

@@ -15,7 +15,9 @@ use Illuminate\Auth\Events\Registered;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use App\Notifications\Ops\PurchaseCreated;
class CheckoutAssignmentService class CheckoutAssignmentService
{ {
@@ -48,6 +50,14 @@ class CheckoutAssignmentService
} }
$metadata = $session->provider_metadata ?? []; $metadata = $session->provider_metadata ?? [];
$consents = [
'accepted_terms_at' => optional($session->accepted_terms_at)->toIso8601String(),
'accepted_privacy_at' => optional($session->accepted_privacy_at)->toIso8601String(),
'accepted_withdrawal_notice_at' => optional($session->accepted_withdrawal_notice_at)->toIso8601String(),
'digital_content_waiver_at' => optional($session->digital_content_waiver_at)->toIso8601String(),
'legal_version' => $session->legal_version,
];
$consents = array_filter($consents);
$providerReference = $options['provider_reference'] $providerReference = $options['provider_reference']
?? $metadata['paddle_transaction_id'] ?? null ?? $metadata['paddle_transaction_id'] ?? null
@@ -72,7 +82,11 @@ class CheckoutAssignmentService
'price' => $session->amount_total, 'price' => $session->amount_total,
'type' => $package->type === 'reseller' ? 'reseller_subscription' : 'endcustomer_event', 'type' => $package->type === 'reseller' ? 'reseller_subscription' : 'endcustomer_event',
'purchased_at' => now(), 'purchased_at' => now(),
'metadata' => $options['payload'] ?? null, 'metadata' => array_filter([
'payload' => $options['payload'] ?? null,
'checkout_session_id' => $session->id,
'consents' => $consents ?: null,
]),
] ]
); );
@@ -104,6 +118,11 @@ class CheckoutAssignmentService
Mail::to($user) Mail::to($user)
->locale($mailLocale) ->locale($mailLocale)
->queue(new PurchaseConfirmation($purchase)); ->queue(new PurchaseConfirmation($purchase));
$opsEmail = config('mail.ops_address');
if ($opsEmail) {
Notification::route('mail', $opsEmail)->notify(new PurchaseCreated($purchase));
}
} }
AbandonedCheckout::query() AbandonedCheckout::query()

View File

@@ -33,6 +33,21 @@ class PaddleTransactionService
]; ];
} }
/**
* Issue a refund for a Paddle transaction.
*
* @param array{reason?: string|null} $options
* @return array<string, mixed>
*/
public function refund(string $transactionId, array $options = []): array
{
$payload = array_filter([
'reason' => $options['reason'] ?? null,
], static fn ($value) => $value !== null && $value !== '');
return $this->client->post("/transactions/{$transactionId}/refunds", $payload);
}
/** /**
* @param array<string, mixed> $transaction * @param array<string, mixed> $transaction
* @return array<string, mixed> * @return array<string, mixed>

View File

@@ -60,6 +60,7 @@ trait PresentsPackages
'max_events_per_year' => $package->max_events_per_year, 'max_events_per_year' => $package->max_events_per_year,
'watermark_allowed' => (bool) $package->watermark_allowed, 'watermark_allowed' => (bool) $package->watermark_allowed,
'branding_allowed' => (bool) $package->branding_allowed, 'branding_allowed' => (bool) $package->branding_allowed,
'activates_immediately' => (bool) ($package->activates_immediately ?? true),
]; ];
} }

View File

@@ -113,4 +113,16 @@ return [
'name' => env('MAIL_FROM_NAME', 'Dein Fotospiel.App Team'), 'name' => env('MAIL_FROM_NAME', 'Dein Fotospiel.App Team'),
], ],
/*
|--------------------------------------------------------------------------
| Ops / SuperAdmin Notification Address
|--------------------------------------------------------------------------
|
| Optional address for internal Ops/SuperAdmin notifications (purchases,
| add-ons, refunds). Leave null to disable Ops mail routing.
|
*/
'ops_address' => env('MAIL_OPS', 'info@fotospiel.app'),
]; ];

View File

@@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('checkout_sessions', function (Blueprint $table) {
$table->timestamp('accepted_terms_at')->nullable()->after('coupon_snapshot');
$table->timestamp('accepted_privacy_at')->nullable()->after('accepted_terms_at');
$table->timestamp('accepted_withdrawal_notice_at')->nullable()->after('accepted_privacy_at');
$table->timestamp('digital_content_waiver_at')->nullable()->after('accepted_withdrawal_notice_at');
$table->string('legal_version', 50)->nullable()->after('digital_content_waiver_at');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('checkout_sessions', function (Blueprint $table) {
$table->dropColumn([
'accepted_terms_at',
'accepted_privacy_at',
'accepted_withdrawal_notice_at',
'digital_content_waiver_at',
'legal_version',
]);
});
}
};

View File

@@ -45,6 +45,16 @@ class LegalPagesSeeder extends Seeder
'en' => 'docs/content/legal/agb-en.md', 'en' => 'docs/content/legal/agb-en.md',
], ],
], ],
'widerruf' => [
'title' => [
'de' => 'Widerrufsbelehrung',
'en' => 'Rights of withdrawal',
],
'files' => [
'de' => 'docs/content/legal/widerrufsbelehrung-de.md',
'en' => 'docs/content/legal/widerrufsbelehrung-en.md',
],
],
]; ];
foreach ($pages as $slug => $config) { foreach ($pages as $slug => $config) {

View File

@@ -0,0 +1,97 @@
# Widerrufsbelehrung für „Die Fotospiel App“
**Stand:** Oktober 2025
---
## 1. Geltungsbereich des Widerrufsrechts
Diese Widerrufsbelehrung gilt für Verträge zwischen uns als Anbieter der „Fotospiel App“ und zugehöriger Online-Dienste (insbesondere Event-Packages, Fotospiel-Zugänge, Foto-Hosting, Online-Galerien) und **Verbrauchern** im Sinne von § 13 BGB.
**Unternehmer** im Sinne von § 14 BGB (z.B. Agenturen, Reseller, gewerbliche Veranstalter, Unternehmen) haben **kein gesetzliches Widerrufsrecht** nach dieser Belehrung.
---
## 2. Widerrufsrecht
Sie haben das Recht, binnen **vierzehn Tagen** ohne Angabe von Gründen diesen Vertrag zu widerrufen.
Die Widerrufsfrist beträgt vierzehn Tage ab dem Tag des Vertragsabschlusses.
Um Ihr Widerrufsrecht auszuüben, müssen Sie uns
> S.E.B. Fotografie
> [vollständige Anschrift wie im Impressum]
> E-Mail: [z.B. support@fotospiel.de]
mittels einer **eindeutigen Erklärung** (z.B. ein mit der Post versandter Brief oder E-Mail) über Ihren Entschluss, diesen Vertrag zu widerrufen, informieren. Sie können dafür das unter Ziffer 6 beigefügte **Muster-Widerrufsformular** verwenden, das jedoch nicht vorgeschrieben ist.
Zur Wahrung der Widerrufsfrist reicht es aus, dass Sie die Mitteilung über die Ausübung des Widerrufsrechts **vor Ablauf der Widerrufsfrist** absenden.
---
## 3. Folgen des Widerrufs
Wenn Sie diesen Vertrag widerrufen, haben wir Ihnen **alle Zahlungen**, die wir von Ihnen erhalten haben, einschließlich der Lieferkosten (mit Ausnahme der zusätzlichen Kosten, die sich daraus ergeben, dass Sie eine andere Art der Lieferung als die von uns angebotene, günstigste Standardlieferung gewählt haben), **unverzüglich und spätestens binnen vierzehn Tagen** ab dem Tag zurückzuzahlen, an dem die Mitteilung über Ihren Widerruf dieses Vertrags bei uns eingegangen ist.
Für diese Rückzahlung verwenden wir dasselbe Zahlungsmittel, das Sie bei der ursprünglichen Transaktion eingesetzt haben (z.B. über unseren Zahlungsdienstleister Paddle), es sei denn, mit Ihnen wurde ausdrücklich etwas anderes vereinbart; in keinem Fall werden Ihnen wegen dieser Rückzahlung **Entgelte berechnet**.
Bei Verträgen über **Dienstleistungen oder digitale Dienste**, die während der Widerrufsfrist bereits begonnen haben, gilt:
- Haben Sie verlangt, dass die Dienstleistung bzw. der digitale Dienst (z.B. Freischaltung eines Event-Packages, Bereitstellung der Event-Galerie, Upload- und Hosting-Funktionen) **während der Widerrufsfrist** beginnen soll, so haben Sie uns einen **angemessenen Betrag** zu zahlen.
- Dieser Betrag entspricht dem Anteil der bis zu dem Zeitpunkt, zu dem Sie uns von der Ausübung des Widerrufsrechts hinsichtlich dieses Vertrags unterrichten, bereits erbrachten Dienstleistungen im Vergleich zum **Umfang der vertraglich vereinbarten Gesamtleistung**.
---
## 4. Besondere Hinweise zu digitalen Dienstleistungen und Event-Packages
Unsere Leistungen sind überwiegend **digitale Dienstleistungen** und **nicht auf einem körperlichen Datenträger** bereitgestellte digitale Inhalte, insbesondere:
- Zugang zur Fotospiel-App für Event-Gäste
- Einrichtung und Verwaltung von Event-Galerien
- Upload, Speicherung und Bereitstellung von Fotos (Foto-Hosting)
- Zusatzfunktionen wie Aufgaben/Fotospiele, Analytics, Branding u. Ä.
- zeitlich befristete oder wiederkehrende **Packages** (Einmalkauf pro Event oder Abo)
Ihr Widerrufsrecht kann bei diesen Verträgen **vorzeitig erlöschen**, wenn folgende Voraussetzungen erfüllt sind:
1. Wir haben mit der **Ausführung des Vertrags** (z.B. Freischaltung Ihres Event-Packages, Aktivierung von Hosting-Leistungen) begonnen, **nachdem**
2. Sie **ausdrücklich zugestimmt** haben, dass wir vor Ablauf der Widerrufsfrist mit der Ausführung des Vertrags beginnen, und
3. Sie **bestätigt** haben, dass Sie **mit Beginn der Ausführung** Ihr Widerrufsrecht **verlieren**.
Dies wird im Bestellprozess z.B. durch eine entsprechende **Checkbox und Hinweis-Text** dokumentiert. Ohne diese ausdrückliche Zustimmung und Kenntnisbestätigung erlischt Ihr Widerrufsrecht nicht vorzeitig.
---
## 5. Widerruf bei Abonnements (wiederkehrende Leistungen)
Bei wiederkehrenden Leistungen (z.B. jährliche Reseller-/Agentur-Packages oder Abos für Event-Kontingente) gilt:
- Sie haben als Verbraucher ein **Widerrufsrecht von 14 Tagen** ab Vertragsabschluss.
- Widerrufen Sie, nachdem die Leistungen bereits begonnen haben (z.B. Event-Credits genutzt, Galerien aktiv), behalten wir uns vor, **Wertersatz** für den bereits erbrachten Zeitraum bzw. die bereits genutzten Leistungen zu verlangen.
- Nach Ablauf der Widerrufsfrist ist ein Widerruf nach diesen Regeln nicht mehr möglich; es gelten dann ausschließlich die vertraglichen und gesetzlichen **Kündigungsregelungen** (siehe AGB).
---
## 6. Muster-Widerrufsformular
> (Wenn Sie den Vertrag widerrufen wollen, dann füllen Sie bitte dieses Formular aus und senden Sie es zurück.)
>
> **An**
> S.E.B. Fotografie
> [vollständige Anschrift wie im Impressum]
> E-Mail: [z.B. support@fotospiel.de]
>
> **Hiermit widerrufe(n) ich/wir (*)** den von mir/uns (*) abgeschlossenen Vertrag über die Erbringung der folgenden Dienstleistung:
>
> Bestellte digitale Dienstleistung / Event-Package / Abo (*):
> Bestellt am (*):
> Name des/der Verbraucher(s):
> Anschrift des/der Verbraucher(s):
> E-Mail-Adresse (mit der bestellt wurde):
>
> Datum:
>
> Unterschrift des/der Verbraucher(s) (nur bei Mitteilung auf Papier)
>
> (*) Unzutreffendes streichen.

View File

@@ -0,0 +1,97 @@
# Right of Withdrawal for “The Fotospiel App”
**Last updated:** October 2025
---
## 1. Scope of the Right of Withdrawal
This withdrawal policy applies to contracts between us as the provider of “The Fotospiel App” and related online services (in particular event packages, Fotospiel access, photo hosting, online galleries) and **consumers** within the meaning of Art. 13 German Civil Code (BGB).
**Business customers** within the meaning of Art. 14 BGB (e.g. agencies, resellers, commercial organizers, companies) **do not have a statutory right of withdrawal** under this policy.
---
## 2. Right of Withdrawal
You have the right to withdraw from this contract within **fourteen days** without giving any reason.
The withdrawal period is fourteen days from the day on which the contract is concluded.
To exercise your right of withdrawal, you must inform us
> S.E.B. Fotografie
> [full address as stated in the imprint]
> Email: [e.g. support@fotospiel.de]
of your decision to withdraw from this contract by means of a **clear statement** (e.g. a letter sent by post or email). You may use the **model withdrawal form** set out in section 6, but this is not mandatory.
To meet the withdrawal deadline, it is sufficient for you to send your communication concerning your exercise of the right of withdrawal **before the withdrawal period has expired**.
---
## 3. Consequences of Withdrawal
If you withdraw from this contract, we shall reimburse you for **all payments** we have received from you, including delivery costs (with the exception of any additional costs arising from the fact that you have chosen a type of delivery other than the least expensive standard delivery offered by us), **without undue delay and at the latest within fourteen days** from the day on which we receive notification of your withdrawal from this contract.
For this reimbursement, we will use the same means of payment that you used for the original transaction (e.g. via our payment provider Paddle), unless we have expressly agreed otherwise with you; in no event will you be charged any **fees** as a result of such reimbursement.
For contracts for **services or digital services** which have already started during the withdrawal period, the following applies:
- If you have requested that the service or digital service (e.g. activation of an event package, provision of an event gallery, upload and hosting functions) **commence during the withdrawal period**, you must pay us an **appropriate amount**.
- This amount corresponds to the portion of the services already provided up to the time at which you inform us of the exercise of the right of withdrawal in relation to this contract, in comparison to the **full scope of the services** covered by the contract.
---
## 4. Special Notes on Digital Services and Event Packages
Our services mainly consist of **digital services** and **digital content not supplied on a tangible medium**, in particular:
- Access to the Fotospiel App for event guests
- Creation and management of event galleries
- Upload, storage and provision of photos (photo hosting)
- Additional features such as tasks/photo games, analytics, branding, etc.
- time-limited or recurring **packages** (one-time purchase per event or subscriptions)
Your right of withdrawal may **expire early** for these contracts if the following conditions are met:
1. We have begun with the **performance of the contract** (e.g. activation of your event package, activation of hosting services) **after**
2. You have **explicitly consented** to us beginning performance of the contract before the end of the withdrawal period, and
3. You have **acknowledged** that you **lose your right of withdrawal** once performance of the contract has begun.
This is documented in the checkout process, for example by means of a corresponding **checkbox and notice text**. Without this explicit consent and acknowledgement, your right of withdrawal does not expire early.
---
## 5. Withdrawal for Subscriptions (Recurring Services)
For recurring services (e.g. annual reseller/agency packages or subscriptions for event contingents), the following applies:
- As a consumer, you have a **right of withdrawal of 14 days** from the conclusion of the contract.
- If you withdraw after services have already started (e.g. event credits have been used, galleries are active), we reserve the right to claim **compensation for the value** of the services already provided and/or used.
- After expiry of the withdrawal period, withdrawal under these rules is no longer possible; in this case, only the contractual and statutory **termination provisions** (see Terms & Conditions) apply.
---
## 6. Model Withdrawal Form
> (If you want to withdraw from the contract, please fill in this form and send it back.)
>
> **To**
> S.E.B. Fotografie
> [full address as stated in the imprint]
> Email: [e.g. support@fotospiel.de]
>
> **I/We (*) hereby withdraw from the contract concluded by me/us (*)** for the provision of the following service:
>
> Ordered digital service / event package / subscription (*):
> Ordered on (*):
> Name of consumer(s):
> Address of consumer(s):
> Email address used for the order:
>
> Date:
>
> Signature of consumer(s) (only if this form is notified on paper)
>
> (*) Delete as appropriate.

View File

@@ -0,0 +1,5 @@
# Widerrufsbelehrung für „Die Fotospiel App“
*(Fallback-Datei; maßgeblich ist die in der Datenbank hinterlegte Version.)*
Siehe aktuelle Version unter `/de/widerrufsbelehrung`.

View File

@@ -0,0 +1,5 @@
# Right of Withdrawal for “The Fotospiel App”
*(Fallback file; authoritative version lives in the database.)*
See the current version at `/en/withdrawal`.

View File

@@ -0,0 +1,4 @@
# Ops-Mail Default & Widerrufsbelehrung Routing
- `MAIL_OPS` neu in `config/mail.php` (Default: `info@fotospiel.app` in `.env.example`). Ops-Benachrichtigungen für Käufe/Add-ons/Refunds nutzen diese Adresse.
- Neue Legal-Routen für Widerrufsbelehrung (`/de/widerrufsbelehrung`, `/en/withdrawal`). Inhalte werden aus der `legal_pages` Tabelle geladen; Fallback-Markdowns liegen unter `docs/legal/widerrufsbelehrung-de/en.md`. Markdown wird wie bei anderen Legal-Seiten gerendert.

View File

@@ -31,6 +31,7 @@
"and": "und", "and": "und",
"stripe_privacy": "Stripe Datenschutz", "stripe_privacy": "Stripe Datenschutz",
"agb": "Allgemeine Geschäftsbedingungen", "agb": "Allgemeine Geschäftsbedingungen",
"widerrufsbelehrung": "Widerrufsbelehrung",
"effective_from": "Gültig seit {{date}}", "effective_from": "Gültig seit {{date}}",
"version": "Version {{version}}" "version": "Version {{version}}"
} }

View File

@@ -167,6 +167,7 @@
"detail_labels": { "detail_labels": {
"photos": "Fotos", "photos": "Fotos",
"guests": "Gäste", "guests": "Gäste",
"gaste": "Gäste",
"tasks": "Aufgaben", "tasks": "Aufgaben",
"gallery": "Galerie", "gallery": "Galerie",
"branding": "Branding", "branding": "Branding",
@@ -480,6 +481,26 @@
"back": "Zurück", "back": "Zurück",
"next": "Weiter", "next": "Weiter",
"cancel": "Abbrechen", "cancel": "Abbrechen",
"legal": {
"headline": "Rechtliches & Bestätigungen",
"summary_title": "Ihre Bestellung",
"package_label": "Ausgewähltes Paket",
"billing_type_one_time": "Einmalkauf (pro Event)",
"billing_type_subscription": "Abo (wiederkehrend)",
"legal_links_intro": "Details zur Belehrung:",
"link_terms": "AGB",
"link_privacy": "Datenschutzerklärung",
"link_cancellation": "Widerrufsbelehrung",
"checkbox_terms_label": "Ich habe die AGB, die Datenschutzerklärung und die Widerrufsbelehrung gelesen und akzeptiere sie.",
"checkbox_terms_error": "Bitte bestätigen Sie, dass Sie AGB, Datenschutzerklärung und Widerrufsbelehrung gelesen haben.",
"checkbox_digital_content_label": "Ich verlange ausdrücklich, dass Sie vor Ablauf der Widerrufsfrist mit der Ausführung der digitalen Dienstleistungen (Freischaltung meines Event-Packages inkl. Galerie und Hosting) beginnen. Mir ist bekannt, dass ich bei vollständiger Vertragserfüllung mein Widerrufsrecht verliere.",
"checkbox_digital_content_error": "Bitte bestätigen Sie, dass Sie dem sofortigen Beginn der digitalen Dienstleistung und dem damit verbundenen vorzeitigen Erlöschen des Widerrufsrechts zustimmen.",
"hint_subscription_withdrawal": "Bei Abonnements haben Verbraucher ein 14-tägiges Widerrufsrecht ab Vertragsschluss. Im Falle eines Widerrufs nach Leistungsbeginn behalten wir uns angemessenen Wertersatz für bereits erbrachte Leistungen vor.",
"open_withdrawal": "Widerrufsbelehrung anzeigen",
"modal_description": "So informieren wir über das Widerrufsrecht. Der volle Text gilt für deinen Kauf.",
"modal_loading": "Widerrufsbelehrung wird geladen…",
"modal_error": "Widerrufsbelehrung konnte nicht geladen werden."
},
"package_step": { "package_step": {
"title": "Paket wählen", "title": "Paket wählen",
"subtitle": "Auswahl und Vergleich", "subtitle": "Auswahl und Vergleich",

View File

@@ -28,6 +28,7 @@
"data_security": "Data Security", "data_security": "Data Security",
"data_security_desc": "We use HTTPS, encrypted storage (passwords hashed) and regular backups. Access to data is role-based restricted (Tenant vs SuperAdmin).", "data_security_desc": "We use HTTPS, encrypted storage (passwords hashed) and regular backups. Access to data is role-based restricted (Tenant vs SuperAdmin).",
"agb": "Terms & Conditions", "agb": "Terms & Conditions",
"widerrufsbelehrung": "Right of Withdrawal",
"effective_from": "Effective from {{date}}", "effective_from": "Effective from {{date}}",
"version": "Version {{version}}" "version": "Version {{version}}"
} }

View File

@@ -473,6 +473,26 @@
"back": "Back", "back": "Back",
"next": "Next", "next": "Next",
"cancel": "Cancel", "cancel": "Cancel",
"legal": {
"headline": "Legal & confirmations",
"summary_title": "Your order",
"package_label": "Selected package",
"billing_type_one_time": "One-time purchase (per event)",
"billing_type_subscription": "Subscription (recurring)",
"legal_links_intro": "Details on the withdrawal policy:",
"link_terms": "Terms & Conditions",
"link_privacy": "Privacy Policy",
"link_cancellation": "Right of Withdrawal",
"checkbox_terms_label": "I have read and accept the Terms & Conditions, Privacy Policy, and Right of Withdrawal.",
"checkbox_terms_error": "Please confirm that you have read and accepted the Terms, Privacy Policy and Right of Withdrawal.",
"checkbox_digital_content_label": "I expressly request that you begin providing the digital services (activation of my event package including gallery and hosting) before the withdrawal period has expired. I understand that I lose my right of withdrawal once the contract has been fully performed.",
"checkbox_digital_content_error": "Please confirm that you agree to the immediate start of the digital service and the related early expiry of the right of withdrawal.",
"hint_subscription_withdrawal": "For subscriptions, consumers have a 14-day right of withdrawal from the conclusion of the contract. In case of withdrawal after the start of the service, we reserve the right to claim appropriate compensation for the value of services already provided.",
"open_withdrawal": "View withdrawal policy",
"modal_description": "Below is the current withdrawal policy for your purchase.",
"modal_loading": "Loading withdrawal policy…",
"modal_error": "Could not load the withdrawal policy."
},
"package_step": { "package_step": {
"title": "Select Package", "title": "Select Package",
"subtitle": "Selection and Comparison", "subtitle": "Selection and Comparison",

View File

@@ -0,0 +1,22 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
export interface ScrollAreaProps extends React.HTMLAttributes<HTMLDivElement> {
viewportClassName?: string;
}
export const ScrollArea = React.forwardRef<HTMLDivElement, ScrollAreaProps>(
({ className, children, viewportClassName, ...props }, ref) => (
<div
ref={ref}
className={cn('relative overflow-hidden', className)}
{...props}
>
<div className={cn('h-full w-full overflow-auto', viewportClassName)}>
{children}
</div>
</div>
)
);
ScrollArea.displayName = 'ScrollArea';

View File

@@ -185,6 +185,8 @@ const [canUpload, setCanUpload] = useState(true);
const canvasRef = useRef<HTMLCanvasElement | null>(null); const canvasRef = useRef<HTMLCanvasElement | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null); const fileInputRef = useRef<HTMLInputElement | null>(null);
const liveRegionRef = useRef<HTMLDivElement | null>(null); const liveRegionRef = useRef<HTMLDivElement | null>(null);
const cameraViewportRef = useRef<HTMLDivElement | null>(null);
const cameraShellRef = useRef<HTMLElement | null>(null);
const streamRef = useRef<MediaStream | null>(null); const streamRef = useRef<MediaStream | null>(null);
const countdownTimerRef = useRef<number | null>(null); const countdownTimerRef = useRef<number | null>(null);
@@ -481,6 +483,25 @@ const [canUpload, setCanUpload] = useState(true);
setPreferences((prev) => ({ ...prev, flashPreferred: !prev.flashPreferred })); setPreferences((prev) => ({ ...prev, flashPreferred: !prev.flashPreferred }));
}, []); }, []);
const handleToggleImmersive = useCallback(async () => {
setImmersiveMode((prev) => !prev);
const shell = cameraShellRef.current;
if (!shell) return;
const prefersReducedMotion = typeof window !== 'undefined'
? window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches
: false;
if (prefersReducedMotion) return;
try {
if (!document.fullscreenElement) {
await shell.requestFullscreen?.();
} else {
await document.exitFullscreen?.();
}
} catch (error) {
console.warn('Fullscreen toggle failed', error);
}
}, []);
const triggerConfetti = useCallback(async () => { const triggerConfetti = useCallback(async () => {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
const prefersReducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches; const prefersReducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches;
@@ -883,14 +904,14 @@ const [canUpload, setCanUpload] = useState(true);
) : null; ) : null;
const heroOverlay = !task && showHeroOverlay && mode !== 'uploading' ? ( const heroOverlay = !task && showHeroOverlay && mode !== 'uploading' ? (
<div className="absolute left-4 right-4 top-6 z-30 rounded-3xl border border-white/30 bg-black/60 p-4 text-white shadow-2xl backdrop-blur sm:left-6 sm:right-6 sm:top-8"> <div className="absolute left-4 right-4 top-4 z-30 rounded-2xl border border-white/25 bg-black/60 px-4 py-3 text-white shadow-2xl backdrop-blur sm:left-6 sm:right-6 sm:top-5">
<div className="flex items-start justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<div> <div className="space-y-1">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-white/70">Bereit für dein Foto?</p> <p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-white/70">Bereit für dein Foto?</p>
<p className="text-lg font-semibold leading-tight">Teile den Moment mit allen Gästen.</p> <p className="text-base font-semibold leading-tight">Teile den Moment mit allen Gästen.</p>
<p className="text-sm text-white/80">Zieh dir eine Mission oder starte direkt mit der Kamera.</p> <p className="text-xs text-white/75">Zieh eine Mission oder starte direkt.</p>
</div> </div>
<Badge variant="secondary" className="rounded-full bg-white/15 px-3 py-1 text-[11px] font-semibold uppercase tracking-wide text-white/90"> <Badge variant="secondary" className="rounded-full bg-white/15 px-3 py-1 text-[10px] font-semibold uppercase tracking-wide text-white/90">
Live Live
</Badge> </Badge>
</div> </div>
@@ -898,7 +919,10 @@ const [canUpload, setCanUpload] = useState(true);
<Button <Button
size="sm" size="sm"
className="rounded-full bg-white text-black shadow" className="rounded-full bg-white text-black shadow"
onClick={() => navigate(tasksUrl)} onClick={() => {
setShowHeroOverlay(false);
navigate(tasksUrl);
}}
> >
Mission ziehen Mission ziehen
</Button> </Button>
@@ -906,11 +930,14 @@ const [canUpload, setCanUpload] = useState(true);
size="sm" size="sm"
variant="secondary" variant="secondary"
className="rounded-full border border-white/30 bg-white/10 text-white" className="rounded-full border border-white/30 bg-white/10 text-white"
onClick={() => navigate(tasksUrl)} onClick={() => {
setShowHeroOverlay(false);
navigate(tasksUrl);
}}
> >
Stimmung wählen Stimmung wählen
</Button> </Button>
<span className="inline-flex items-center gap-2 rounded-full border border-white/20 bg-white/5 px-3 py-1 text-xs text-white/85"> <span className="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/5 px-3 py-1 text-[11px] text-white/85">
<Sparkles className="h-4 w-4 text-amber-200" /> <Sparkles className="h-4 w-4 text-amber-200" />
Mini-Mission: Fang ein Lachen ein Mini-Mission: Fang ein Lachen ein
</span> </span>
@@ -1069,14 +1096,26 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[
return renderWithDialog( return renderWithDialog(
<> <>
<div className="relative flex min-h-screen flex-col gap-6 pt-4" style={bodyFont ? { fontFamily: bodyFont } : undefined}> <div
ref={cameraShellRef as unknown as React.RefObject<HTMLDivElement>}
className="relative flex min-h-screen flex-col gap-4 pb-[calc(env(safe-area-inset-bottom,0px)+12px)] pt-3"
style={bodyFont ? { fontFamily: bodyFont } : undefined}
>
{taskFloatingCard} {taskFloatingCard}
{heroOverlay} {heroOverlay}
<section <section
className="relative flex min-h-[70vh] flex-col overflow-hidden border border-white/10 bg-black text-white shadow-2xl" className="relative flex flex-col overflow-hidden border border-white/10 bg-black text-white shadow-2xl"
style={{ borderRadius: radius }} style={{ borderRadius: radius }}
> >
<div className="relative aspect-[3/4] sm:aspect-video"> <div
ref={cameraViewportRef}
className="relative w-full"
style={{
height: 'calc(100vh - 160px)',
minHeight: '70vh',
maxHeight: '90vh',
}}
>
<video <video
ref={videoRef} ref={videoRef}
className={cn( className={cn(
@@ -1123,7 +1162,7 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[
</div> </div>
)} )}
<div className="pointer-events-none absolute inset-x-0 bottom-6 z-30 flex justify-center"> <div className="pointer-events-none absolute inset-x-0 bottom-4 z-30 flex justify-center">
<div className="pointer-events-auto flex items-center gap-2 rounded-full border border-white/20 bg-black/40 px-3 py-2 backdrop-blur"> <div className="pointer-events-auto flex items-center gap-2 rounded-full border border-white/20 bg-black/40 px-3 py-2 backdrop-blur">
<Button <Button
size="icon" size="icon"
@@ -1197,7 +1236,7 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[
controlIconButtonBase, controlIconButtonBase,
immersiveMode && 'border-white bg-white text-black' immersiveMode && 'border-white bg-white text-black'
)} )}
onClick={() => setImmersiveMode((prev) => !prev)} onClick={handleToggleImmersive}
title={ title={
immersiveMode immersiveMode
? t('upload.controls.exitFullscreen', 'Menü einblenden') ? t('upload.controls.exitFullscreen', 'Menü einblenden')
@@ -1299,9 +1338,9 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[
)} )}
{isCountdownActive && ( {isCountdownActive && (
<div <div
className="absolute inset-0 rounded-full" className="absolute inset-1 rounded-full"
style={{ style={{
background: `conic-gradient(#fff ${countdownDegrees}deg, rgba(255,255,255,0.15) ${countdownDegrees}deg)`, background: `conic-gradient(#fff ${countdownDegrees}deg, rgba(255,255,255,0.12) ${countdownDegrees}deg)`,
}} }}
/> />
)} )}

View File

@@ -14,6 +14,7 @@ const Footer: React.FC = () => {
impressum: localizedPath('/impressum'), impressum: localizedPath('/impressum'),
datenschutz: localizedPath('/datenschutz'), datenschutz: localizedPath('/datenschutz'),
agb: localizedPath('/agb'), agb: localizedPath('/agb'),
widerruf: localizedPath('/widerrufsbelehrung'),
kontakt: localizedPath('/kontakt'), kontakt: localizedPath('/kontakt'),
}), [localizedPath]); }), [localizedPath]);
@@ -57,6 +58,11 @@ const Footer: React.FC = () => {
{t('legal:agb')} {t('legal:agb')}
</Link> </Link>
</li> </li>
<li>
<Link href={links.widerruf} className="transition-colors hover:text-pink-500 dark:hover:text-pink-300">
{t('legal:widerrufsbelehrung')}
</Link>
</li>
<li> <li>
<Link href={links.kontakt} className="transition-colors hover:text-pink-500 dark:hover:text-pink-300"> <Link href={links.kontakt} className="transition-colors hover:text-pink-500 dark:hover:text-pink-300">
{t('marketing:nav.contact')} {t('marketing:nav.contact')}

View File

@@ -10,6 +10,10 @@ import { previewCoupon as requestCouponPreview } from '@/lib/coupons';
import type { CouponPreviewResponse } from '@/types/coupon'; import type { CouponPreviewResponse } from '@/types/coupon';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { ScrollArea } from '@/components/ui/scroll-area';
type PaymentStatus = 'idle' | 'processing' | 'ready' | 'error'; type PaymentStatus = 'idle' | 'processing' | 'ready' | 'error';
@@ -120,6 +124,9 @@ export const PaymentStep: React.FC = () => {
const [message, setMessage] = useState<string>(''); const [message, setMessage] = useState<string>('');
const [initialised, setInitialised] = useState(false); const [initialised, setInitialised] = useState(false);
const [inlineActive, setInlineActive] = useState(false); const [inlineActive, setInlineActive] = useState(false);
const [acceptedTerms, setAcceptedTerms] = useState(false);
const [acceptedWaiver, setAcceptedWaiver] = useState(false);
const [consentError, setConsentError] = useState<string | null>(null);
const [couponCode, setCouponCode] = useState<string>(() => { const [couponCode, setCouponCode] = useState<string>(() => {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return ''; return '';
@@ -142,6 +149,11 @@ export const PaymentStep: React.FC = () => {
const eventCallbackRef = useRef<(event: Record<string, unknown>) => void>(); const eventCallbackRef = useRef<(event: Record<string, unknown>) => void>();
const hasAutoAppliedCoupon = useRef(false); const hasAutoAppliedCoupon = useRef(false);
const checkoutContainerClass = 'paddle-checkout-container'; const checkoutContainerClass = 'paddle-checkout-container';
const [showWithdrawalModal, setShowWithdrawalModal] = useState(false);
const [withdrawalHtml, setWithdrawalHtml] = useState<string | null>(null);
const [withdrawalTitle, setWithdrawalTitle] = useState<string | null>(null);
const [withdrawalLoading, setWithdrawalLoading] = useState(false);
const [withdrawalError, setWithdrawalError] = useState<string | null>(null);
const paddleLocale = useMemo(() => { const paddleLocale = useMemo(() => {
const sourceLocale = i18n.language || (typeof document !== 'undefined' ? document.documentElement.lang : null); const sourceLocale = i18n.language || (typeof document !== 'undefined' ? document.documentElement.lang : null);
@@ -149,6 +161,7 @@ export const PaymentStep: React.FC = () => {
}, [i18n.language]); }, [i18n.language]);
const isFree = useMemo(() => (selectedPackage ? Number(selectedPackage.price) <= 0 : false), [selectedPackage]); const isFree = useMemo(() => (selectedPackage ? Number(selectedPackage.price) <= 0 : false), [selectedPackage]);
const requiresImmediateWaiver = useMemo(() => Boolean(selectedPackage?.activates_immediately), [selectedPackage]);
const applyCoupon = useCallback(async (code: string) => { const applyCoupon = useCallback(async (code: string) => {
if (!selectedPackage) { if (!selectedPackage) {
@@ -228,6 +241,11 @@ export const PaymentStep: React.FC = () => {
}, [couponCode]); }, [couponCode]);
const handleFreeActivation = async () => { const handleFreeActivation = async () => {
if (!acceptedTerms || (requiresImmediateWaiver && !acceptedWaiver)) {
setConsentError(t('checkout.legal.checkbox_terms_error'));
return;
}
setPaymentCompleted(true); setPaymentCompleted(true);
nextStep(); nextStep();
}; };
@@ -237,6 +255,11 @@ export const PaymentStep: React.FC = () => {
return; return;
} }
if (!acceptedTerms || (requiresImmediateWaiver && !acceptedWaiver)) {
setConsentError(t('checkout.legal.checkbox_terms_error'));
return;
}
if (!selectedPackage.paddle_price_id) { if (!selectedPackage.paddle_price_id) {
setStatus('error'); setStatus('error');
setMessage(t('checkout.payment_step.paddle_not_configured')); setMessage(t('checkout.payment_step.paddle_not_configured'));
@@ -282,13 +305,15 @@ export const PaymentStep: React.FC = () => {
frameInitialHeight: '550', frameInitialHeight: '550',
frameStyle: 'width: 100%; min-width: 320px; background-color: transparent; border: none;', frameStyle: 'width: 100%; min-width: 320px; background-color: transparent; border: none;',
theme: 'light', theme: 'light',
locale: paddleLocale, locale: paddleLocale,
}, },
customData: { customData: {
package_id: String(selectedPackage.id), package_id: String(selectedPackage.id),
locale: paddleLocale, locale: paddleLocale,
}, accepted_terms: acceptedTerms ? '1' : '0',
}; accepted_waiver: requiresImmediateWaiver && acceptedWaiver ? '1' : '0',
},
};
const customerEmail = authUser?.email ?? null; const customerEmail = authUser?.email ?? null;
if (customerEmail) { if (customerEmail) {
@@ -329,6 +354,8 @@ export const PaymentStep: React.FC = () => {
package_id: selectedPackage.id, package_id: selectedPackage.id,
locale: paddleLocale, locale: paddleLocale,
coupon_code: couponPreview?.coupon.code ?? undefined, coupon_code: couponPreview?.coupon.code ?? undefined,
accepted_terms: acceptedTerms,
accepted_waiver: requiresImmediateWaiver ? acceptedWaiver : false,
}), }),
}); });
@@ -489,6 +516,31 @@ export const PaymentStep: React.FC = () => {
} }
}, []); }, []);
const openWithdrawalModal = useCallback(async () => {
setShowWithdrawalModal(true);
if (withdrawalHtml || withdrawalLoading) {
return;
}
setWithdrawalLoading(true);
setWithdrawalError(null);
try {
const response = await fetch(`/api/v1/legal/widerrufsbelehrung?lang=${paddleLocale}`);
if (!response.ok) {
throw new Error(`Failed to load withdrawal page (${response.status})`);
}
const data = await response.json();
setWithdrawalHtml(data.body_html || '');
setWithdrawalTitle(data.title || t('checkout.legal.link_cancellation'));
} catch (error) {
setWithdrawalError(t('checkout.legal.modal_error'));
} finally {
setWithdrawalLoading(false);
}
}, [paddleLocale, t, withdrawalHtml, withdrawalLoading]);
if (!selectedPackage) { if (!selectedPackage) {
return ( return (
<Alert variant="destructive"> <Alert variant="destructive">
@@ -554,15 +606,85 @@ export const PaymentStep: React.FC = () => {
</div> </div>
</div> </div>
<div className="flex flex-col items-stretch gap-3 w-full max-w-sm"> <div className="flex flex-col items-stretch gap-3 w-full max-w-sm">
<PaddleCta <div className="space-y-3 rounded-xl border border-white/30 bg-white/10 p-4">
onCheckout={startPaddleCheckout} <div className="space-y-2">
disabled={status === 'processing'} <div className="flex items-start gap-3">
isProcessing={status === 'processing'} <Checkbox
className={cn('bg-white text-[#001835] hover:bg-white/90', PRIMARY_CTA_STYLES)} id="checkout-terms-hero"
/> checked={acceptedTerms}
<p className="text-xs text-white/70 text-center"> onCheckedChange={(checked) => {
{t('checkout.payment_step.guided_cta_hint')} setAcceptedTerms(Boolean(checked));
</p> if (consentError) {
setConsentError(null);
}
}}
className="border-white/60 data-[state=checked]:bg-white data-[state=checked]:text-[#001835]"
/>
<div className="space-y-1 text-sm">
<Label htmlFor="checkout-terms-hero" className="cursor-pointer text-white">
{t('checkout.legal.checkbox_terms_label')}
</Label>
<p className="text-xs text-white/80">
{t('checkout.legal.legal_links_intro')}{' '}
<button
type="button"
className="underline underline-offset-2"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
openWithdrawalModal();
}}
>
{t('checkout.legal.open_withdrawal')}
</button>
</p>
</div>
</div>
{requiresImmediateWaiver && (
<div className="flex items-start gap-3">
<Checkbox
id="checkout-waiver-hero"
checked={acceptedWaiver}
onCheckedChange={(checked) => {
setAcceptedWaiver(Boolean(checked));
if (consentError) {
setConsentError(null);
}
}}
className="border-white/60 data-[state=checked]:bg-white data-[state=checked]:text-[#001835]"
/>
<div className="space-y-1 text-sm">
<Label htmlFor="checkout-waiver-hero" className="cursor-pointer text-white">
{t('checkout.legal.checkbox_digital_content_label')}
</Label>
<p className="text-xs text-white/80">
{t('checkout.legal.hint_subscription_withdrawal')}
</p>
</div>
</div>
)}
{consentError && (
<div className="flex items-center gap-2 text-sm text-red-200">
<XCircle className="h-4 w-4" />
<span>{consentError}</span>
</div>
)}
</div>
<div className="space-y-2">
<PaddleCta
onCheckout={startPaddleCheckout}
disabled={status === 'processing' || !acceptedTerms || (requiresImmediateWaiver && !acceptedWaiver)}
isProcessing={status === 'processing'}
className={cn('bg-white text-[#001835] hover:bg-white/90', PRIMARY_CTA_STYLES)}
/>
<p className="text-xs text-white/70 text-center">
{t('checkout.payment_step.guided_cta_hint')}
</p>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -632,7 +754,7 @@ export const PaymentStep: React.FC = () => {
</p> </p>
<PaddleCta <PaddleCta
onCheckout={startPaddleCheckout} onCheckout={startPaddleCheckout}
disabled={status === 'processing'} disabled={status === 'processing' || !acceptedTerms || (requiresImmediateWaiver && !acceptedWaiver)}
isProcessing={status === 'processing'} isProcessing={status === 'processing'}
className={PRIMARY_CTA_STYLES} className={PRIMARY_CTA_STYLES}
/> />
@@ -664,6 +786,37 @@ export const PaymentStep: React.FC = () => {
</p> </p>
</div> </div>
</div> </div>
<Dialog open={showWithdrawalModal} onOpenChange={setShowWithdrawalModal}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>{withdrawalTitle || t('checkout.legal.link_cancellation')}</DialogTitle>
<DialogDescription>{t('checkout.legal.modal_description')}</DialogDescription>
</DialogHeader>
<div className="min-h-[200px]">
{withdrawalLoading && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<LoaderCircle className="h-4 w-4 animate-spin" />
<span>{t('checkout.legal.modal_loading')}</span>
</div>
)}
{withdrawalError && (
<div className="flex items-center gap-2 text-sm text-destructive">
<XCircle className="h-4 w-4" />
<span>{withdrawalError}</span>
</div>
)}
{!withdrawalLoading && !withdrawalError && withdrawalHtml && (
<ScrollArea className="max-h-[60vh] rounded-md border" viewportClassName="p-3">
<div
className="prose prose-sm dark:prose-invert"
dangerouslySetInnerHTML={{ __html: withdrawalHtml }}
/>
</ScrollArea>
)}
</div>
</DialogContent>
</Dialog>
</div> </div>
); );
}; };

View File

@@ -26,6 +26,7 @@ export interface CheckoutPackage {
limits?: Record<string, unknown>; limits?: Record<string, unknown>;
paddle_price_id?: string | null; paddle_price_id?: string | null;
paddle_product_id?: string | null; paddle_product_id?: string | null;
activates_immediately?: boolean;
[key: string]: unknown; [key: string]: unknown;
} }

View File

@@ -153,4 +153,48 @@ return [
'open' => 'Feedback im Super Admin öffnen', 'open' => 'Feedback im Super Admin öffnen',
'received_at' => 'Eingegangen: :date', 'received_at' => 'Eingegangen: :date',
], ],
'refund' => [
'subject' => 'Rückerstattung für :package',
'greeting' => 'Hallo :name,',
'body' => 'Wir haben eine Rückerstattung eingeleitet. Betrag: :amount :currency. Zahlungs-ID: :provider_id.',
'reason' => 'Grund: :reason',
'footer' => 'Die Rückerstattung wird vom Zahlungsanbieter verarbeitet und kann je nach Bank einige Tage dauern.',
],
'ops' => [
'purchase' => [
'subject' => 'Neuer Kauf: :package',
'greeting' => 'Hallo Ops-Team,',
'tenant' => 'Tenant: :tenant',
'package' => 'Paket: :package',
'amount' => 'Betrag: :amount :currency',
'provider' => 'Provider: :provider (ID: :id)',
'consents' => 'Consents Version: :legal, Terms: :terms, Waiver: :waiver',
'footer' => 'Bitte prüfen und ggf. verbuchen.',
],
'addon' => [
'subject' => 'Add-on gekauft: :addon',
'greeting' => 'Hallo Ops-Team,',
'tenant' => 'Tenant: :tenant',
'event' => 'Event: :event',
'addon' => 'Add-on: :addon (Menge: :quantity)',
'amount' => 'Betrag: :amount :currency',
'provider' => 'Checkout: :checkout, Transaction: :transaction',
'footer' => 'Add-on ist abgeschlossen und angewendet.',
],
'refund' => [
'subject' => 'Refund verarbeitet: :package',
'greeting' => 'Hallo Ops-Team,',
'tenant' => 'Tenant: :tenant',
'package' => 'Paket: :package',
'amount' => 'Betrag: :amount :currency',
'provider' => 'Provider: :provider (ID: :id)',
'status_success' => 'Status: Erfolgreich',
'status_failed' => 'Status: Fehlgeschlagen',
'reason' => 'Grund: :reason',
'error' => 'Fehler: :error',
'footer' => 'Bitte prüfen und ggf. manuell nachfassen.',
],
],
]; ];

View File

@@ -35,4 +35,5 @@ return [
'version' => 'Version :version', 'version' => 'Version :version',
'and' => 'und', 'and' => 'und',
'stripe_privacy' => 'Stripe Datenschutz', 'stripe_privacy' => 'Stripe Datenschutz',
'widerrufsbelehrung' => 'Widerrufsbelehrung',
]; ];

View File

@@ -100,6 +100,22 @@ return [
'company' => 'S.E.B. Fotografie', 'company' => 'S.E.B. Fotografie',
'rights_reserved' => 'Alle Rechte vorbehalten', 'rights_reserved' => 'Alle Rechte vorbehalten',
], ],
'checkout' => [
'headline' => 'Checkout & Bestellübersicht',
'summary_title' => 'Ihre Bestellung',
'package_label' => 'Ausgewähltes Package',
'billing_type_one_time' => 'Einmalkauf (pro Event)',
'billing_type_subscription' => 'Abo (wiederkehrend)',
'legal_links_intro' => 'Mit Abschluss des Kaufs akzeptieren Sie unsere',
'link_terms' => 'AGB',
'link_privacy' => 'Datenschutzerklärung',
'link_cancellation' => 'Widerrufsbelehrung',
'checkbox_terms_label' => 'Ich habe die :terms, die :privacy und die :cancellation gelesen und akzeptiere sie.',
'checkbox_terms_error' => 'Bitte bestätigen Sie, dass Sie AGB, Datenschutzerklärung und Widerrufsbelehrung gelesen haben.',
'checkbox_digital_content_label' => 'Ich verlange ausdrücklich, dass Sie vor Ablauf der Widerrufsfrist mit der Ausführung der digitalen Dienstleistungen (Freischaltung meines Event-Packages inkl. Galerie und Hosting) beginnen. Mir ist bekannt, dass ich bei vollständiger Vertragserfüllung mein Widerrufsrecht verliere.',
'checkbox_digital_content_error' => 'Bitte bestätigen Sie, dass Sie dem sofortigen Beginn der digitalen Dienstleistung und dem damit verbundenen vorzeitigen Erlöschen des Widerrufsrechts zustimmen.',
'hint_subscription_withdrawal' => 'Bei Abonnements haben Verbraucher ein 14-tägiges Widerrufsrecht ab Vertragsschluss. Im Falle eines Widerrufs nach Leistungsbeginn behalten wir uns angemessenen Wertersatz für bereits erbrachte Leistungen vor.',
],
'legal' => [ 'legal' => [
'imprint' => 'Impressum', 'imprint' => 'Impressum',
'privacy' => 'Datenschutz', 'privacy' => 'Datenschutz',

View File

@@ -153,4 +153,48 @@ return [
'open' => 'Open feedback in Super Admin', 'open' => 'Open feedback in Super Admin',
'received_at' => 'Received: :date', 'received_at' => 'Received: :date',
], ],
'refund' => [
'subject' => 'Refund for :package',
'greeting' => 'Hi :name,',
'body' => 'We have initiated a refund. Amount: :amount :currency. Payment ID: :provider_id.',
'reason' => 'Reason: :reason',
'footer' => 'The refund is processed by the payment provider and may take a few days depending on your bank.',
],
'ops' => [
'purchase' => [
'subject' => 'New purchase: :package',
'greeting' => 'Hello Ops team,',
'tenant' => 'Tenant: :tenant',
'package' => 'Package: :package',
'amount' => 'Amount: :amount :currency',
'provider' => 'Provider: :provider (ID: :id)',
'consents' => 'Consents Version: :legal, Terms: :terms, Waiver: :waiver',
'footer' => 'Please review and reconcile.',
],
'addon' => [
'subject' => 'Add-on purchased: :addon',
'greeting' => 'Hello Ops team,',
'tenant' => 'Tenant: :tenant',
'event' => 'Event: :event',
'addon' => 'Add-on: :addon (Quantity: :quantity)',
'amount' => 'Amount: :amount :currency',
'provider' => 'Checkout: :checkout, Transaction: :transaction',
'footer' => 'Add-on is completed and applied.',
],
'refund' => [
'subject' => 'Refund processed: :package',
'greeting' => 'Hello Ops team,',
'tenant' => 'Tenant: :tenant',
'package' => 'Package: :package',
'amount' => 'Amount: :amount :currency',
'provider' => 'Provider: :provider (ID: :id)',
'status_success' => 'Status: Success',
'status_failed' => 'Status: Failed',
'reason' => 'Reason: :reason',
'error' => 'Error: :error',
'footer' => 'Please review and follow up if needed.',
],
],
]; ];

View File

@@ -30,6 +30,7 @@ return [
'data_security' => 'Data Security', 'data_security' => 'Data Security',
'data_security_desc' => 'We use HTTPS, encrypted storage (passwords hashed) and regular backups. Access to data is role-based restricted (Tenant vs SuperAdmin).', 'data_security_desc' => 'We use HTTPS, encrypted storage (passwords hashed) and regular backups. Access to data is role-based restricted (Tenant vs SuperAdmin).',
'agb' => 'Terms & Conditions', 'agb' => 'Terms & Conditions',
'widerrufsbelehrung' => 'Right of Withdrawal',
'headline' => 'Legal', 'headline' => 'Legal',
'effective_from' => 'Effective from :date', 'effective_from' => 'Effective from :date',
'version' => 'Version :version', 'version' => 'Version :version',

View File

@@ -100,6 +100,22 @@ return [
'company' => 'S.E.B. Fotografie', 'company' => 'S.E.B. Fotografie',
'rights_reserved' => 'All rights reserved', 'rights_reserved' => 'All rights reserved',
], ],
'checkout' => [
'headline' => 'Checkout & Order Summary',
'summary_title' => 'Your order',
'package_label' => 'Selected package',
'billing_type_one_time' => 'One-time purchase (per event)',
'billing_type_subscription' => 'Subscription (recurring)',
'legal_links_intro' => 'By completing your order you accept our',
'link_terms' => 'Terms & Conditions',
'link_privacy' => 'Privacy Policy',
'link_cancellation' => 'Right of Withdrawal',
'checkbox_terms_label' => 'I have read and accept the :terms, :privacy and :cancellation.',
'checkbox_terms_error' => 'Please confirm that you have read and accepted the Terms, Privacy Policy and Right of Withdrawal.',
'checkbox_digital_content_label' => 'I expressly request that you begin providing the digital services (activation of my event package including gallery and hosting) before the withdrawal period has expired. I understand that I lose my right of withdrawal once the contract has been fully performed.',
'checkbox_digital_content_error' => 'Please confirm that you agree to the immediate start of the digital service and the related early expiry of the right of withdrawal.',
'hint_subscription_withdrawal' => 'For subscriptions, consumers have a 14-day right of withdrawal from the conclusion of the contract. In case of withdrawal after the start of the service, we reserve the right to claim appropriate compensation for the value of services already provided.',
],
'legal' => [ 'legal' => [
'imprint' => 'Imprint', 'imprint' => 'Imprint',
'privacy' => 'Privacy', 'privacy' => 'Privacy',

View File

@@ -7,6 +7,8 @@
<a href="{{ route('datenschutz') }}" class="transition-colors hover:text-[#FFB6C1]">{{ __('legal.datenschutz') }}</a> <a href="{{ route('datenschutz') }}" class="transition-colors hover:text-[#FFB6C1]">{{ __('legal.datenschutz') }}</a>
<span class="text-white/40">&bull;</span> <span class="text-white/40">&bull;</span>
<a href="{{ route('agb') }}" class="transition-colors hover:text-[#FFB6C1]">{{ __('legal.agb') }}</a> <a href="{{ route('agb') }}" class="transition-colors hover:text-[#FFB6C1]">{{ __('legal.agb') }}</a>
<span class="text-white/40">&bull;</span>
<a href="{{ route('widerrufsbelehrung') }}" class="transition-colors hover:text-[#FFB6C1]">{{ __('legal.widerrufsbelehrung') }}</a>
</div> </div>
</div> </div>
</footer> </footer>

View File

@@ -132,6 +132,13 @@ Route::prefix('{locale}')
->where('locale', 'en') ->where('locale', 'en')
->defaults('slug', 'agb') ->defaults('slug', 'agb')
->name('terms'); ->name('terms');
Route::get('/widerrufsbelehrung', [LegalPageController::class, 'show'])
->defaults('slug', 'widerrufsbelehrung')
->name('widerrufsbelehrung');
Route::get('/withdrawal', [LegalPageController::class, 'show'])
->where('locale', 'en')
->defaults('slug', 'widerrufsbelehrung')
->name('withdrawal');
Route::get('/buy/{packageId}', [MarketingController::class, 'buyPackages']) Route::get('/buy/{packageId}', [MarketingController::class, 'buyPackages'])
->name('buy.packages') ->name('buy.packages')

View File

@@ -62,6 +62,8 @@ class EventAddonCheckoutTest extends TenantTestCase
$response = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/addons/checkout", [ $response = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/addons/checkout", [
'addon_key' => 'extra_photos_small', 'addon_key' => 'extra_photos_small',
'quantity' => 2, 'quantity' => 2,
'accepted_terms' => true,
'accepted_waiver' => true,
]); ]);
$response->assertOk(); $response->assertOk();