649 lines
23 KiB
PHP
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());
|
|
}
|
|
}
|