Files
fotospiel-app/app/Http/Controllers/MarketingController.php
2026-01-06 08:36:55 +01:00

649 lines
23 KiB
PHP

<?php
namespace App\Http\Controllers;
use App\Mail\ContactConfirmation;
use App\Mail\ContactRequest;
use App\Models\BlogPost;
use App\Models\CheckoutSession;
use App\Models\Event;
use App\Models\Package;
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\CheckoutRequestContext;
use App\Support\CheckoutRoutes;
use App\Support\Concerns\PresentsPackages;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use League\CommonMark\Environment\Environment;
use League\CommonMark\Extension\Autolink\AutolinkExtension;
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
use League\CommonMark\Extension\Strikethrough\StrikethroughExtension;
use League\CommonMark\Extension\Table\TableExtension;
use League\CommonMark\Extension\TaskList\TaskListExtension;
use League\CommonMark\MarkdownConverter;
class MarketingController extends Controller
{
use PresentsPackages;
private ?MarkdownConverter $markdownConverter = null;
public function __construct(
private readonly CheckoutSessionService $checkoutSessions,
private readonly PaddleCheckoutService $paddleCheckout,
private readonly CouponService $coupons,
private readonly GiftVoucherCheckoutService $giftVouchers,
) {}
public function index()
{
$packages = Package::where('type', 'endcustomer')
->orderBy('price')
->get()
->map(fn (Package $package) => $this->presentPackage($package))
->values()
->all();
return Inertia::render('marketing/Home', compact('packages'));
}
public function contact(Request $request)
{
$request->validate([
'name' => 'required|string|max:255',
'email' => 'required|email|max:255',
'message' => 'required|string|max:1000',
'nickname' => 'present|size:0',
]);
$locale = app()->getLocale();
$contactAddress = config('mail.contact_address', config('mail.from.address'));
if (! $contactAddress) {
throw ValidationException::withMessages([
'email' => __('marketing.contact.error_recipient_missing', [], $locale) ?: 'Anfrage derzeit nicht möglich. Bitte später erneut versuchen.',
]);
}
try {
Mail::to($contactAddress)
->locale($locale)
->send(new ContactRequest(
name: $request->name,
email: $request->email,
messageBody: $request->message,
));
Mail::to($request->email)
->locale($locale)
->queue(new ContactConfirmation($request->name));
} catch (\Throwable $exception) {
Log::error('Contact form mail failed', [
'error' => $exception->getMessage(),
'code' => $exception->getCode(),
]);
throw ValidationException::withMessages([
'email' => __('marketing.contact.error_send_failed', [], $locale) ?: 'Nachricht konnte nicht gesendet werden. Bitte versuche es später erneut.',
]);
}
return redirect()
->back()
->with('success', __('marketing.contact.success', [], $locale));
}
public function contactView(Request $request)
{
$locale = \App\Support\LocaleConfig::canonicalize((string) ($request->route('locale') ?? app()->getLocale()));
$secondSegment = $request->segment(2);
$slug = $secondSegment ? '/'.trim((string) $secondSegment, '/') : '/';
if ($locale === 'en' && $slug === '/kontakt') {
return redirect()->route('marketing.contact', [
'locale' => $request->route('locale') ?? $locale,
], 301);
}
if ($locale === 'de' && $slug === '/contact') {
return redirect()->route('kontakt', [
'locale' => $request->route('locale') ?? $locale,
], 301);
}
return Inertia::render('marketing/Kontakt');
}
public function giftVouchers()
{
$tiers = $this->giftVouchers->tiers();
return Inertia::render('marketing/GiftVoucher', [
'tiers' => $tiers,
]);
}
/**
* Handle package purchase flow.
*/
public function buyPackages(Request $request, string $locale, $packageId)
{
Log::info('Buy packages called', ['auth' => Auth::check(), 'locale' => $locale, 'package_id' => $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);
if (! Auth::check()) {
return redirect()->to(CheckoutRoutes::wizardUrl($package->id, $locale))
->with('message', __('marketing.packages.register_required'));
}
$user = Auth::user();
if (! $user->email_verified_at) {
return redirect()->route('verification.notice')
->with('message', __('auth.verification_required'));
}
$tenant = $user->tenant;
if (! $tenant) {
abort(500, 'Tenant not found');
}
if ($package->price == 0) {
TenantPackage::updateOrCreate(
[
'tenant_id' => $tenant->id,
'package_id' => $package->id,
],
[
'price' => $package->price,
'active' => true,
'purchased_at' => now(),
'expires_at' => now()->addYear(),
]
);
PackagePurchase::create([
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'provider' => 'free',
'provider_id' => 'free',
'price' => $package->price,
'type' => $package->type === 'endcustomer' ? 'endcustomer_event' : 'reseller_subscription',
'purchased_at' => now(),
'refunded' => false,
]);
return redirect('/event-admin')->with('success', __('marketing.packages.free_assigned'));
}
if (! $package->paddle_price_id) {
Log::warning('Package missing Paddle price id', ['package_id' => $package->id]);
return redirect()->route('packages', [
'locale' => app()->getLocale(),
'highlight' => $package->slug,
])
->with('error', __('marketing.packages.paddle_not_configured'));
}
$session = $this->checkoutSessions->createOrResume($user, $package, array_merge(
CheckoutRequestContext::fromRequest($request),
[
'tenant' => $tenant,
]
));
$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;
if ($couponCode) {
try {
$preview = $this->coupons->preview($couponCode, $package, $tenant);
$this->checkoutSessions->applyCoupon($session, $preview['coupon'], $preview['pricing']);
$appliedDiscountId = $preview['coupon']->paddle_discount_id;
$request->session()->forget('marketing.checkout.coupon');
} catch (ValidationException $exception) {
$request->session()->flash('coupon_error', $exception->errors()['code'][0] ?? __('marketing.coupon.errors.generic'));
}
}
$checkout = $this->paddleCheckout->createCheckout($tenant, $package, [
'success_url' => route('marketing.success', [
'locale' => app()->getLocale(),
'packageId' => $package->id,
]),
'return_url' => route('packages', [
'locale' => app()->getLocale(),
'highlight' => $package->slug,
]),
'metadata' => [
'checkout_session_id' => $session->id,
'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,
]);
$session->forceFill([
'paddle_checkout_id' => $checkout['id'] ?? $session->paddle_checkout_id,
'provider_metadata' => array_merge($session->provider_metadata ?? [], array_filter([
'paddle_checkout_id' => $checkout['id'] ?? null,
'paddle_checkout_url' => $checkout['checkout_url'] ?? null,
'paddle_expires_at' => $checkout['expires_at'] ?? null,
])),
])->save();
$redirectUrl = $checkout['checkout_url'] ?? null;
if (! $redirectUrl) {
throw ValidationException::withMessages([
'paddle' => __('marketing.packages.paddle_checkout_failed'),
]);
}
return redirect()->away($redirectUrl);
}
public function success(Request $request, $packageId = null)
{
if (Auth::check() && Auth::user()->email_verified_at) {
return redirect('/event-admin')->with('success', __('marketing.success.welcome'));
}
return Inertia::render('marketing/Success', compact('packageId'));
}
protected function rememberCouponFromRequest(Request $request, Package $package): ?string
{
$input = Str::upper(trim((string) $request->input('coupon')));
if ($input !== '') {
$request->session()->put('marketing.checkout.coupon', [
'package_id' => $package->id,
'code' => $input,
]);
return $input;
}
if ($request->has('coupon')) {
$request->session()->forget('marketing.checkout.coupon');
return null;
}
$stored = $request->session()->get('marketing.checkout.coupon');
if ($stored && (int) ($stored['package_id'] ?? 0) === (int) $package->id) {
return $stored['code'] ?? null;
}
return null;
}
public function blogIndex(Request $request, string $locale)
{
$locale = $locale ?: app()->getLocale();
$query = BlogPost::query()
->with('author')
->whereHas('category', function ($query) {
$query->where('slug', 'blog');
});
$query->where('is_published', true)
->whereNotNull('published_at')
->where('published_at', '<=', now());
// Removed translation filter for now
$posts = $query->orderBy('published_at', 'desc')
->paginate(4)
->through(function (BlogPost $post) use ($locale) {
$excerpt = $post->getTranslation('excerpt', $locale) ?? $post->getTranslation('excerpt', 'de') ?? '';
return [
'id' => $post->id,
'slug' => $post->slug,
'title' => $post->getTranslation('title', $locale) ?? $post->getTranslation('title', 'de') ?? '',
'excerpt' => $excerpt,
'excerpt_html' => $this->convertMarkdownToHtml($excerpt),
'featured_image' => $post->featured_image ?? $post->banner_url ?? null,
'published_at' => optional($post->published_at)->toDateString(),
'author' => $post->author ? ['name' => $post->author->name] : null,
];
});
$postsArray = $posts->toArray();
$postsArray['links'] = array_map(function (array $link) use ($locale) {
return [
'url' => $link['url'],
'label' => $this->localizePaginationLabel($link['label'] ?? '', $locale),
'active' => (bool) ($link['active'] ?? false),
];
}, $postsArray['links'] ?? []);
return Inertia::render('marketing/Blog', [
'posts' => $postsArray,
]);
}
public function blogShow(string $locale, string $slug)
{
$locale = $locale ?: app()->getLocale();
$postModel = BlogPost::query()
->with('author')
->whereHas('category', function ($query) {
$query->where('slug', 'blog');
})
->where('slug', $slug)
->where('is_published', true)
->whereNotNull('published_at')
->where('published_at', '<=', now())
// Removed translation filter for now
->firstOrFail();
// Transform to array with translated strings for the current locale
$markdown = $postModel->getTranslation('content', $locale) ?? $postModel->getTranslation('content', 'de') ?? '';
$excerpt = $postModel->getTranslation('excerpt', $locale) ?? $postModel->getTranslation('excerpt', 'de') ?? '';
$contentHtml = $this->convertMarkdownToHtml($markdown);
[$contentHtmlWithIds, $headings] = $this->decorateHeadings($contentHtml);
$post = [
'id' => $postModel->id,
'title' => $postModel->getTranslation('title', $locale) ?? $postModel->getTranslation('title', 'de') ?? '',
'excerpt' => $excerpt,
'excerpt_html' => $this->convertMarkdownToHtml($excerpt),
'content' => $markdown,
'content_html' => $contentHtmlWithIds,
'headings' => $headings,
'featured_image' => $postModel->featured_image ?? $postModel->banner_url ?? null,
'published_at' => $postModel->published_at->toDateString(),
'slug' => $postModel->slug,
'url' => route('blog.show', ['locale' => $locale, 'slug' => $postModel->slug], absolute: true),
'author' => $postModel->author ? [
'name' => $postModel->author->name,
] : null,
'previous_post' => $this->presentAdjacentPost($postModel, $locale, direction: 'previous'),
'next_post' => $this->presentAdjacentPost($postModel, $locale, direction: 'next'),
];
return Inertia::render('marketing/BlogShow', compact('post'));
}
public function howItWorks()
{
return Inertia::render('marketing/HowItWorks');
}
public function demo()
{
$joinToken = optional(Event::firstWhere('slug', 'demo-wedding-2025'))
?->joinTokens()
->latest('id')
->first();
$demoToken = null;
if ($joinToken) {
if (! empty($joinToken->token_encrypted)) {
try {
$demoToken = Crypt::decryptString($joinToken->token_encrypted);
} catch (\Throwable $exception) {
Log::warning('Failed to decrypt demo join token', [
'token_id' => $joinToken->id,
'exception' => $exception->getMessage(),
]);
}
}
if (! $demoToken) {
$demoToken = $joinToken->metadata['plain_token'] ?? null;
}
}
return Inertia::render('marketing/Demo', [
'demoToken' => $demoToken,
]);
}
public function packagesIndex()
{
$endcustomerPackages = Package::where('type', 'endcustomer')
->orderBy('price')
->get()
->map(fn (Package $package) => $this->presentPackage($package))
->values()
->all();
$resellerPackages = Package::where('type', 'reseller')
->orderBy('price')
->get()
->map(fn (Package $package) => $this->presentPackage($package))
->values()
->all();
return Inertia::render('marketing/Packages', [
'endcustomerPackages' => $endcustomerPackages,
'resellerPackages' => $resellerPackages,
]);
}
public function occasionsType(Request $request, string $locale, string $type)
{
Log::info('OccasionsType hit', [
'type' => $type,
'locale' => $locale,
'url' => request()->fullUrl(),
'route' => request()->route()->getName(),
'isInertia' => request()->header('X-Inertia'),
]);
$normalized = strtolower($type);
$typeMap = [
'hochzeit' => 'hochzeit',
'wedding' => 'hochzeit',
'geburtstag' => 'geburtstag',
'birthday' => 'geburtstag',
'firmenevent' => 'firmenevent',
'corporate-event' => 'firmenevent',
'konfirmation' => 'konfirmation',
'confirmation' => 'konfirmation',
];
if (! array_key_exists($normalized, $typeMap)) {
Log::warning('Invalid occasion type accessed', ['type' => $type]);
abort(404, 'Invalid occasion type');
}
$baseSlug = $typeMap[$normalized];
$canonical = [
'hochzeit' => [
'de' => 'hochzeit',
'en' => 'wedding',
],
'geburtstag' => [
'de' => 'geburtstag',
'en' => 'birthday',
],
'firmenevent' => [
'de' => 'firmenevent',
'en' => 'corporate-event',
],
'konfirmation' => [
'de' => 'konfirmation',
'en' => 'confirmation',
],
];
$canonicalSlug = $canonical[$baseSlug][$locale] ?? $baseSlug;
$currentSlug = strtolower($type);
if ($currentSlug !== $canonicalSlug) {
$routeName = $locale === 'en' ? 'occasions.type' : 'anlaesse.type';
return redirect()->route($routeName, [
'locale' => $locale,
'type' => $canonicalSlug,
], 301);
}
return Inertia::render('marketing/Occasions', [
'type' => $baseSlug,
'requestedType' => $normalized,
]);
}
private function localizePaginationLabel(?string $label, string $locale): string
{
$decoded = trim(html_entity_decode(strip_tags($label ?? '')));
if ($decoded === '') {
return '';
}
if ($decoded === '...') {
return '…';
}
$normalized = Str::lower($decoded);
if (Str::contains($normalized, ['previous', 'vorherige', 'zurück'])) {
return __('marketing.blog.pagination.previous', [], $locale);
}
if (Str::contains($normalized, ['next', 'weiter', 'nächste'])) {
return __('marketing.blog.pagination.next', [], $locale);
}
return $decoded;
}
private function markdownConverter(): MarkdownConverter
{
if (! $this->markdownConverter instanceof MarkdownConverter) {
$environment = new Environment;
$environment->addExtension(new CommonMarkCoreExtension);
$environment->addExtension(new TableExtension);
$environment->addExtension(new AutolinkExtension);
$environment->addExtension(new StrikethroughExtension);
$environment->addExtension(new TaskListExtension);
$this->markdownConverter = new MarkdownConverter($environment);
}
return $this->markdownConverter;
}
private function convertMarkdownToHtml(?string $markdown): string
{
if ($markdown === null || trim((string) $markdown) === '') {
return '';
}
return (string) $this->markdownConverter()->convert($markdown);
}
private function decorateHeadings(string $html): array
{
$headings = [];
$usedSlugs = [];
$updatedHtml = preg_replace_callback('/<h([2-3])>(.*?)<\/h\1>/', function ($matches) use (&$headings, &$usedSlugs) {
$level = (int) $matches[1];
$text = trim(strip_tags($matches[2]));
$text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5);
if ($text === '') {
return $matches[0];
}
$baseSlug = Str::slug($text) ?: 'section';
$slug = $baseSlug;
$iterator = 1;
while (in_array($slug, $usedSlugs, true)) {
$slug = $baseSlug.'-'.(++$iterator);
}
$usedSlugs[] = $slug;
$headings[] = [
'text' => $text,
'slug' => $slug,
'level' => $level,
];
return sprintf('<h%s id="%s">%s</h%s>', $level, $slug, $matches[2], $level);
}, $html) ?: $html;
return [$updatedHtml, $headings];
}
private function presentAdjacentPost(BlogPost $current, string $locale, string $direction): ?array
{
$operator = $direction === 'previous' ? '<' : '>';
$orderDirection = $direction === 'previous' ? 'desc' : 'asc';
$neighbor = BlogPost::query()
->with('author')
->whereHas('category', function ($query) {
$query->where('slug', 'blog');
})
->where('is_published', true)
->whereNotNull('published_at')
->where('published_at', $operator, $current->published_at)
->orderBy('published_at', $orderDirection)
->first();
if (! $neighbor) {
return null;
}
$excerpt = $neighbor->getTranslation('excerpt', $locale) ?? $neighbor->getTranslation('excerpt', 'de') ?? '';
return [
'slug' => $neighbor->slug,
'title' => $neighbor->getTranslation('title', $locale) ?? $neighbor->getTranslation('title', 'de') ?? '',
'excerpt' => $excerpt,
'excerpt_html' => $this->convertMarkdownToHtml($excerpt),
];
}
protected function resolveLegalVersion(): string
{
return config('app.legal_version', now()->toDateString());
}
}