From d8f365ddd65cd41ae67e9c99afa41e8296041382 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Wed, 19 Nov 2025 13:12:35 +0100 Subject: [PATCH] =?UTF-8?q?admin=20widget=20zu=20dokploy=20geswitched,=20v?= =?UTF-8?q?iele=20=C3=BCbersetzungen=20im=20Frontend=20vervollst=C3=A4ndig?= =?UTF-8?q?t=20und=20Anl=C3=A4sse-Seiten=20mit=20ChatGPT=20ausgebaut?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 1 + __pycache__/translate.cpython-312.pyc | Bin 0 -> 519 bytes .../InfrastructureActionLogResource.php | 9 +- .../SuperAdmin/Pages/DokployDeployments.php | 52 +- .../Widgets/DokployPlatformHealth.php | 64 +- app/Http/Controllers/MarketingController.php | 159 +- app/Services/Dokploy/DokployClient.php | 131 +- config/dokploy.php | 1 + docs/deployment/dokploy.md | 36 +- public/lang/de/blog_show.json | 24 +- public/lang/de/marketing.json | 15 +- public/lang/en/blog_show.json | 24 +- public/lang/en/marketing.json | 13 +- resources/js/pages/marketing/Blog.tsx | 59 +- resources/js/pages/marketing/BlogShow.tsx | 374 +++- resources/js/pages/marketing/HowItWorks.tsx | 12 +- resources/js/pages/marketing/Occasions.tsx | 1710 ++++++++++++++++- resources/lang/de/marketing.json | 29 +- resources/lang/de/marketing.php | 6 +- resources/lang/en/how_it_works.json | 18 + resources/lang/en/marketing.json | 29 +- resources/lang/en/marketing.php | 6 +- resources/lang/en/marketing_new.json | 43 + .../pages/dokploy-deployments.blade.php | 22 +- .../widgets/dokploy-platform-health.blade.php | 66 +- resources/views/partials/header.blade.php | 17 +- tests/Feature/Marketing/BlogIndexTest.php | 66 + tests/Feature/Marketing/BlogShowTest.php | 101 + translate.py | 7 + translator.py | 19 + 30 files changed, 2820 insertions(+), 293 deletions(-) create mode 100644 __pycache__/translate.cpython-312.pyc create mode 100644 resources/lang/en/how_it_works.json create mode 100644 resources/lang/en/marketing_new.json create mode 100644 tests/Feature/Marketing/BlogIndexTest.php create mode 100644 tests/Feature/Marketing/BlogShowTest.php create mode 100644 translate.py create mode 100644 translator.py diff --git a/.env.example b/.env.example index 30f760f..0567a0c 100644 --- a/.env.example +++ b/.env.example @@ -137,6 +137,7 @@ DOKPLOY_API_KEY= DOKPLOY_WEB_URL= DOKPLOY_API_TIMEOUT=10 DOKPLOY_APPLICATION_IDS={"app":"app_xxx","queue":"app_queue","scheduler":"app_scheduler","ftp":"app_ftp"} +DOKPLOY_COMPOSE_IDS={"stack":"cmp_main","ftp":"cmp_ftp"} GUEST_ACHIEVEMENT_MILESTONES=10,25,50 diff --git a/__pycache__/translate.cpython-312.pyc b/__pycache__/translate.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d274f526b1fca354e9c5f67bcdd992b06a8b6858 GIT binary patch literal 519 zcmZ`zze~eF7`@AnCQXSV4#h&Tju|wILqQSz2Rb=rkzme}kS5`BwPq>lR5~dxI=V_7 z^q+8XDphJv1i`7B(5;hq+E7INhI{XQ-+SM8kGie_Ru}hW_m1HQ7g@=3GJRm=5;(wt zKCn0nK5Ph<009DCF0(Jt5!aZoBsTrtzL;?4>Ay8s{dvn<{(okv0zd+iZlyl_aanoR z(NNwK|E7?dxiW+xfrdQc)|vsSc7W|5_H7d43E#(B3JDK=JE$84D#l4n^JH#8m|vih zjq4{=<(8&hM^ui9;|5g1$ac8EZbT)5P6^erWQeI8VK*Q*V9GDg46_js^T=$qS~v`e zc^r~3j$Gt#+EHX?F|-vWGj+ESI!zz#;Z>I8e~fGF`4l09uRwbPmG{EZS+!R^-|y|D z>WiXvlTOlYciKaxI8w?3r94#DQ)#Rhuhl2rNVPGLQ*V?j4RWQc?P0F+3^wozTlobg C(shLZ literal 0 HcmV?d00001 diff --git a/app/Filament/Resources/InfrastructureActionLogs/InfrastructureActionLogResource.php b/app/Filament/Resources/InfrastructureActionLogs/InfrastructureActionLogResource.php index 117b9bf..5a212bf 100644 --- a/app/Filament/Resources/InfrastructureActionLogs/InfrastructureActionLogResource.php +++ b/app/Filament/Resources/InfrastructureActionLogs/InfrastructureActionLogResource.php @@ -34,16 +34,17 @@ class InfrastructureActionLogResource extends Resource ->sortable() ->searchable(), Tables\Columns\TextColumn::make('service_id') - ->label('Service') + ->label('Target') ->searchable() ->copyable() ->limit(30), Tables\Columns\BadgeColumn::make('action') ->label('Action') ->colors([ - 'warning' => 'restart', - 'info' => 'redeploy', - 'gray' => 'logs', + 'warning' => fn ($state) => in_array($state, ['compose.redeploy', 'redeploy'], true), + 'success' => fn ($state) => in_array($state, ['compose.deploy', 'deploy'], true), + 'danger' => fn ($state) => in_array($state, ['compose.stop', 'stop'], true), + 'gray' => fn ($state) => $state === 'logs', ]) ->sortable(), Tables\Columns\TextColumn::make('status_code') diff --git a/app/Filament/SuperAdmin/Pages/DokployDeployments.php b/app/Filament/SuperAdmin/Pages/DokployDeployments.php index 1d6c89a..4abdc3d 100644 --- a/app/Filament/SuperAdmin/Pages/DokployDeployments.php +++ b/app/Filament/SuperAdmin/Pages/DokployDeployments.php @@ -19,7 +19,7 @@ class DokployDeployments extends Page protected string $view = 'filament.super-admin.pages.dokploy-deployments'; - public array $applications = []; + public array $composes = []; public array $recentLogs = []; @@ -28,43 +28,45 @@ class DokployDeployments extends Page public function mount(DokployClient $client): void { $this->dokployWebUrl = config('dokploy.web_url'); - $this->refreshApplications($client); + $this->refreshComposes($client); $this->refreshLogs(); } - public function reload(string $applicationId): void + public function redeploy(string $composeId): void { - $this->performAction($applicationId, 'reload'); + $this->performAction($composeId, 'redeploy'); } - public function redeploy(string $applicationId): void + public function stop(string $composeId): void { - $this->performAction($applicationId, 'redeploy'); + $this->performAction($composeId, 'stop'); } - protected function performAction(string $applicationId, string $action): void + protected function performAction(string $composeId, string $action): void { $client = app(DokployClient::class); - if (! $this->isKnownApplication($applicationId)) { + if (! $this->isKnownCompose($composeId)) { Notification::make() ->danger() ->title('Unknown service') - ->body("The application ID {$applicationId} is not configured.") + ->body("The compose ID {$composeId} is not configured.") ->send(); return; } try { - $action === 'reload' - ? $client->reloadApplication($applicationId, auth()->user()) - : $client->redeployApplication($applicationId, auth()->user()); + match ($action) { + 'redeploy' => $client->redeployCompose($composeId, auth()->user()), + 'stop' => $client->stopCompose($composeId, auth()->user()), + default => throw new \RuntimeException("Unsupported action [{$action}]"), + }; Notification::make() ->success() ->title(ucfirst($action).' requested') - ->body("Dokploy accepted the {$action} action for {$applicationId}.") + ->body("Dokploy accepted the {$action} action for {$composeId}.") ->send(); } catch (\Throwable $exception) { Notification::make() @@ -74,35 +76,35 @@ class DokployDeployments extends Page ->send(); } - $this->refreshApplications($client); + $this->refreshComposes($client); $this->refreshLogs(); } - protected function refreshApplications(DokployClient $client): void + protected function refreshComposes(DokployClient $client): void { - $applicationMap = config('dokploy.applications', []); + $composeMap = config('dokploy.composes', []); $results = []; - foreach ($applicationMap as $label => $id) { + foreach ($composeMap as $label => $id) { try { - $status = $client->applicationStatus($id); - $application = Arr::get($status, 'application', []); + $status = $client->composeStatus($id); + $compose = Arr::get($status, 'compose', []); $results[] = [ 'label' => ucfirst($label), - 'application_id' => $id, - 'status' => Arr::get($application, 'applicationStatus', 'unknown'), + 'compose_id' => $id, + 'status' => Arr::get($compose, 'composeStatus', 'unknown'), ]; } catch (\Throwable $e) { $results[] = [ 'label' => ucfirst($label), - 'application_id' => $id, + 'compose_id' => $id, 'status' => 'error', ]; } } - $this->applications = $results; + $this->composes = $results; } protected function refreshLogs(): void @@ -122,8 +124,8 @@ class DokployDeployments extends Page ->toArray(); } - protected function isKnownApplication(string $applicationId): bool + protected function isKnownCompose(string $composeId): bool { - return in_array($applicationId, array_values(config('dokploy.applications', [])), true); + return in_array($composeId, array_values(config('dokploy.composes', [])), true); } } diff --git a/app/Filament/Widgets/DokployPlatformHealth.php b/app/Filament/Widgets/DokployPlatformHealth.php index 06ec3ac..b039ed3 100644 --- a/app/Filament/Widgets/DokployPlatformHealth.php +++ b/app/Filament/Widgets/DokployPlatformHealth.php @@ -15,49 +15,38 @@ class DokployPlatformHealth extends Widget protected function getViewData(): array { return [ - 'applications' => $this->loadApplications(), + 'composes' => $this->loadComposes(), ]; } - protected function loadApplications(): array + protected function loadComposes(): array { $client = app(DokployClient::class); - $applicationMap = config('dokploy.applications', []); + $composeMap = config('dokploy.composes', []); $results = []; - foreach ($applicationMap as $label => $applicationId) { + foreach ($composeMap as $label => $composeId) { try { - $status = $client->applicationStatus($applicationId); - $deployments = $client->recentDeployments($applicationId, 1); - $application = Arr::get($status, 'application', []); - $monitoring = Arr::get($status, 'monitoring', []); + $status = $client->composeStatus($composeId); + $deployments = $client->composeDeployments($composeId, 1); + $compose = Arr::get($status, 'compose', []); + $services = $this->formatServices(Arr::get($status, 'services', [])); $results[] = [ 'label' => ucfirst($label), - 'application_id' => $applicationId, - 'app_name' => Arr::get($application, 'appName') ?? Arr::get($application, 'name'), - 'status' => Arr::get($application, 'applicationStatus', 'unknown'), - 'cpu' => $this->extractMetric($monitoring, [ - 'metrics.cpuPercent', - 'metrics.cpu_percent', - 'cpuPercent', - 'cpu_percent', - ]), - 'memory' => $this->extractMetric($monitoring, [ - 'metrics.memoryPercent', - 'metrics.memory_percent', - 'memoryPercent', - 'memory_percent', - ]), + 'compose_id' => $composeId, + 'name' => Arr::get($compose, 'name') ?? Arr::get($compose, 'appName') ?? $composeId, + 'status' => Arr::get($compose, 'composeStatus', 'unknown'), + 'services' => $services, 'last_deploy' => Arr::get($deployments, '0.createdAt') ?? Arr::get($deployments, '0.created_at') - ?? Arr::get($application, 'updatedAt') - ?? Arr::get($application, 'lastDeploymentAt'), + ?? Arr::get($compose, 'updatedAt') + ?? Arr::get($compose, 'lastDeploymentAt'), ]; } catch (\Throwable $exception) { $results[] = [ 'label' => ucfirst($label), - 'application_id' => $applicationId, + 'compose_id' => $composeId, 'status' => 'unreachable', 'error' => $exception->getMessage(), ]; @@ -68,9 +57,9 @@ class DokployPlatformHealth extends Widget return [ [ 'label' => 'Dokploy', - 'application_id' => '-', + 'compose_id' => '-', 'status' => 'unconfigured', - 'error' => 'Set DOKPLOY_APPLICATION_IDS in .env to enable monitoring.', + 'error' => 'Set DOKPLOY_COMPOSE_IDS in .env to enable monitoring.', ], ]; } @@ -78,14 +67,17 @@ class DokployPlatformHealth extends Widget return $results; } - protected function extractMetric(array $source, array $candidates): mixed + protected function formatServices(array $services): array { - foreach ($candidates as $key) { - if (Arr::has($source, $key)) { - return Arr::get($source, $key); - } - } - - return null; + return collect($services) + ->map(function ($service) { + return [ + 'name' => Arr::get($service, 'serviceName') ?? Arr::get($service, 'name') ?? Arr::get($service, 'containerName'), + 'status' => Arr::get($service, 'status') ?? Arr::get($service, 'state') ?? Arr::get($service, 'composeStatus', 'unknown'), + ]; + }) + ->filter(fn ($service) => filled($service['name'])) + ->values() + ->all(); } } diff --git a/app/Http/Controllers/MarketingController.php b/app/Http/Controllers/MarketingController.php index bded5fa..c6519b1 100644 --- a/app/Http/Controllers/MarketingController.php +++ b/app/Http/Controllers/MarketingController.php @@ -4,8 +4,8 @@ namespace App\Http\Controllers; use App\Mail\ContactConfirmation; use App\Models\BlogPost; -use App\Models\Event; use App\Models\CheckoutSession; +use App\Models\Event; use App\Models\Package; use App\Models\PackagePurchase; use App\Models\TenantPackage; @@ -33,6 +33,8 @@ class MarketingController extends Controller { use PresentsPackages; + private ?MarkdownConverter $markdownConverter = null; + public function __construct( private readonly CheckoutSessionService $checkoutSessions, private readonly PaddleCheckoutService $paddleCheckout, @@ -292,11 +294,14 @@ class MarketingController extends Controller $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' => $post->getTranslation('excerpt', $locale) ?? $post->getTranslation('excerpt', '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, @@ -310,7 +315,18 @@ class MarketingController extends Controller 'first_post_title' => $posts->count() > 0 ? ($posts->first()['title'] ?? 'No title') : 'No posts', ]); - return Inertia::render('marketing/Blog', compact('posts')); + $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) @@ -330,29 +346,27 @@ class MarketingController extends Controller // Transform to array with translated strings for the current locale $markdown = $postModel->getTranslation('content', $locale) ?? $postModel->getTranslation('content', 'de') ?? ''; - - $environment = new Environment; - $environment->addExtension(new CommonMarkCoreExtension); - $environment->addExtension(new TableExtension); - $environment->addExtension(new AutolinkExtension); - $environment->addExtension(new StrikethroughExtension); - $environment->addExtension(new TaskListExtension); - - $converter = new MarkdownConverter($environment); - $contentHtml = (string) $converter->convert($markdown); + $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' => $postModel->getTranslation('excerpt', $locale) ?? $postModel->getTranslation('excerpt', 'de') ?? '', + 'excerpt' => $excerpt, + 'excerpt_html' => $this->convertMarkdownToHtml($excerpt), 'content' => $markdown, - 'content_html' => $contentHtml, + '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')); @@ -481,4 +495,119 @@ class MarketingController extends Controller '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\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('%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), + ]; + } } diff --git a/app/Services/Dokploy/DokployClient.php b/app/Services/Dokploy/DokployClient.php index dcdf657..88326e4 100644 --- a/app/Services/Dokploy/DokployClient.php +++ b/app/Services/Dokploy/DokployClient.php @@ -64,7 +64,7 @@ class DokployClient throw new \RuntimeException('Dokploy application name is required to reload the service.'); } - return $this->dispatchAction( + $response = $this->dispatchAction( $applicationId, 'reload', [ @@ -74,11 +74,15 @@ class DokployClient fn (array $payload) => $this->post('/application.reload', $payload), $actor, ); + + $this->forgetApplicationCaches($applicationId); + + return $response; } public function redeployApplication(string $applicationId, ?Authenticatable $actor = null): array { - return $this->dispatchAction( + $response = $this->dispatchAction( $applicationId, 'redeploy', [ @@ -87,6 +91,95 @@ class DokployClient fn (array $payload) => $this->post('/application.redeploy', $payload), $actor, ); + + $this->forgetApplicationCaches($applicationId); + + return $response; + } + + public function composeStatus(string $composeId): array + { + return $this->cached($this->composeCacheKey($composeId), function () use ($composeId) { + $compose = $this->get('/compose.one', [ + 'composeId' => $composeId, + ]); + + $services = $this->optionalGet('/compose.loadServices', [ + 'composeId' => $composeId, + 'type' => 'cache', + ]); + + return [ + 'compose' => $compose, + 'services' => $services, + ]; + }, 30); + } + + public function composeDeployments(string $composeId, int $limit = 5): array + { + return $this->cached($this->composeDeploymentsCacheKey($composeId), function () use ($composeId, $limit) { + $deployments = $this->get('/deployment.allByCompose', [ + 'composeId' => $composeId, + ]); + + if (! is_array($deployments)) { + return []; + } + + return array_slice($deployments, 0, $limit); + }, 60); + } + + public function redeployCompose(string $composeId, ?Authenticatable $actor = null): array + { + $response = $this->dispatchAction( + $composeId, + 'compose.redeploy', + [ + 'composeId' => $composeId, + ], + fn (array $payload) => $this->post('/compose.redeploy', $payload), + $actor, + ); + + $this->forgetComposeCaches($composeId); + + return $response; + } + + public function deployCompose(string $composeId, ?Authenticatable $actor = null): array + { + $response = $this->dispatchAction( + $composeId, + 'compose.deploy', + [ + 'composeId' => $composeId, + ], + fn (array $payload) => $this->post('/compose.deploy', $payload), + $actor, + ); + + $this->forgetComposeCaches($composeId); + + return $response; + } + + public function stopCompose(string $composeId, ?Authenticatable $actor = null): array + { + $response = $this->dispatchAction( + $composeId, + 'compose.stop', + [ + 'composeId' => $composeId, + ], + fn (array $payload) => $this->post('/compose.stop', $payload), + $actor, + ); + + $this->forgetComposeCaches($composeId); + + return $response; } protected function cached(string $key, callable $callback, int $seconds): mixed @@ -167,7 +260,7 @@ class DokployClient } protected function dispatchAction( - string $applicationId, + string $targetId, string $action, array $payload, callable $callback, @@ -178,22 +271,20 @@ class DokployClient $body = $response->json() ?? []; $status = $response->status(); } catch (\Throwable $exception) { - $this->logAction($applicationId, $action, $payload, [ + $this->logAction($targetId, $action, $payload, [ 'error' => $exception->getMessage(), ], null, $actor); throw $exception; } - $this->logAction($applicationId, $action, $payload, $body, $status, $actor); - Cache::forget($this->applicationCacheKey($applicationId)); - Cache::forget($this->deploymentCacheKey($applicationId)); + $this->logAction($targetId, $action, $payload, $body, $status, $actor); return $body; } protected function logAction( - string $applicationId, + string $targetId, string $action, array $payload, array $response, @@ -202,7 +293,7 @@ class DokployClient ): void { InfrastructureActionLog::create([ 'user_id' => $actor?->getAuthIdentifier() ?? auth()->id(), - 'service_id' => $applicationId, + 'service_id' => $targetId, 'action' => $action, 'payload' => $payload, 'response' => $response, @@ -219,4 +310,26 @@ class DokployClient { return "dokploy.deployments.{$applicationId}"; } + + protected function composeCacheKey(string $composeId): string + { + return "dokploy.compose.{$composeId}"; + } + + protected function composeDeploymentsCacheKey(string $composeId): string + { + return "dokploy.compose.deployments.{$composeId}"; + } + + protected function forgetApplicationCaches(string $applicationId): void + { + Cache::forget($this->applicationCacheKey($applicationId)); + Cache::forget($this->deploymentCacheKey($applicationId)); + } + + protected function forgetComposeCaches(string $composeId): void + { + Cache::forget($this->composeCacheKey($composeId)); + Cache::forget($this->composeDeploymentsCacheKey($composeId)); + } } diff --git a/config/dokploy.php b/config/dokploy.php index fd980d2..d716635 100644 --- a/config/dokploy.php +++ b/config/dokploy.php @@ -8,4 +8,5 @@ return [ ], 'web_url' => env('DOKPLOY_WEB_URL'), 'applications' => json_decode(env('DOKPLOY_APPLICATION_IDS', '{}'), true) ?? [], + 'composes' => json_decode(env('DOKPLOY_COMPOSE_IDS', '{}'), true) ?? [], ]; diff --git a/docs/deployment/dokploy.md b/docs/deployment/dokploy.md index ad079c7..7a0de45 100644 --- a/docs/deployment/dokploy.md +++ b/docs/deployment/dokploy.md @@ -38,12 +38,13 @@ Add the infrastructure observability variables to the Laravel app environment: DOKPLOY_API_BASE_URL=https://dokploy.example.com/api DOKPLOY_API_KEY=pat_xxxxxxxxxxxxxxxxx DOKPLOY_WEB_URL=https://dokploy.example.com -DOKPLOY_APPLICATION_IDS={"app":"app_123","queue":"app_456","scheduler":"app_789","ftp":"app_abc"} +DOKPLOY_COMPOSE_IDS={"stack":"cmp_main","ftp":"cmp_ftp"} DOKPLOY_API_TIMEOUT=10 ``` -- `DOKPLOY_APPLICATION_IDS` is a JSON object mapping human labels to Dokploy `applicationId` values. Those IDs drive the SuperAdmin widget buttons. -- The API key needs permission to read the project, query deployments, and trigger `application.redeploy` / `application.reload`. +- `DOKPLOY_COMPOSE_IDS` ist eine JSON-Map Label → `composeId` (siehe Compose-Detailseite in Dokploy). Diese IDs steuern Widget & Buttons. +- Optional kannst du weiterhin `DOKPLOY_APPLICATION_IDS` pflegen, falls du später einzelne Apps statt Compose-Stacks integrieren möchtest. +- Die API benötigt Rechte für `compose.one`, `compose.loadServices`, `compose.redeploy`, `compose.stop` etc. ## 3. Project & server setup @@ -77,7 +78,7 @@ Follow these steps for each component: - Optionally create a dedicated container for Horizon using `docs/queue-supervisor/horizon.sh`. 4. **vsftpd + Photobooth control** - - Deploy the ftp image (see `docker-compose` setup) or reuse Dokploy’s Docker Compose support. + - Nutze deinen bestehenden Docker-Compose-Stack (z. B. `docker-compose.dokploy.yml`) oder dedizierte Compose-Applikationen. - Mount `photobooth` volume read-write. 5. **Database/Redis** @@ -89,17 +90,16 @@ Follow these steps for each component: ## 5. SuperAdmin observability (Dokploy API) -The SuperAdmin dashboard now uses the Dokploy API to fetch health data and trigger actions: +Das SuperAdmin-Dashboard nutzt jetzt ausschließlich Compose-Endpunkte: -1. **Config file** – `config/dokploy.php` reads the environment variables above. -2. **Client** – `App\Services\Dokploy\DokployClient` wraps key endpoints: - - `GET /application.one?applicationId=...` → status + metadata. - - `GET /application.readAppMonitoring?appName=...` → CPU & memory metrics. - - `GET /deployment.all?applicationId=...` → latest deployments for history. - - `POST /application.reload` (requires `applicationId` + `appName`). - - `POST /application.redeploy` (redeploy latest commit). -3. **Widgets / pages** – `DokployPlatformHealth` widget displays the mapped applications, and the `DokployDeployments` page exposes reload/redeploy buttons plus a log table (`InfrastructureActionLog`). -4. **Auditing** – all actions persist to `infrastructure_action_logs` with user, payload, response, and status code. +1. **Config file** – `config/dokploy.php` liest `DOKPLOY_COMPOSE_IDS`. +2. **Client** – `App\Services\Dokploy\DokployClient` kapselt: + - `GET /compose.one?composeId=...` für Meta- und Statusinfos (deploying/error/done). + - `GET /compose.loadServices?composeId=...` für die einzelnen Services innerhalb des Stacks. + - `GET /deployment.allByCompose?composeId=...` für die Deploy-Historie. + - `POST /compose.redeploy`, `POST /compose.deploy`, `POST /compose.stop` (Buttons im UI). +3. **Widgets / Pages** – `DokployPlatformHealth` zeigt jeden Compose-Stack inkl. Services; die `DokployDeployments`-Seite bietet Redeploy/Stop + Audit-Log (`InfrastructureActionLog`). +4. **Auditing** – jede Aktion wird mit User, Payload, Response & HTTP-Code in `infrastructure_action_logs` festgehalten. Only SuperAdmins should have access to these widgets. If you rotate the API key, update the `.env` and deploy the app to refresh the cache. @@ -111,12 +111,12 @@ Only SuperAdmins should have access to these widgets. If you rotate the API key, ## 7. Production readiness checklist -1. All applications deployed in Dokploy with health checks and attached volumes. +1. Alle Compose-Stacks in Dokploy laufen mit Health Checks & Volumes. 2. `photobooth` volume mounted for Laravel + vsftpd + control service. 3. Database/Redis backups scheduled (Dokploy snapshot or external tooling). -4. `.env` contains the Dokploy API credentials and application ID mapping. -5. Scheduler, workers, and Horizon logging visible in Dokploy. -6. SuperAdmin widgets show green health states and allow reload/redeploy actions. +4. `.env` enthält die Dokploy-API-Credentials und `DOKPLOY_COMPOSE_IDS`. +5. Scheduler, Worker, Horizon werden im Compose-Stack überwacht. +6. SuperAdmin-Widget zeigt die Compose-Stacks und erlaubt Redeploy/Stop. 7. Webhooks/alerts configured for failed deployments or unhealthy containers. With this setup the Fotospiel team can manage deployments, restarts, and metrics centrally through Dokploy while Laravel’s scheduler and workers continue to run within the same infrastructure. diff --git a/public/lang/de/blog_show.json b/public/lang/de/blog_show.json index 19da4bb..d41d7ce 100644 --- a/public/lang/de/blog_show.json +++ b/public/lang/de/blog_show.json @@ -1,7 +1,25 @@ { "title_suffix": " - Fotospiel Blog", "by_author": "Von", - "team": "Team", + "team": "Fotospiel Team", "published_on": "Veröffentlicht am", - "back_to_blog": "Zurück zum Blog" -} \ No newline at end of file + "back_to_blog": "Zurück zum Blog", + "breadcrumb_home": "Start", + "breadcrumb_blog": "Blog", + "summary_title": "Wichtigste Erkenntnisse", + "toc_title": "In diesem Artikel", + "toc_empty": "Scrolle weiter, um die komplette Story zu lesen.", + "sidebar_author_title": "Über den Autor", + "sidebar_author_description": "Kuratiert vom Fotospiel Team.", + "share_title": "Story teilen", + "share_hint": "Mit einem Tipp verbreiten.", + "share_copy": "Link kopieren", + "share_copied": "Link kopiert!", + "share_native": "Gerät zum Teilen nutzen", + "share_whatsapp": "WhatsApp", + "share_linkedin": "LinkedIn", + "share_email": "E-Mail", + "previous_post": "Vorherige Story", + "next_post": "Nächste Story", + "read_story": "Story lesen" +} diff --git a/public/lang/de/marketing.json b/public/lang/de/marketing.json index 5cb3119..0a13fca 100644 --- a/public/lang/de/marketing.json +++ b/public/lang/de/marketing.json @@ -255,7 +255,7 @@ "hero_description": "Sammeln Sie unvergessliche Fotos von Ihren Gästen mit QR-Codes. Perfekt für :type – einfach, mobil und datenschutzkonform.", "cta": "Paket wählen", "weddings": { - "title": "Hochzeiten mit Fotospiel", + "title": "Hochzeiten mit der Fotospiel App", "description": "Fangen Sie romantische Momente ein: Gäste teilen Fotos via QR, wählen Emotionen wie 'Romantisch' oder 'Freudig'. Besser als traditionelle Fotoboxen.", "benefits_title": "Vorteile für Hochzeiten", "benefit1": "QR-Code für Gäste: Einfaches Teilen ohne App-Download.", @@ -373,6 +373,9 @@ "privacy": "Datenschutz", "impressum": "Impressum", "occasions_types": { + "weddings": "Hochzeiten", + "birthdays": "Geburtstage", + "corporate": "Firmenevents", "confirmation": "Konfirmation & Jugendweihe" }, "language": "Sprache", @@ -608,7 +611,7 @@ "hero": { "title": "So funktioniert die Fotospiel App", "subtitle": "Teile deinen QR-Code, sammle Fotos in Echtzeit und behalte die Moderation. Alles läuft im Browser – ganz ohne App.", - "primaryCta": "Event starten", + "primaryCta": "Pakete entdecken", "secondaryCta": "Kontakt aufnehmen", "stats": [ { @@ -870,6 +873,14 @@ "description": "Unser Team hilft dir bei der Einrichtung oder plant mit dir ein Pilot-Event.", "cta": "Kontakt aufnehmen" }, + "labels": { + "timeline_heading": "Ein klarer Fahrplan für euer Event", + "recommendations": "Empfehlungen", + "challenge_ideas": "Ideen für Challenges", + "prep_hint": "Alles, was ihr vor dem Event abhaken solltet.", + "good_to_know": "Gut zu wissen", + "tips": "Tipps" + }, "timeline_title": "Der Ablauf im Detail" }, "labels": { diff --git a/public/lang/en/blog_show.json b/public/lang/en/blog_show.json index d5c254e..11b3b7f 100644 --- a/public/lang/en/blog_show.json +++ b/public/lang/en/blog_show.json @@ -1,7 +1,25 @@ { "title_suffix": " - Fotospiel Blog", "by_author": "By", - "team": "Team", + "team": "Fotospiel Team", "published_on": "Published on", - "back_to_blog": "Back to Blog" -} \ No newline at end of file + "back_to_blog": "Back to Blog", + "breadcrumb_home": "Home", + "breadcrumb_blog": "Blog", + "summary_title": "Key takeaways", + "toc_title": "In this article", + "toc_empty": "Scroll to explore the full story.", + "sidebar_author_title": "About the author", + "sidebar_author_description": "Stories curated by the Fotospiel team.", + "share_title": "Share this story", + "share_hint": "Spread the word with one tap.", + "share_copy": "Copy link", + "share_copied": "Link copied!", + "share_native": "Share via device", + "share_whatsapp": "WhatsApp", + "share_linkedin": "LinkedIn", + "share_email": "Email", + "previous_post": "Previous story", + "next_post": "Next story", + "read_story": "Read story" +} diff --git a/public/lang/en/marketing.json b/public/lang/en/marketing.json index 92e0964..0c06654 100644 --- a/public/lang/en/marketing.json +++ b/public/lang/en/marketing.json @@ -359,6 +359,9 @@ "privacy": "Privacy", "impressum": "Imprint", "occasions_types": { + "weddings": "Weddings", + "birthdays": "Birthdays", + "corporate": "Corporate Events", "confirmation": "Confirmations" }, "language": "Language", @@ -602,7 +605,7 @@ "hero": { "title": "How the Fotospiel App Works", "subtitle": "Share your QR code, collect guest photos in real time, and stay in full control – all inside the browser.", - "primaryCta": "Create an event", + "primaryCta": "Discover our packages", "secondaryCta": "Talk to our team", "stats": [ { @@ -864,6 +867,14 @@ "description": "Our team is happy to set up a pilot event or walk you through the dashboard.", "cta": "Contact us" }, + "labels": { + "timeline_heading": "A clear roadmap for your event", + "recommendations": "Recommendations", + "challenge_ideas": "Challenge ideas", + "prep_hint": "Everything you should tick off before the event.", + "good_to_know": "Good to know", + "tips": "Tips" + }, "timeline_title": "The detailed flow" }, "labels": { diff --git a/resources/js/pages/marketing/Blog.tsx b/resources/js/pages/marketing/Blog.tsx index 91055e9..e721d92 100644 --- a/resources/js/pages/marketing/Blog.tsx +++ b/resources/js/pages/marketing/Blog.tsx @@ -14,6 +14,7 @@ interface PostSummary { slug: string; title: string; excerpt?: string; + excerpt_html?: string; featured_image?: string; published_at?: string; author?: { name?: string } | string; @@ -34,6 +35,18 @@ interface Props { }; } +const MarkdownPreview: React.FC<{ html?: string; fallback?: string; className?: string }> = ({ html, fallback, className }) => { + if (html && html.trim().length > 0) { + return
; + } + + if (!fallback) { + return null; + } + + return

{fallback}

; +}; + const Blog: React.FC = ({ posts }) => { const { localizedPath } = useLocalizedRoutes(); const { props } = usePage<{ supportedLocales?: string[] }>(); @@ -118,6 +131,7 @@ const Blog: React.FC = ({ posts }) => {
{posts.links.map((link, index) => { const href = resolvePaginationHref(link.url); + const labelText = link.label?.trim() || '…'; if (!href) { return ( @@ -126,8 +140,9 @@ const Blog: React.FC = ({ posts }) => { variant={link.active ? 'default' : 'outline'} disabled className={link.active ? 'bg-[#FFB6C1] hover:bg-[#FF69B4]' : ''} - dangerouslySetInnerHTML={{ __html: link.label }} - /> + > + {labelText} + ); } @@ -138,7 +153,9 @@ const Blog: React.FC = ({ posts }) => { variant={link.active ? 'default' : 'outline'} className={link.active ? 'bg-[#FFB6C1] hover:bg-[#FF69B4]' : ''} > - + + {labelText} + ); })} @@ -182,9 +199,11 @@ const Blog: React.FC = ({ posts }) => {

{featuredPost.title || 'Untitled'}

-

- {featuredPost.excerpt || ''} -

+ {renderPostMeta(featuredPost)}
{renderPostMeta(post)} @@ -261,7 +282,11 @@ const Blog: React.FC = ({ posts }) => {

{post.title || 'Untitled'}

-

{post.excerpt || ''}

+ {renderPostMeta(post)} -
diff --git a/resources/js/pages/marketing/BlogShow.tsx b/resources/js/pages/marketing/BlogShow.tsx index c506228..bc16608 100644 --- a/resources/js/pages/marketing/BlogShow.tsx +++ b/resources/js/pages/marketing/BlogShow.tsx @@ -1,112 +1,336 @@ -import React from 'react'; +import React from 'react'; import { Head, Link } from '@inertiajs/react'; import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes'; import { useTranslation } from 'react-i18next'; import MarketingLayout from '@/layouts/mainWebsite'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Card, CardContent } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Separator } from '@/components/ui/separator'; import { Button } from '@/components/ui/button'; +interface AdjacentPost { + slug: string; + title: string; + excerpt?: string; + excerpt_html?: string; +} + +interface HeadingItem { + text: string; + slug: string; + level: number; +} + interface Props { post: { id: number; title: string; excerpt?: string; + excerpt_html?: string; content: string; content_html: string; + headings?: HeadingItem[]; featured_image?: string; published_at: string; author?: { name: string }; slug: string; + url?: string; + previous_post?: AdjacentPost | null; + next_post?: AdjacentPost | null; }; } +const MarkdownPreview: React.FC<{ html?: string; fallback?: string; className?: string }> = ({ html, fallback, className }) => { + if (html && html.trim().length > 0) { + return
; + } + + if (!fallback) { + return null; + } + + return

{fallback}

; +}; + const BlogShow: React.FC = ({ post }) => { const { localizedPath } = useLocalizedRoutes(); - const { t } = useTranslation('blog_show'); + const { t, i18n } = useTranslation('blog_show'); + const [copied, setCopied] = React.useState(false); + const locale = i18n.language || 'de'; + const dateLocale = locale === 'en' ? 'en-US' : 'de-DE'; + + const formattedDate = React.useMemo(() => { + try { + return new Date(post.published_at).toLocaleDateString(dateLocale, { + day: 'numeric', + month: 'long', + year: 'numeric' + }); + } catch (error) { + console.warn('[Marketing BlogShow] Failed to format date', error); + return post.published_at; + } + }, [post.published_at, dateLocale]); + + const handleCopyLink = React.useCallback(() => { + if (typeof navigator === 'undefined' || !navigator.clipboard) { + return; + } + + navigator.clipboard.writeText(post.url || window.location.href).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }).catch((error) => { + console.warn('[Marketing BlogShow] Failed to copy link', error); + }); + }, [post.url]); + + const shareUrl = post.url || (typeof window !== 'undefined' ? window.location.href : ''); + const encodedShareUrl = encodeURIComponent(shareUrl); + const encodedTitle = encodeURIComponent(post.title); + + const shareLinks = [ + { + key: 'whatsapp', + label: t('share_whatsapp'), + href: `https://wa.me/?text=${encodedTitle}%20${encodedShareUrl}` + }, + { + key: 'linkedin', + label: t('share_linkedin'), + href: `https://www.linkedin.com/sharing/share-offsite/?url=${encodedShareUrl}` + }, + { + key: 'email', + label: t('share_email'), + href: `mailto:?subject=${encodedTitle}&body=${encodedShareUrl}` + } + ]; + + const canUseNativeShare = typeof navigator !== 'undefined' && typeof navigator.share === 'function'; + + const buildArticleHref = React.useCallback((slug?: string | null) => { + if (!slug) { + return '#'; + } + + return localizedPath(`/blog/${encodeURIComponent(slug)}`); + }, [localizedPath]); return ( - {/* Hero Section */} -
-
- - -

{post.title}

+
+
+
+ + + {t('back_to_blog')} + +
-
- - {t('by_author')} {post.author?.name || t('team')} - - - - {t('published_on')} {new Date(post.published_at).toLocaleDateString('de-DE', { - day: 'numeric', - month: 'long', - year: 'numeric' - })} - -
- - {post.featured_image && ( -
- {post.title} + +
+
+ + Fotospiel Stories + +

{post.title}

+
+ {t('by_author')} {post.author?.name || t('team')} + + {t('published_on')} {formattedDate} +
+
+
+ {post.featured_image ? ( +
+ {post.title} +
+ ) : ( +
+ Fotospiel Stories +
+ )} +
+
+
+ +
+
+ +
+
+
+
+
+ + +
+
+ + {(post.previous_post || post.next_post) && ( +
+
+
+ {post.previous_post && ( + + +

+ {t('previous_post')} +

+

{post.previous_post.title}

+ + +
+
)} - - -
-
- {/* Post Content */} -
-
- - -
- - -
-
- - {/* Back to Blog */} -
-
- - - - - - -
-
+ {post.next_post && ( + + +

+ {t('next_post')} +

+

{post.next_post.title}

+ + +
+
+ )} +
+
+ + )} ); }; diff --git a/resources/js/pages/marketing/HowItWorks.tsx b/resources/js/pages/marketing/HowItWorks.tsx index 885e8b7..0347457 100644 --- a/resources/js/pages/marketing/HowItWorks.tsx +++ b/resources/js/pages/marketing/HowItWorks.tsx @@ -103,7 +103,7 @@ const HowItWorks: React.FC = () => {

@@ -192,7 +192,7 @@ const HowItWorks: React.FC = () => { {t('how_it_works_page.timeline_title', 'Der Ablauf im Detail')}

- Ein klarer Fahrplan für dein Event + {t('how_it_works_page.labels.timeline_heading', 'Ein klarer Fahrplan für dein Event')}

@@ -211,7 +211,7 @@ const HowItWorks: React.FC = () => { {item.tips?.length ? (

- {t('marketing.actions.tips', 'Tipps')} + {t('how_it_works_page.labels.tips', 'Tipps')}

    {item.tips.map((tip) => ( @@ -271,7 +271,7 @@ const HowItWorks: React.FC = () => {

    - {t('marketing.labels.recommendations', 'Empfehlungen')} + {t('how_it_works_page.labels.recommendations', 'Empfehlungen')}

      {tab.recommendations.map((item) => ( @@ -284,7 +284,7 @@ const HowItWorks: React.FC = () => {

    - {t('marketing.labels.challengeIdeas', 'Ideen für Challenges')} + {t('how_it_works_page.labels.challenge_ideas', 'Ideen für Challenges')}

    {tab.ideas.map((idea) => ( @@ -308,7 +308,7 @@ const HowItWorks: React.FC = () => { {checklist.title} - {t('marketing.labels.prepHint', 'Alles, was du vor dem Event abhaken solltest.')} + {t('how_it_works_page.labels.prep_hint', 'Alles, was du vor dem Event abhaken solltest.')} diff --git a/resources/js/pages/marketing/Occasions.tsx b/resources/js/pages/marketing/Occasions.tsx index af855da..be17753 100644 --- a/resources/js/pages/marketing/Occasions.tsx +++ b/resources/js/pages/marketing/Occasions.tsx @@ -1,16 +1,22 @@ -import React from 'react'; +import React from 'react'; import { Head, Link } from '@inertiajs/react'; import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes'; import { useTranslation } from 'react-i18next'; import MarketingLayout from '@/layouts/mainWebsite'; +import { Card, CardContent } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Separator } from '@/components/ui/separator'; +import { Button } from '@/components/ui/button'; interface OccasionsProps { type: string; } const Occasions: React.FC = ({ type }) => { - const { t } = useTranslation('marketing'); + const { t, i18n } = useTranslation('marketing'); const { localizedPath } = useLocalizedRoutes(); + const locale = i18n.language || 'de'; + const isEn = locale.toLowerCase().startsWith('en'); const occasionsContent = { hochzeit: { @@ -57,36 +63,1686 @@ const Occasions: React.FC = ({ type }) => { ], cta: t('occasions.cta'), }, - }; + } as const; -const content = occasionsContent[type as keyof typeof occasionsContent] || occasionsContent.hochzeit; + const content = occasionsContent[type as keyof typeof occasionsContent] || occasionsContent.hochzeit; - return ( - - -
    -
    -
    -

    {content.title}

    -

    {content.description}

    - - {content.cta} - -
    -
    - {content.features.map((feature, index) => ( -
    -
    - {index + 1} + // Default, simple layout for unknown types + if (!['hochzeit', 'geburtstag', 'firmenevent', 'konfirmation'].includes(type)) { + return ( + + +
    +
    +
    + + + {t('nav.discover_packages')} + +
    + +
    +

    {content.title}

    +

    {content.description}

    + + {content.cta} + +
    + +
    + {content.features.map((feature, index) => ( +
    +
    + {index + 1} +
    +

    {feature}

    -

    {feature}

    -
    - ))} + ))} +
    -
    - - ); + + ); + } + + // Wedding (Hochzeit) + if (type === 'hochzeit') { + if (isEn) { + return ( + + + +
    +
    +
    + + + {t('nav.discover_packages')} + +
    + + + +
    +
    + + {t('occasions.weddings.title')} + +

    + Unposed, emotional wedding photos from your guests +

    +

    + The Fotospiel App turns your entire guest list into a creative photo team. Instead of stiff photo booth poses, + you get real moments – from tears of joy to dancefloor chaos – all collected in one moderated gallery. +

    +
      +
    • • Emotion-packed snapshots instead of staged photo booth pictures
    • +
    • • Guests become storytellers – no app store, no login required
    • +
    • • Everything in one GDPR-friendly gallery under your control
    • +
    +
    + + +
    +
    + +
    +
    +
    +
    + 📱 QR at the entrance + Guests scan and join +
    +
    + 🎯 Photo tasks + “First dance”, “Biggest laugh” +
    +
    + 📸 Live gallery + Your day in real time +
    +
    + 💾 Download afterwards + Perfect for your album +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +

    {t('occasions.weddings.benefits_title')}

    +

    + Picture your wedding as a collaborative photo adventure: guests capture the day from their own perspectives – + from getting ready to the last song on the dancefloor. +

    +
    + +
    + + +

    + Without the Fotospiel App +

    +
      +
    • • Photos disappear into countless chat groups
    • +
    • • Photo booth images stay in a corner
    • +
    • • Many lovely moments never make it into your album
    • +
    +
    +
    + + + +

    + With the Fotospiel App +

    +
      +
    • • Guests actively capture your celebration
    • +
    • • Tasks guide their view to your highlights
    • +
    • • You already see first favorite shots during the party
    • +
    +
    +
    +
    + +
    +

    Best practices for your wedding setup

    +
      +
    1. 1. Add the QR code to your invitation or wedding website.
    2. +
    3. 2. Explain the photo tasks clearly – for example on table cards or next to the guestbook.
    4. +
    5. 3. Announce a short “photo challenge” slot between dinner and party (e.g. 20–30 minutes).
    6. +
    7. 4. Show a small slideshow of the best guest photos in the evening.
    8. +
    9. 5. After the wedding, go through the gallery together and mark favorites for your album.
    10. +
    +
    +
    + + +
    + + + +
    + + +

    + What couples say +

    +

    + “We ended up with more than 800 guest photos – from little mishaps to the most emotional moments. Our + photographer loved how well the Fotospiel App complemented her work.” +

    +
    +
    + + + +

    + Next step +

    +

    + Try the Fotospiel App with a test event or set up your wedding gallery right away. You can start small and + upgrade later at any time. +

    +
    + + +
    +
    +
    +
    +
    +
    +
    + ); + } + + // German wedding content + return ( + + + +
    +
    +
    + + + {t('nav.discover_packages')} + +
    + + + +
    +
    + + {t('occasions.weddings.title')} + +

    + {t('occasions.weddings.hero_title', { defaultValue: content.title })} +

    +

    + {content.description} +

    +
      +
    • • Emotionale Momentaufnahmen statt gestellter Fotobox-Bilder
    • +
    • • Gäste werden zu Storyteller:innen – ohne App-Download
    • +
    • • Alles DSGVO-freundlich in einer gemeinsamen Galerie
    • +
    +
    + + +
    +
    + +
    +
    +
    +
    + 📱 QR-Code am Eingang + Gäste scannen & starten +
    +
    + 🎯 Fotoaufgaben + „Erster Tanz", „Lautestes Lachen" +
    +
    + 📸 Live-Galerie + Euer Tag in Echtzeit +
    +
    + 💾 Download danach + Perfekt fürs Album +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +

    {t('occasions.weddings.benefits_title')}

    +

    + Stell dir eure Hochzeitsfeier als gemeinsames Foto-Abenteuer vor: Gäst:innen werden zu Geschichtenerzähler:innen, + die euren Tag aus ganz eigenen Blickwinkeln festhalten – vom Getting Ready bis zum letzten Song. +

    +
    + +
    + + +

    + Ohne die Fotospiel App +

    +
      +
    • • Fotos versteckt in zig WhatsApp-Gruppen
    • +
    • • Fotobox-Bilder bleiben in der Ecke
    • +
    • • Viele tolle Momente gehen unter
    • +
    +
    +
    + + + +

    + Mit der Fotospiel App +

    +
      +
    • • Alle Gäste fotografieren aktiv mit
    • +
    • • Aufgaben leiten den Blick auf eure Highlights
    • +
    • • Ihr seht schon am Abend erste Lieblingsbilder
    • +
    +
    +
    +
    + +
    +

    Best Practices für euren Hochzeitseinsatz

    +
      +
    1. 1. QR-Code bereits in der Einladung oder auf eurer Hochzeitswebseite einbinden.
    2. +
    3. 2. Fotoaufgaben klar kommunizieren – z.B. als Karte am Platz oder am Gästebuch.
    4. +
    5. 3. Zwischen Essen und Party einen „Foto-Challenge"-Slot ankündigen (z.B. 20–30 Minuten).
    6. +
    7. 4. Abends eine kleine Slideshow aus den besten Gäste-Fotos zeigen.
    8. +
    9. 5. Nach der Hochzeit gemeinsam durch die Galerie gehen und Favoriten fürs Album markieren.
    10. +
    +
    +
    + + +
    + + + +
    + + +

    + Was Paare berichten +

    +

    + „Wir hatten über 800 Gästefotos – von kleinen Pannen bis zu den emotionalsten Momenten. Unsere Fotografin war + begeistert, wie gut die Fotospiel App ihre Arbeit ergänzt hat.“ +

    +
    +
    + + + +

    + Nächster Schritt +

    +

    + Testet die Fotospiel App mit einem Probe-Event oder legt direkt eure Hochzeits-Galerie an. Ihr könnt klein starten + und später jederzeit upgraden. +

    +
    + + +
    +
    +
    +
    +
    +
    +
    + ); + } + + // Birthday + if (type === 'geburtstag') { + if (isEn) { + return ( + + + +
    +
    +
    + + + {t('nav.discover_packages')} + +
    + + + +
    +
    + + {content.title} + +

    + More than just quick phone snaps +

    +

    + Whether it's a kids' birthday or a big milestone party: the Fotospiel App turns all your guests into part of + the photo challenge. Instead of scattered images in chat groups, everything ends up in a single moderated + gallery. +

    +
      +
    • • QR code on invitations, cake or decor – everyone can join instantly
    • +
    • • Playful photo tasks like "biggest smile" or "wildest cake moment"
    • +
    • • Gallery for parents and friends – without social-media overload
    • +
    +
    + + +
    +
    + +
    +
    +
    +
    + 🎂 QR on the cake + Scan and start +
    +
    + 🎉 Photo tasks + "Funniest face", "Party chaos" +
    +
    + 📱 Live gallery + For young and old +
    +
    + 💾 Memory album + Download after the party +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +

    How to turn your party into a photo challenge

    +

    + From kids' birthdays to adult parties: the Fotospiel App turns passive guests into active story hunters. Your + best moments end up safely in one gallery instead of disappearing in chat noise. +

    +
    + +
    + + +

    + Without the Fotospiel App +

    +
      +
    • • Photos scattered across phones and chats
    • +
    • • Parents miss many moments
    • +
    • • Hard to pick pictures for thank-you messages
    • +
    +
    +
    + + + +

    + With the Fotospiel App +

    +
      +
    • • All photos land in a single shared gallery
    • +
    • • Kids and adults enjoy playful challenges
    • +
    • • You end up with a colorful, complete memory album
    • +
    +
    +
    +
    + +
    +

    Best practices for birthdays

    +
      +
    1. 1. Place the QR code on invitations, cake or table stands.
    2. +
    3. 2. Announce photo tasks out loud – kids love clear challenges.
    4. +
    5. 3. Plan a short “photo session” slot (e.g. 15 minutes) in your schedule.
    6. +
    7. 4. Send the gallery link to all guests after the party.
    8. +
    +
    +
    + + +
    + + + +
    + + +

    + What hosts say +

    +

    + “The kids loved the challenges – and we ended up with all photos in one place instead of scrolling through + hundreds of chat messages.” +

    +
    +
    + + + +

    + Next step +

    +

    + Create your birthday event in the Fotospiel App now and test the flow with a few friends – your QR code will be + ready in minutes. +

    +
    + + +
    +
    +
    +
    +
    +
    +
    + ); + } + + // German version (existing content) + return ( + + + +
    +
    +
    + + + {t('nav.discover_packages')} + +
    + + + +
    +
    + + {content.title} + +

    + Mehr als nur Handy-Schnappschüsse +

    +

    + Ob Kindergeburtstag oder runde Party: Mit der Fotospiel App werden alle Gäste Teil der Foto-Challenge. + Statt verstreuter Bilder in Chat-Gruppen landet alles in einer gemeinsamen, moderierten Galerie. +

    +
      +
    • • QR-Code auf Einladung, Torte oder Deko – jeder kann sofort mitmachen
    • +
    • • Spielerische Fotoaufgaben wie „größtes Lächeln" oder „größter Kuchen-Fail"
    • +
    • • Galerie für Eltern & Freundeskreis – ohne Social-Media-Zwang
    • +
    +
    + + +
    +
    + +
    +
    +
    +
    + 🎂 QR auf der Torte + Scan & los geht's +
    +
    + 🎉 Foto-Aufgaben + „Lustigste Grimasse", „Party-Chaos" +
    +
    + 📱 Live-Galerie + Für Groß & Klein +
    +
    + 💾 Erinnerungsalbum + Download nach der Feier +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +

    So wird eure Party zur Foto-Challenge

    +

    + Egal ob Kindergeburtstag oder Erwachsenen-Party: Die Fotospiel App macht aus passiven Gästen aktive + Geschichtensucher:innen. Die besten Momente landen sicher in eurer Galerie statt im Chat-Chaos. +

    +
    + +
    + + +

    + Ohne die Fotospiel App +

    +
      +
    • • Fotos verteilt auf zig Handys und Chats
    • +
    • • Eltern sehen vieles gar nicht
    • +
    • • Schwierige Auswahl für Dankes-Nachrichten
    • +
    +
    +
    + + + +

    + Mit der Fotospiel App +

    +
      +
    • • Alle Fotos laufen in einer gemeinsamen Galerie ein
    • +
    • • Kids & Erwachsene haben Spaß an den Aufgaben
    • +
    • • Am Ende habt ihr ein buntes, vollständiges Erinnerungsalbum
    • +
    +
    +
    +
    + +
    +

    Best Practices für Geburtstage

    +
      +
    1. 1. QR-Code auf Einladung, Torte oder Tischaufstellern platzieren.
    2. +
    3. 2. Fotoaufgaben auch laut ankündigen – Kinder lieben klare Challenges.
    4. +
    5. 3. Eine kurze „Foto-Session" (z.B. 15 Minuten) in den Ablauf einplanen.
    6. +
    7. 4. Nach der Party einen Link zur Galerie an alle Gäste schicken.
    8. +
    +
    +
    + + +
    + + + +
    + + +

    + Was Gastgeber:innen berichten +

    +

    + „Die Kinder hatten riesigen Spaß an den Aufgaben – und wir hatten am Ende alle Fotos an einem Ort, statt + hunderte Chat-Nachrichten durchsuchen zu müssen." +

    +
    +
    + + + +

    + Nächster Schritt +

    +

    + Legt jetzt euer Geburtstags-Event in der Fotospiel App an und testet den Ablauf mit ein paar Freund:innen – der + QR-Code ist in wenigen Minuten startklar. +

    +
    + + +
    +
    +
    +
    +
    +
    +
    + ); + } + + // Corporate events + if (type === 'firmenevent') { + if (isEn) { + return ( + + + +
    +
    +
    + + + {t('nav.discover_packages')} + +
    + + + +
    +
    + + {content.title} + +

    + Event photos HR and marketing can actually use +

    +

    + The Fotospiel App collects all your event guests' photos in one place – with moderation, branding options + and export capabilities. Perfect for HR, marketing and employer branding. +

    +
      +
    • • Photo challenges tailored to your format (fair, offsite, team event)
    • +
    • • Moderated gallery instead of uncontrolled social posting
    • +
    • • Export for website, intranet and internal comms
    • +
    +
    + + +
    +
    + +
    +
    +
    +
    + 🏷️ QR codes on badges & signage + Join without app store +
    +
    + 🎯 Themed photo tasks + “Best team photo”, “Innovation in action” +
    +
    + 🛡️ Moderated gallery + Approval by the event team +
    +
    + 📊 Export & reporting + Material for HR & marketing +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +

    Why the Fotospiel App fits your company

    +

    + Instead of scattered snapshots on employees' private devices, you get a central, controlled event gallery. Ideal + for internal communication, social media and employer branding – always with data protection in mind. +

    +
    + +
    + + +

    + Without the Fotospiel App +

    +
      +
    • • Images live on private employee devices
    • +
    • • Clearing rights for individual photos is time-consuming
    • +
    • • Little overview of available motifs
    • +
    +
    +
    + + + +

    + With the Fotospiel App +

    +
      +
    • • Central gallery with approval workflow
    • +
    • • Quickly available visuals for internal and external channels
    • +
    • • Clarity on which images can be used
    • +
    +
    +
    +
    + +
    +

    Best practices for corporate events

    +
      +
    1. 1. Place QR codes visibly at registration, buffet and stage areas.
    2. +
    3. 2. Briefly explain photo challenges in the welcome or moderation.
    4. +
    5. 3. Assign someone on the event team to approve or hide photos.
    6. +
    7. 4. Share a curated selection internally and, if appropriate, externally after the event.
    8. +
    +
    +
    + + +
    + + + +
    + + +

    + What companies say +

    +

    + “After our offsite we finally had all event photos available in one place – HR and marketing were able to prepare + intranet and LinkedIn stories within the same week.” +

    +
    +
    + + + +

    + Next step +

    +

    + Talk to your event or HR team about how the Fotospiel App could support your next event – or test it directly with + a small internal format. +

    +
    + + +
    +
    +
    +
    +
    +
    +
    + ); + } + + // German corporate content + return ( + + + +
    +
    +
    + + + {t('nav.discover_packages')} + +
    + + + +
    +
    + + {content.title} + +

    + Event-Fotos, die Marketing & HR wirklich nutzen können +

    +

    + Die Fotospiel App sammelt alle Gäste-Fotos deines Firmenevents zentral – inklusive Moderation, + Branding-Optionen und Exportmöglichkeiten. Perfekt für HR, Marketing und Employer Branding. +

    +
      +
    • • Foto-Challenges passend zu deinem Format (Messe, Offsite, Team-Event)
    • +
    • • Moderierte Galerie statt unkontrolliertem Social-Media-Posting
    • +
    • • Export für Website, Intranet & interne Kommunikation
    • +
    +
    + + +
    +
    + +
    +
    +
    +
    + 🏷️ QR-Codes an Badge & Roll-Up + Teilnahme ohne App-Store +
    +
    + 🎯 Themen-Aufgaben + „Bestes Teamfoto", "Innovation in Aktion" +
    +
    + 🛡️ Moderierte Galerie + Freigabe durch Orga-Team +
    +
    + 📊 Export & Reporting + Material für HR & Marketing +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +

    Warum die Fotospiel App fürs Unternehmen passt

    +

    + Statt verstreuter Schnappschüsse auf privaten Smartphones erhält dein Unternehmen eine zentrale, + kontrollierte Event-Galerie. Ideal für interne Kommunikation, Social Media und Employer Branding – immer mit Blick + auf Datenschutz. +

    +
    + +
    + + +

    + Ohne die Fotospiel App +

    +
      +
    • • Bilder liegen auf privaten Geräten der Mitarbeitenden
    • +
    • • Rechteklärung für einzelne Fotos ist aufwendig
    • +
    • • Kaum Überblick, welche Motive existieren
    • +
    +
    +
    + + + +

    + Mit der Fotospiel App +

    +
      +
    • • Zentrale Galerie mit Freigabe-Workflow
    • +
    • • Schnell verfügbare Motive für interne & externe Kommunikation
    • +
    • • Klarheit, welche Bilder genutzt werden dürfen
    • +
    +
    +
    +
    + +
    +

    Best Practices für Firmenevents

    +
      +
    1. 1. QR-Codes sichtbar an Registrierung, Buffet und Bühnenbereich platzieren.
    2. +
    3. 2. Foto-Challenges in der Begrüßung oder Moderation kurz erklären.
    4. +
    5. 3. Verantwortliche Person im Orga-Team für die Freigabe der Fotos benennen.
    6. +
    7. 4. Nach dem Event eine kuratierte Auswahl intern und ggf. extern teilen.
    8. +
    +
    +
    + + +
    + + + +
    + + +

    + Was Unternehmen berichten +

    +

    + „Wir hatten nach unserem Offsite zum ersten Mal alle Event-Fotos zentral verfügbar – HR und Marketing konnten noch + in derselben Woche Storys fürs Intranet und LinkedIn vorbereiten." +

    +
    +
    + + + +

    + Nächster Schritt +

    +

    + Sprich mit deinem Event- oder HR-Team darüber, wie die Fotospiel App euer nächstes Event begleiten kann – oder + teste sie direkt mit einem kleinen internen Format. +

    +
    + + +
    +
    +
    +
    +
    +
    +
    + ); + } + + // Confirmation / religious family events + // Keep this one simpler but still richer than before + if (type === 'konfirmation') { + if (isEn) { + return ( + + + +
    +
    +
    + + + {t('nav.discover_packages')} + +
    + + + +
    + + {content.title} + +

    + Memories of an important step in life +

    +

    + Confirmation, first communion or a blessing service: the Fotospiel App collects the most important moments from + the perspective of family and friends – discreet, respectful and easy to moderate. +

    +
    + + +
    +
    +
    +
    +
    +
    + +
    +
    +
    + + +

    + What the Fotospiel App provides here +

    +
      +
    • • Discreet photo tasks focused on family and togetherness
    • +
    • • Gallery only for invited guests – no public social-media pressure
    • +
    • • Watching the photos together later strengthens the shared memory
    • +
    +
    +
    + + + +

    + Best practices in the family circle +

    +
      +
    1. 1. Place the QR code at the entrance and in the hall or community space.
    2. +
    3. 2. Choose photo tasks that are respectful and appropriate for the celebration.
    4. +
    5. 3. Nominate a family member to approve or hide photos.
    6. +
    +
    +
    +
    +
    +
    +
    + ); + } + + // German confirmation content + return ( + + + +
    +
    +
    + + + {t('nav.discover_packages')} + +
    + + + +
    + + {content.title} + +

    + Erinnerungen an einen besonderen Schritt im Leben +

    +

    + Konfirmation, Kommunion oder Segnungsfeier: Mit der Fotospiel App sammelt ihr die wichtigsten Momente aus Sicht der + Familie und Freund:innen – dezent, wertschätzend und gut moderierbar. +

    +
    + + +
    +
    +
    +
    +
    +
    + +
    +
    +
    + + +

    + Was die Fotospiel App hier leistet +

    +
      +
    • • Diskrete Fotoaufgaben, die den Fokus auf Familie und Gemeinschaft legen
    • +
    • • Galerie nur für eingeladene Personen – kein öffentlicher Social-Media-Druck
    • +
    • • Späteres gemeinsames Anschauen der Bilder stärkt das Erinnerungsgefühl
    • +
    +
    +
    + + + +

    + Best Practices im Familienkreis +

    +
      +
    1. 1. QR-Code am Eingang und im Kirchen-/Gemeindesaal platzieren.
    2. +
    3. 2. Fotoaufgaben wählen, die respektvoll und zur Feier passend sind.
    4. +
    5. 3. Eine Person in der Familie festlegen, die Fotos freigibt oder ausblendet.
    6. +
    +
    +
    +
    +
    +
    +
    + ); + } }; Occasions.layout = (page: React.ReactNode) => page; diff --git a/resources/lang/de/marketing.json b/resources/lang/de/marketing.json index bacdd02..c7f1360 100644 --- a/resources/lang/de/marketing.json +++ b/resources/lang/de/marketing.json @@ -141,7 +141,7 @@ "hero_description": "Sammeln Sie unvergessliche Fotos von Ihren Gästen mit QR-Codes. Perfekt für :type – einfach, mobil und datenschutzkonform.", "cta": "Paket wählen", "weddings": { - "title": "Hochzeiten mit Fotospiel", + "title": "Hochzeiten mit der Fotospiel App", "description": "Fangen Sie romantische Momente ein: Gäste teilen Fotos via QR, wählen Emotionen wie 'Romantisch' oder 'Freudig'. Besser als traditionelle Fotoboxen.", "benefits_title": "Vorteile für Hochzeiten", "benefit1": "QR-Code für Gäste: Einfaches Teilen ohne App-Download.", @@ -221,13 +221,38 @@ "title_suffix": " - Fotospiel Blog", "by_author": "Von", "published_on": "Veröffentlicht am", - "back_to_blog": "Zurück zum Blog" + "back_to_blog": "Zurück zum Blog", + "breadcrumb_home": "Start", + "breadcrumb_blog": "Blog", + "team": "Fotospiel Team", + "summary_title": "Wichtigste Erkenntnisse", + "toc_title": "In diesem Artikel", + "toc_empty": "Scrolle weiter, um die komplette Story zu lesen.", + "sidebar_author_title": "Über den Autor", + "sidebar_author_description": "Kuratiert vom Fotospiel Team.", + "share_title": "Story teilen", + "share_hint": "Mit einem Tipp verbreiten.", + "share_copy": "Link kopieren", + "share_copied": "Link kopiert!", + "share_native": "Gerät zum Teilen nutzen", + "share_whatsapp": "WhatsApp", + "share_linkedin": "LinkedIn", + "share_email": "E-Mail", + "previous_post": "Vorherige Story", + "next_post": "Nächste Story", + "read_story": "Story lesen" }, "nav": { "home": "Startseite", "how_it_works": "So funktioniert es", "features": "Features", "occasions": "Anlässe", + "occasions_types": { + "weddings": "Hochzeiten", + "birthdays": "Geburtstage", + "corporate": "Firmenevents", + "confirmation": "Konfirmation & Jugendweihe" + }, "blog": "Blog", "packages": "Pakete", "contact": "Kontakt", diff --git a/resources/lang/de/marketing.php b/resources/lang/de/marketing.php index 922aac7..055df77 100644 --- a/resources/lang/de/marketing.php +++ b/resources/lang/de/marketing.php @@ -117,6 +117,10 @@ return [ 'read_more' => 'Lesen', 'back' => 'Zurück zum Blog', 'empty' => 'Noch keine Posts verfügbar. Bleib dran!', + 'pagination' => [ + 'previous' => 'Zurück', + 'next' => 'Weiter', + ], ], 'occasions' => [ 'title' => 'Fotospiel für :type', @@ -124,7 +128,7 @@ return [ 'hero_description' => 'Sammle unvergessliche Fotos von deinen Gästen mit QR-Codes. Perfekt für :type – einfach, mobil und datenschutzkonform.', 'cta' => 'Package wählen', 'weddings' => [ - 'title' => 'Hochzeiten mit Fotospiel', + 'title' => 'Hochzeiten mit der Fotospiel App', 'description' => 'Erfange romantische Momente: Gäste teilen Fotos via QR, wähle Emotions wie \'Romantisch\' oder \'Fröhlich\'. Besser als traditionelle Fotoboxen.', 'benefits_title' => 'Vorteile für Hochzeiten', 'benefit1' => 'QR-Code für Gäste: Einfaches Teilen ohne App-Download.', diff --git a/resources/lang/en/how_it_works.json b/resources/lang/en/how_it_works.json new file mode 100644 index 0000000..e1f897c --- /dev/null +++ b/resources/lang/en/how_it_works.json @@ -0,0 +1,18 @@ +{ + "timeline_title": "The detailed flow", + "experience": { + "host": { + "label": "Hosts", + "intro": "Plan, moderate, and export your event memories from a single dashboard.", + "callouts_heading": "Good to know" + }, + "guest": { + "label": "Guests", + "intro": "Your guests simply scan, shoot, and share. No login, no download, no friction.", + "callouts_heading": "Good to know" + } + }, + "timeline": [ + { "title": "Prepare your event", "body": "...", "tips": [] } + ] +} diff --git a/resources/lang/en/marketing.json b/resources/lang/en/marketing.json index c25839a..5960cc8 100644 --- a/resources/lang/en/marketing.json +++ b/resources/lang/en/marketing.json @@ -141,7 +141,7 @@ "hero_description": "Collect unforgettable photos from your guests with QR-Codes. Perfect for :type – simple, mobile and privacy-compliant.", "cta": "Choose Package", "weddings": { - "title": "Weddings with Fotospiel", + "title": "Weddings with the Fotospiel App", "description": "Capture romantic moments: Guests share photos via QR, choose emotions like 'Romantic' or 'Joyful'. Better than traditional photo booths.", "benefits_title": "Benefits for Weddings", "benefit1": "QR-Code for Guests: Easy sharing without app download.", @@ -221,13 +221,38 @@ "title_suffix": " - Fotospiel Blog", "by_author": "By", "published_on": "Published on", - "back_to_blog": "Back to Blog" + "back_to_blog": "Back to Blog", + "breadcrumb_home": "Home", + "breadcrumb_blog": "Blog", + "team": "Fotospiel Team", + "summary_title": "Key takeaways", + "toc_title": "In this article", + "toc_empty": "Scroll to explore the full story.", + "sidebar_author_title": "About the author", + "sidebar_author_description": "Stories curated by the Fotospiel team.", + "share_title": "Share this story", + "share_hint": "Spread the word with one tap.", + "share_copy": "Copy link", + "share_copied": "Link copied!", + "share_native": "Share via device", + "share_whatsapp": "WhatsApp", + "share_linkedin": "LinkedIn", + "share_email": "Email", + "previous_post": "Previous story", + "next_post": "Next story", + "read_story": "Read story" }, "nav": { "home": "Home", "how_it_works": "How it works", "features": "Features", "occasions": "Occasions", + "occasions_types": { + "weddings": "Weddings", + "birthdays": "Birthdays", + "corporate": "Corporate Events", + "confirmation": "Confirmations" + }, "blog": "Blog", "packages": "Packages", "contact": "Contact", diff --git a/resources/lang/en/marketing.php b/resources/lang/en/marketing.php index 5c29443..b75e14c 100644 --- a/resources/lang/en/marketing.php +++ b/resources/lang/en/marketing.php @@ -117,6 +117,10 @@ return [ 'read_more' => 'Read', 'back' => 'Back to Blog', 'empty' => 'No posts available yet. Stay tuned!', + 'pagination' => [ + 'previous' => 'Previous', + 'next' => 'Next', + ], ], 'occasions' => [ 'title' => 'Fotospiel for :type', @@ -124,7 +128,7 @@ return [ 'hero_description' => 'Collect unforgettable photos from your guests with QR-Codes. Perfect for :type – simple, mobile and privacy-compliant.', 'cta' => 'Choose Package', 'weddings' => [ - 'title' => 'Weddings with Fotospiel', + 'title' => 'Weddings with the Fotospiel App', 'description' => 'Capture romantic moments: Guests share photos via QR, choose emotions like \'Romantic\' or \'Joyful\'. Better than traditional photo booths.', 'benefits_title' => 'Benefits for Weddings', 'benefit1' => 'QR-Code for Guests: Easy sharing without app download.', diff --git a/resources/lang/en/marketing_new.json b/resources/lang/en/marketing_new.json new file mode 100644 index 0000000..15db792 --- /dev/null +++ b/resources/lang/en/marketing_new.json @@ -0,0 +1,43 @@ +{ + "home": { + "title": "Home - Fotospiel", + "hero_title": "Your event. Their photos.", + "hero_description": "The Fotospiel App combines QR access, live galleries, and moderation in one platform—perfect for weddings, corporate events, and every celebration that deserves a highlight reel.", + "cta_explore": "Discover Packages", + "cta_explore_highlight": "Start your Fotospiel trial", + "hero_image_alt": "Guests sharing photos via QR code", + "how_title": "How the Fotospiel App works", + "step1_title": "Create event & pick a package", + "step1_desc": "Set limits for photos, guests, and branding in just a few clicks.", + "step2_title": "Share QR link & access code", + "step2_desc": "Guests scan the QR code or type your access code to start uploading instantly—no app store needed.", + "step3_title": "Moderate live & spotlight favorites", + "step3_desc": "Approve posts, trigger slideshows, and export highlight galleries on demand.", + "features_title": "Why the Fotospiel App?", + "feature1_title": "Secure & Privacy Compliant", + "feature1_desc": "GDPR compliant, no PII storage.", + "feature2_title": "Mobile & PWA", + "feature2_desc": "Works offline, installable like an app.", + "feature3_title": "Easy to Use", + "feature3_desc": "Intuitive UI for guests and organizers.", + "packages_title": "Packages & pricing", + "view_details": "View Details", + "all_packages": "View All Packages", + "contact_title": "Let's plan your event", + "contact_lead": "We’ll guide you through moderation, QR touchpoints, and the perfect Fotospiel App setup.", + "name_label": "Name", + "email_label": "Email", + "message_label": "Message", + "sending": "Sending...", + "send": "Send", + "testimonials_title": "Voices from the community", + "testimonials_subtitle": "Over 1,200 events have already run on the Fotospiel App.", + "testimonial1": "Our guests documented the day for us—and everything landed in one secure archive.", + "testimonial2": "Branding, moderation, analytics—all right where I need them during an event.", + "testimonial3": "Confirmation without messaging chaos. QR out, emojis in, photos for everyone.", + "faq_title": "Still curious?", + "faq1_q": "Can I try the Fotospiel App first?", + "faq1_a": "Absolutely! Use our demo event or pick the Free package to explore all core features.", + "faq2_q": "Do guests need an account?", + "faq2_a": "No. A personal access code is enough, and you can add an optional PIN for extra gallery protection." } +} diff --git a/resources/views/filament/super-admin/pages/dokploy-deployments.blade.php b/resources/views/filament/super-admin/pages/dokploy-deployments.blade.php index 78a8788..40550a7 100644 --- a/resources/views/filament/super-admin/pages/dokploy-deployments.blade.php +++ b/resources/views/filament/super-admin/pages/dokploy-deployments.blade.php @@ -1,28 +1,28 @@
    - +
    - @foreach($applications as $application) + @foreach($composes as $compose)
    -

    {{ $application['label'] }}

    -

    {{ $application['application_id'] }}

    +

    {{ $compose['label'] }}

    +

    {{ $compose['compose_id'] }}

    - {{ ucfirst($application['status'] ?? 'unknown') }} + {{ ucfirst($compose['status'] ?? 'unknown') }}
    - - Reload - - + Redeploy + + Stop + @if($dokployWebUrl) - + Open in Dokploy @endif @@ -39,7 +39,7 @@ When User - Application + Target Action Status diff --git a/resources/views/filament/widgets/dokploy-platform-health.blade.php b/resources/views/filament/widgets/dokploy-platform-health.blade.php index d044fe4..3a66c93 100644 --- a/resources/views/filament/widgets/dokploy-platform-health.blade.php +++ b/resources/views/filament/widgets/dokploy-platform-health.blade.php @@ -1,52 +1,54 @@
    - @forelse($applications as $application) + @forelse($composes as $compose)
    -

    {{ $application['label'] }}

    -

    {{ $application['app_name'] ?? $application['application_id'] }}

    -

    {{ $application['application_id'] }}

    +

    {{ $compose['label'] }}

    +

    {{ $compose['name'] }}

    +

    {{ $compose['compose_id'] }}

    $application['status'] === 'running', - 'bg-amber-100 text-amber-800' => in_array($application['status'], ['deploying', 'idle']), - 'bg-rose-100 text-rose-800' => in_array($application['status'], ['unreachable', 'error']), - 'bg-slate-100 text-slate-600' => ! in_array($application['status'], ['running', 'deploying', 'idle', 'unreachable', 'error']), + 'bg-emerald-100 text-emerald-800' => $compose['status'] === 'done', + 'bg-amber-100 text-amber-800' => in_array($compose['status'], ['deploying', 'pending']), + 'bg-rose-100 text-rose-800' => in_array($compose['status'], ['unreachable', 'error', 'failed']), + 'bg-slate-100 text-slate-600' => ! in_array($compose['status'], ['done', 'deploying', 'pending', 'unreachable', 'error', 'failed']), ])> - {{ ucfirst($application['status']) }} + {{ ucfirst($compose['status']) }}
    - @if(isset($application['error'])) -

    {{ $application['error'] }}

    + @if(isset($compose['error'])) +

    {{ $compose['error'] }}

    @else -
    -
    -
    CPU
    -
    - {{ isset($application['cpu']) ? $application['cpu'].'%' : '—' }} -
    -
    -
    -
    Memory
    -
    - {{ isset($application['memory']) ? $application['memory'].'%' : '—' }} -
    -
    -
    -
    Last Deploy
    -
    - {{ $application['last_deploy'] ? \Illuminate\Support\Carbon::parse($application['last_deploy'])->diffForHumans() : '—' }} -
    -
    -
    +
    +

    Services

    + @forelse($compose['services'] as $service) +
    + {{ $service['name'] }} + in_array($service['status'], ['running', 'done']), + 'bg-amber-200/70 text-amber-900' => in_array($service['status'], ['starting', 'deploying']), + 'bg-rose-200/70 text-rose-900' => in_array($service['status'], ['error', 'failed', 'unhealthy']), + 'bg-slate-200/70 text-slate-900' => ! in_array($service['status'], ['running', 'done', 'starting', 'deploying', 'error', 'failed', 'unhealthy']), + ])> + {{ strtoupper($service['status'] ?? 'N/A') }} + +
    + @empty +

    No services reported.

    + @endforelse +
    +

    + Last deploy: {{ $compose['last_deploy'] ? \Illuminate\Support\Carbon::parse($compose['last_deploy'])->diffForHumans() : '—' }} +

    @endif
    @empty -

    No Dokploy applications configured.

    +

    No Dokploy compose stacks configured.

    @endforelse
    diff --git a/resources/views/partials/header.blade.php b/resources/views/partials/header.blade.php index 2d5acce..7a2a26d 100644 --- a/resources/views/partials/header.blade.php +++ b/resources/views/partials/header.blade.php @@ -7,15 +7,26 @@
    + @php + $currentLocale = app()->getLocale(); + $occasionRouteName = $currentLocale === 'en' ? 'occasions.type' : 'anlaesse.type'; + $occasionSlugs = [ + 'hochzeit' => $currentLocale === 'en' ? 'wedding' : 'hochzeit', + 'geburtstag' => $currentLocale === 'en' ? 'birthday' : 'geburtstag', + 'firmenevent' => $currentLocale === 'en' ? 'corporate-event' : 'firmenevent', + 'konfirmation' => $currentLocale === 'en' ? 'confirmation' : 'konfirmation', + ]; + @endphp