admin widget zu dokploy geswitched, viele übersetzungen im Frontend vervollständigt und Anlässe-Seiten mit ChatGPT ausgebaut

This commit is contained in:
Codex Agent
2025-11-19 13:12:35 +01:00
parent 125c624588
commit d8f365ddd6
30 changed files with 2820 additions and 293 deletions

View File

@@ -137,6 +137,7 @@ DOKPLOY_API_KEY=
DOKPLOY_WEB_URL= DOKPLOY_WEB_URL=
DOKPLOY_API_TIMEOUT=10 DOKPLOY_API_TIMEOUT=10
DOKPLOY_APPLICATION_IDS={"app":"app_xxx","queue":"app_queue","scheduler":"app_scheduler","ftp":"app_ftp"} 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 GUEST_ACHIEVEMENT_MILESTONES=10,25,50

Binary file not shown.

View File

@@ -34,16 +34,17 @@ class InfrastructureActionLogResource extends Resource
->sortable() ->sortable()
->searchable(), ->searchable(),
Tables\Columns\TextColumn::make('service_id') Tables\Columns\TextColumn::make('service_id')
->label('Service') ->label('Target')
->searchable() ->searchable()
->copyable() ->copyable()
->limit(30), ->limit(30),
Tables\Columns\BadgeColumn::make('action') Tables\Columns\BadgeColumn::make('action')
->label('Action') ->label('Action')
->colors([ ->colors([
'warning' => 'restart', 'warning' => fn ($state) => in_array($state, ['compose.redeploy', 'redeploy'], true),
'info' => 'redeploy', 'success' => fn ($state) => in_array($state, ['compose.deploy', 'deploy'], true),
'gray' => 'logs', 'danger' => fn ($state) => in_array($state, ['compose.stop', 'stop'], true),
'gray' => fn ($state) => $state === 'logs',
]) ])
->sortable(), ->sortable(),
Tables\Columns\TextColumn::make('status_code') Tables\Columns\TextColumn::make('status_code')

View File

@@ -19,7 +19,7 @@ class DokployDeployments extends Page
protected string $view = 'filament.super-admin.pages.dokploy-deployments'; protected string $view = 'filament.super-admin.pages.dokploy-deployments';
public array $applications = []; public array $composes = [];
public array $recentLogs = []; public array $recentLogs = [];
@@ -28,43 +28,45 @@ class DokployDeployments extends Page
public function mount(DokployClient $client): void public function mount(DokployClient $client): void
{ {
$this->dokployWebUrl = config('dokploy.web_url'); $this->dokployWebUrl = config('dokploy.web_url');
$this->refreshApplications($client); $this->refreshComposes($client);
$this->refreshLogs(); $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); $client = app(DokployClient::class);
if (! $this->isKnownApplication($applicationId)) { if (! $this->isKnownCompose($composeId)) {
Notification::make() Notification::make()
->danger() ->danger()
->title('Unknown service') ->title('Unknown service')
->body("The application ID {$applicationId} is not configured.") ->body("The compose ID {$composeId} is not configured.")
->send(); ->send();
return; return;
} }
try { try {
$action === 'reload' match ($action) {
? $client->reloadApplication($applicationId, auth()->user()) 'redeploy' => $client->redeployCompose($composeId, auth()->user()),
: $client->redeployApplication($applicationId, auth()->user()); 'stop' => $client->stopCompose($composeId, auth()->user()),
default => throw new \RuntimeException("Unsupported action [{$action}]"),
};
Notification::make() Notification::make()
->success() ->success()
->title(ucfirst($action).' requested') ->title(ucfirst($action).' requested')
->body("Dokploy accepted the {$action} action for {$applicationId}.") ->body("Dokploy accepted the {$action} action for {$composeId}.")
->send(); ->send();
} catch (\Throwable $exception) { } catch (\Throwable $exception) {
Notification::make() Notification::make()
@@ -74,35 +76,35 @@ class DokployDeployments extends Page
->send(); ->send();
} }
$this->refreshApplications($client); $this->refreshComposes($client);
$this->refreshLogs(); $this->refreshLogs();
} }
protected function refreshApplications(DokployClient $client): void protected function refreshComposes(DokployClient $client): void
{ {
$applicationMap = config('dokploy.applications', []); $composeMap = config('dokploy.composes', []);
$results = []; $results = [];
foreach ($applicationMap as $label => $id) { foreach ($composeMap as $label => $id) {
try { try {
$status = $client->applicationStatus($id); $status = $client->composeStatus($id);
$application = Arr::get($status, 'application', []); $compose = Arr::get($status, 'compose', []);
$results[] = [ $results[] = [
'label' => ucfirst($label), 'label' => ucfirst($label),
'application_id' => $id, 'compose_id' => $id,
'status' => Arr::get($application, 'applicationStatus', 'unknown'), 'status' => Arr::get($compose, 'composeStatus', 'unknown'),
]; ];
} catch (\Throwable $e) { } catch (\Throwable $e) {
$results[] = [ $results[] = [
'label' => ucfirst($label), 'label' => ucfirst($label),
'application_id' => $id, 'compose_id' => $id,
'status' => 'error', 'status' => 'error',
]; ];
} }
} }
$this->applications = $results; $this->composes = $results;
} }
protected function refreshLogs(): void protected function refreshLogs(): void
@@ -122,8 +124,8 @@ class DokployDeployments extends Page
->toArray(); ->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);
} }
} }

View File

@@ -15,49 +15,38 @@ class DokployPlatformHealth extends Widget
protected function getViewData(): array protected function getViewData(): array
{ {
return [ return [
'applications' => $this->loadApplications(), 'composes' => $this->loadComposes(),
]; ];
} }
protected function loadApplications(): array protected function loadComposes(): array
{ {
$client = app(DokployClient::class); $client = app(DokployClient::class);
$applicationMap = config('dokploy.applications', []); $composeMap = config('dokploy.composes', []);
$results = []; $results = [];
foreach ($applicationMap as $label => $applicationId) { foreach ($composeMap as $label => $composeId) {
try { try {
$status = $client->applicationStatus($applicationId); $status = $client->composeStatus($composeId);
$deployments = $client->recentDeployments($applicationId, 1); $deployments = $client->composeDeployments($composeId, 1);
$application = Arr::get($status, 'application', []); $compose = Arr::get($status, 'compose', []);
$monitoring = Arr::get($status, 'monitoring', []); $services = $this->formatServices(Arr::get($status, 'services', []));
$results[] = [ $results[] = [
'label' => ucfirst($label), 'label' => ucfirst($label),
'application_id' => $applicationId, 'compose_id' => $composeId,
'app_name' => Arr::get($application, 'appName') ?? Arr::get($application, 'name'), 'name' => Arr::get($compose, 'name') ?? Arr::get($compose, 'appName') ?? $composeId,
'status' => Arr::get($application, 'applicationStatus', 'unknown'), 'status' => Arr::get($compose, 'composeStatus', 'unknown'),
'cpu' => $this->extractMetric($monitoring, [ 'services' => $services,
'metrics.cpuPercent',
'metrics.cpu_percent',
'cpuPercent',
'cpu_percent',
]),
'memory' => $this->extractMetric($monitoring, [
'metrics.memoryPercent',
'metrics.memory_percent',
'memoryPercent',
'memory_percent',
]),
'last_deploy' => Arr::get($deployments, '0.createdAt') 'last_deploy' => Arr::get($deployments, '0.createdAt')
?? Arr::get($deployments, '0.created_at') ?? Arr::get($deployments, '0.created_at')
?? Arr::get($application, 'updatedAt') ?? Arr::get($compose, 'updatedAt')
?? Arr::get($application, 'lastDeploymentAt'), ?? Arr::get($compose, 'lastDeploymentAt'),
]; ];
} catch (\Throwable $exception) { } catch (\Throwable $exception) {
$results[] = [ $results[] = [
'label' => ucfirst($label), 'label' => ucfirst($label),
'application_id' => $applicationId, 'compose_id' => $composeId,
'status' => 'unreachable', 'status' => 'unreachable',
'error' => $exception->getMessage(), 'error' => $exception->getMessage(),
]; ];
@@ -68,9 +57,9 @@ class DokployPlatformHealth extends Widget
return [ return [
[ [
'label' => 'Dokploy', 'label' => 'Dokploy',
'application_id' => '-', 'compose_id' => '-',
'status' => 'unconfigured', '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; return $results;
} }
protected function extractMetric(array $source, array $candidates): mixed protected function formatServices(array $services): array
{ {
foreach ($candidates as $key) { return collect($services)
if (Arr::has($source, $key)) { ->map(function ($service) {
return Arr::get($source, $key); 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'),
];
return null; })
->filter(fn ($service) => filled($service['name']))
->values()
->all();
} }
} }

View File

@@ -4,8 +4,8 @@ namespace App\Http\Controllers;
use App\Mail\ContactConfirmation; use App\Mail\ContactConfirmation;
use App\Models\BlogPost; use App\Models\BlogPost;
use App\Models\Event;
use App\Models\CheckoutSession; use App\Models\CheckoutSession;
use App\Models\Event;
use App\Models\Package; use App\Models\Package;
use App\Models\PackagePurchase; use App\Models\PackagePurchase;
use App\Models\TenantPackage; use App\Models\TenantPackage;
@@ -33,6 +33,8 @@ class MarketingController extends Controller
{ {
use PresentsPackages; use PresentsPackages;
private ?MarkdownConverter $markdownConverter = null;
public function __construct( public function __construct(
private readonly CheckoutSessionService $checkoutSessions, private readonly CheckoutSessionService $checkoutSessions,
private readonly PaddleCheckoutService $paddleCheckout, private readonly PaddleCheckoutService $paddleCheckout,
@@ -292,11 +294,14 @@ class MarketingController extends Controller
$posts = $query->orderBy('published_at', 'desc') $posts = $query->orderBy('published_at', 'desc')
->paginate(4) ->paginate(4)
->through(function (BlogPost $post) use ($locale) { ->through(function (BlogPost $post) use ($locale) {
$excerpt = $post->getTranslation('excerpt', $locale) ?? $post->getTranslation('excerpt', 'de') ?? '';
return [ return [
'id' => $post->id, 'id' => $post->id,
'slug' => $post->slug, 'slug' => $post->slug,
'title' => $post->getTranslation('title', $locale) ?? $post->getTranslation('title', 'de') ?? '', '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, 'featured_image' => $post->featured_image ?? $post->banner_url ?? null,
'published_at' => optional($post->published_at)->toDateString(), 'published_at' => optional($post->published_at)->toDateString(),
'author' => $post->author ? ['name' => $post->author->name] : null, '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', '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) 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 // Transform to array with translated strings for the current locale
$markdown = $postModel->getTranslation('content', $locale) ?? $postModel->getTranslation('content', 'de') ?? ''; $markdown = $postModel->getTranslation('content', $locale) ?? $postModel->getTranslation('content', 'de') ?? '';
$excerpt = $postModel->getTranslation('excerpt', $locale) ?? $postModel->getTranslation('excerpt', 'de') ?? '';
$environment = new Environment; $contentHtml = $this->convertMarkdownToHtml($markdown);
$environment->addExtension(new CommonMarkCoreExtension); [$contentHtmlWithIds, $headings] = $this->decorateHeadings($contentHtml);
$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);
$post = [ $post = [
'id' => $postModel->id, 'id' => $postModel->id,
'title' => $postModel->getTranslation('title', $locale) ?? $postModel->getTranslation('title', 'de') ?? '', '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' => $markdown,
'content_html' => $contentHtml, 'content_html' => $contentHtmlWithIds,
'headings' => $headings,
'featured_image' => $postModel->featured_image ?? $postModel->banner_url ?? null, 'featured_image' => $postModel->featured_image ?? $postModel->banner_url ?? null,
'published_at' => $postModel->published_at->toDateString(), 'published_at' => $postModel->published_at->toDateString(),
'slug' => $postModel->slug, 'slug' => $postModel->slug,
'url' => route('blog.show', ['locale' => $locale, 'slug' => $postModel->slug], absolute: true),
'author' => $postModel->author ? [ 'author' => $postModel->author ? [
'name' => $postModel->author->name, 'name' => $postModel->author->name,
] : null, ] : null,
'previous_post' => $this->presentAdjacentPost($postModel, $locale, direction: 'previous'),
'next_post' => $this->presentAdjacentPost($postModel, $locale, direction: 'next'),
]; ];
return Inertia::render('marketing/BlogShow', compact('post')); return Inertia::render('marketing/BlogShow', compact('post'));
@@ -481,4 +495,119 @@ class MarketingController extends Controller
'requestedType' => $normalized, '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),
];
}
} }

View File

@@ -64,7 +64,7 @@ class DokployClient
throw new \RuntimeException('Dokploy application name is required to reload the service.'); throw new \RuntimeException('Dokploy application name is required to reload the service.');
} }
return $this->dispatchAction( $response = $this->dispatchAction(
$applicationId, $applicationId,
'reload', 'reload',
[ [
@@ -74,11 +74,15 @@ class DokployClient
fn (array $payload) => $this->post('/application.reload', $payload), fn (array $payload) => $this->post('/application.reload', $payload),
$actor, $actor,
); );
$this->forgetApplicationCaches($applicationId);
return $response;
} }
public function redeployApplication(string $applicationId, ?Authenticatable $actor = null): array public function redeployApplication(string $applicationId, ?Authenticatable $actor = null): array
{ {
return $this->dispatchAction( $response = $this->dispatchAction(
$applicationId, $applicationId,
'redeploy', 'redeploy',
[ [
@@ -87,6 +91,95 @@ class DokployClient
fn (array $payload) => $this->post('/application.redeploy', $payload), fn (array $payload) => $this->post('/application.redeploy', $payload),
$actor, $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 protected function cached(string $key, callable $callback, int $seconds): mixed
@@ -167,7 +260,7 @@ class DokployClient
} }
protected function dispatchAction( protected function dispatchAction(
string $applicationId, string $targetId,
string $action, string $action,
array $payload, array $payload,
callable $callback, callable $callback,
@@ -178,22 +271,20 @@ class DokployClient
$body = $response->json() ?? []; $body = $response->json() ?? [];
$status = $response->status(); $status = $response->status();
} catch (\Throwable $exception) { } catch (\Throwable $exception) {
$this->logAction($applicationId, $action, $payload, [ $this->logAction($targetId, $action, $payload, [
'error' => $exception->getMessage(), 'error' => $exception->getMessage(),
], null, $actor); ], null, $actor);
throw $exception; throw $exception;
} }
$this->logAction($applicationId, $action, $payload, $body, $status, $actor); $this->logAction($targetId, $action, $payload, $body, $status, $actor);
Cache::forget($this->applicationCacheKey($applicationId));
Cache::forget($this->deploymentCacheKey($applicationId));
return $body; return $body;
} }
protected function logAction( protected function logAction(
string $applicationId, string $targetId,
string $action, string $action,
array $payload, array $payload,
array $response, array $response,
@@ -202,7 +293,7 @@ class DokployClient
): void { ): void {
InfrastructureActionLog::create([ InfrastructureActionLog::create([
'user_id' => $actor?->getAuthIdentifier() ?? auth()->id(), 'user_id' => $actor?->getAuthIdentifier() ?? auth()->id(),
'service_id' => $applicationId, 'service_id' => $targetId,
'action' => $action, 'action' => $action,
'payload' => $payload, 'payload' => $payload,
'response' => $response, 'response' => $response,
@@ -219,4 +310,26 @@ class DokployClient
{ {
return "dokploy.deployments.{$applicationId}"; 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));
}
} }

View File

@@ -8,4 +8,5 @@ return [
], ],
'web_url' => env('DOKPLOY_WEB_URL'), 'web_url' => env('DOKPLOY_WEB_URL'),
'applications' => json_decode(env('DOKPLOY_APPLICATION_IDS', '{}'), true) ?? [], 'applications' => json_decode(env('DOKPLOY_APPLICATION_IDS', '{}'), true) ?? [],
'composes' => json_decode(env('DOKPLOY_COMPOSE_IDS', '{}'), true) ?? [],
]; ];

View File

@@ -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_BASE_URL=https://dokploy.example.com/api
DOKPLOY_API_KEY=pat_xxxxxxxxxxxxxxxxx DOKPLOY_API_KEY=pat_xxxxxxxxxxxxxxxxx
DOKPLOY_WEB_URL=https://dokploy.example.com 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_API_TIMEOUT=10
``` ```
- `DOKPLOY_APPLICATION_IDS` is a JSON object mapping human labels to Dokploy `applicationId` values. Those IDs drive the SuperAdmin widget buttons. - `DOKPLOY_COMPOSE_IDS` ist eine JSON-Map Label → `composeId` (siehe Compose-Detailseite in Dokploy). Diese IDs steuern Widget & Buttons.
- The API key needs permission to read the project, query deployments, and trigger `application.redeploy` / `application.reload`. - 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 ## 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`. - Optionally create a dedicated container for Horizon using `docs/queue-supervisor/horizon.sh`.
4. **vsftpd + Photobooth control** 4. **vsftpd + Photobooth control**
- Deploy the ftp image (see `docker-compose` setup) or reuse Dokploys Docker Compose support. - Nutze deinen bestehenden Docker-Compose-Stack (z.B. `docker-compose.dokploy.yml`) oder dedizierte Compose-Applikationen.
- Mount `photobooth` volume read-write. - Mount `photobooth` volume read-write.
5. **Database/Redis** 5. **Database/Redis**
@@ -89,17 +90,16 @@ Follow these steps for each component:
## 5. SuperAdmin observability (Dokploy API) ## 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. 1. **Config file** `config/dokploy.php` liest `DOKPLOY_COMPOSE_IDS`.
2. **Client** `App\Services\Dokploy\DokployClient` wraps key endpoints: 2. **Client** `App\Services\Dokploy\DokployClient` kapselt:
- `GET /application.one?applicationId=...` → status + metadata. - `GET /compose.one?composeId=...` für Meta- und Statusinfos (deploying/error/done).
- `GET /application.readAppMonitoring?appName=...` → CPU & memory metrics. - `GET /compose.loadServices?composeId=...` für die einzelnen Services innerhalb des Stacks.
- `GET /deployment.all?applicationId=...` → latest deployments for history. - `GET /deployment.allByCompose?composeId=...` für die Deploy-Historie.
- `POST /application.reload` (requires `applicationId` + `appName`). - `POST /compose.redeploy`, `POST /compose.deploy`, `POST /compose.stop` (Buttons im UI).
- `POST /application.redeploy` (redeploy latest commit). 3. **Widgets / Pages** `DokployPlatformHealth` zeigt jeden Compose-Stack inkl. Services; die `DokployDeployments`-Seite bietet Redeploy/Stop + Audit-Log (`InfrastructureActionLog`).
3. **Widgets / pages** `DokployPlatformHealth` widget displays the mapped applications, and the `DokployDeployments` page exposes reload/redeploy buttons plus a log table (`InfrastructureActionLog`). 4. **Auditing** jede Aktion wird mit User, Payload, Response & HTTP-Code in `infrastructure_action_logs` festgehalten.
4. **Auditing** all actions persist to `infrastructure_action_logs` with user, payload, response, and status code.
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. 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 ## 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. 2. `photobooth` volume mounted for Laravel + vsftpd + control service.
3. Database/Redis backups scheduled (Dokploy snapshot or external tooling). 3. Database/Redis backups scheduled (Dokploy snapshot or external tooling).
4. `.env` contains the Dokploy API credentials and application ID mapping. 4. `.env` enthält die Dokploy-API-Credentials und `DOKPLOY_COMPOSE_IDS`.
5. Scheduler, workers, and Horizon logging visible in Dokploy. 5. Scheduler, Worker, Horizon werden im Compose-Stack überwacht.
6. SuperAdmin widgets show green health states and allow reload/redeploy actions. 6. SuperAdmin-Widget zeigt die Compose-Stacks und erlaubt Redeploy/Stop.
7. Webhooks/alerts configured for failed deployments or unhealthy containers. 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 Laravels scheduler and workers continue to run within the same infrastructure. With this setup the Fotospiel team can manage deployments, restarts, and metrics centrally through Dokploy while Laravels scheduler and workers continue to run within the same infrastructure.

View File

@@ -1,7 +1,25 @@
{ {
"title_suffix": " - Fotospiel Blog", "title_suffix": " - Fotospiel Blog",
"by_author": "Von", "by_author": "Von",
"team": "Team", "team": "Fotospiel Team",
"published_on": "Veröffentlicht am", "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",
"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"
} }

View File

@@ -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.", "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", "cta": "Paket wählen",
"weddings": { "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.", "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", "benefits_title": "Vorteile für Hochzeiten",
"benefit1": "QR-Code für Gäste: Einfaches Teilen ohne App-Download.", "benefit1": "QR-Code für Gäste: Einfaches Teilen ohne App-Download.",
@@ -373,6 +373,9 @@
"privacy": "Datenschutz", "privacy": "Datenschutz",
"impressum": "Impressum", "impressum": "Impressum",
"occasions_types": { "occasions_types": {
"weddings": "Hochzeiten",
"birthdays": "Geburtstage",
"corporate": "Firmenevents",
"confirmation": "Konfirmation & Jugendweihe" "confirmation": "Konfirmation & Jugendweihe"
}, },
"language": "Sprache", "language": "Sprache",
@@ -608,7 +611,7 @@
"hero": { "hero": {
"title": "So funktioniert die Fotospiel App", "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.", "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", "secondaryCta": "Kontakt aufnehmen",
"stats": [ "stats": [
{ {
@@ -870,6 +873,14 @@
"description": "Unser Team hilft dir bei der Einrichtung oder plant mit dir ein Pilot-Event.", "description": "Unser Team hilft dir bei der Einrichtung oder plant mit dir ein Pilot-Event.",
"cta": "Kontakt aufnehmen" "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" "timeline_title": "Der Ablauf im Detail"
}, },
"labels": { "labels": {

View File

@@ -1,7 +1,25 @@
{ {
"title_suffix": " - Fotospiel Blog", "title_suffix": " - Fotospiel Blog",
"by_author": "By", "by_author": "By",
"team": "Team", "team": "Fotospiel Team",
"published_on": "Published on", "published_on": "Published on",
"back_to_blog": "Back to Blog" "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"
} }

View File

@@ -359,6 +359,9 @@
"privacy": "Privacy", "privacy": "Privacy",
"impressum": "Imprint", "impressum": "Imprint",
"occasions_types": { "occasions_types": {
"weddings": "Weddings",
"birthdays": "Birthdays",
"corporate": "Corporate Events",
"confirmation": "Confirmations" "confirmation": "Confirmations"
}, },
"language": "Language", "language": "Language",
@@ -602,7 +605,7 @@
"hero": { "hero": {
"title": "How the Fotospiel App Works", "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.", "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", "secondaryCta": "Talk to our team",
"stats": [ "stats": [
{ {
@@ -864,6 +867,14 @@
"description": "Our team is happy to set up a pilot event or walk you through the dashboard.", "description": "Our team is happy to set up a pilot event or walk you through the dashboard.",
"cta": "Contact us" "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" "timeline_title": "The detailed flow"
}, },
"labels": { "labels": {

View File

@@ -14,6 +14,7 @@ interface PostSummary {
slug: string; slug: string;
title: string; title: string;
excerpt?: string; excerpt?: string;
excerpt_html?: string;
featured_image?: string; featured_image?: string;
published_at?: string; published_at?: string;
author?: { name?: string } | 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 <div className={className} dangerouslySetInnerHTML={{ __html: html }} />;
}
if (!fallback) {
return null;
}
return <p className={className}>{fallback}</p>;
};
const Blog: React.FC<Props> = ({ posts }) => { const Blog: React.FC<Props> = ({ posts }) => {
const { localizedPath } = useLocalizedRoutes(); const { localizedPath } = useLocalizedRoutes();
const { props } = usePage<{ supportedLocales?: string[] }>(); const { props } = usePage<{ supportedLocales?: string[] }>();
@@ -118,6 +131,7 @@ const Blog: React.FC<Props> = ({ posts }) => {
<div className="flex flex-wrap justify-center gap-2"> <div className="flex flex-wrap justify-center gap-2">
{posts.links.map((link, index) => { {posts.links.map((link, index) => {
const href = resolvePaginationHref(link.url); const href = resolvePaginationHref(link.url);
const labelText = link.label?.trim() || '…';
if (!href) { if (!href) {
return ( return (
@@ -126,8 +140,9 @@ const Blog: React.FC<Props> = ({ posts }) => {
variant={link.active ? 'default' : 'outline'} variant={link.active ? 'default' : 'outline'}
disabled disabled
className={link.active ? 'bg-[#FFB6C1] hover:bg-[#FF69B4]' : ''} className={link.active ? 'bg-[#FFB6C1] hover:bg-[#FF69B4]' : ''}
dangerouslySetInnerHTML={{ __html: link.label }} >
/> {labelText}
</Button>
); );
} }
@@ -138,7 +153,9 @@ const Blog: React.FC<Props> = ({ posts }) => {
variant={link.active ? 'default' : 'outline'} variant={link.active ? 'default' : 'outline'}
className={link.active ? 'bg-[#FFB6C1] hover:bg-[#FF69B4]' : ''} className={link.active ? 'bg-[#FFB6C1] hover:bg-[#FF69B4]' : ''}
> >
<Link href={href} dangerouslySetInnerHTML={{ __html: link.label }} /> <Link href={href} aria-label={labelText}>
{labelText}
</Link>
</Button> </Button>
); );
})} })}
@@ -182,9 +199,11 @@ const Blog: React.FC<Props> = ({ posts }) => {
<h2 className="text-3xl font-bold text-gray-900 dark:text-gray-50"> <h2 className="text-3xl font-bold text-gray-900 dark:text-gray-50">
{featuredPost.title || 'Untitled'} {featuredPost.title || 'Untitled'}
</h2> </h2>
<p className="text-lg leading-relaxed text-gray-600 dark:text-gray-300"> <MarkdownPreview
{featuredPost.excerpt || ''} html={featuredPost.excerpt_html}
</p> fallback={featuredPost.excerpt}
className="text-lg leading-relaxed text-gray-600 dark:text-gray-300"
/>
{renderPostMeta(featuredPost)} {renderPostMeta(featuredPost)}
<Button asChild size="lg" className="bg-[#FFB6C1] hover:bg-[#FF69B4] text-white"> <Button asChild size="lg" className="bg-[#FFB6C1] hover:bg-[#FF69B4] text-white">
<Link href={buildArticleHref(featuredPost.slug)}> <Link href={buildArticleHref(featuredPost.slug)}>
@@ -224,9 +243,11 @@ const Blog: React.FC<Props> = ({ posts }) => {
<h3 className="text-xl font-semibold text-gray-900 dark:text-gray-50"> <h3 className="text-xl font-semibold text-gray-900 dark:text-gray-50">
{post.title || 'Untitled'} {post.title || 'Untitled'}
</h3> </h3>
<p className="text-sm leading-relaxed text-gray-600 dark:text-gray-300"> <MarkdownPreview
{post.excerpt || ''} html={post.excerpt_html}
</p> fallback={post.excerpt}
className="text-sm leading-relaxed text-gray-600 dark:text-gray-300"
/>
</div> </div>
<div className="flex-1" /> <div className="flex-1" />
{renderPostMeta(post)} {renderPostMeta(post)}
@@ -261,7 +282,11 @@ const Blog: React.FC<Props> = ({ posts }) => {
<h3 className="text-2xl font-semibold text-gray-900 dark:text-gray-50"> <h3 className="text-2xl font-semibold text-gray-900 dark:text-gray-50">
{post.title || 'Untitled'} {post.title || 'Untitled'}
</h3> </h3>
<p className="text-gray-600 dark:text-gray-300">{post.excerpt || ''}</p> <MarkdownPreview
html={post.excerpt_html}
fallback={post.excerpt}
className="text-gray-600 dark:text-gray-300"
/>
{renderPostMeta(post)} {renderPostMeta(post)}
<Button asChild variant="outline" className="w-fit border-pink-200 text-pink-600 hover:bg-pink-50 dark:border-pink-500/40 dark:text-pink-200"> <Button asChild variant="outline" className="w-fit border-pink-200 text-pink-600 hover:bg-pink-50 dark:border-pink-500/40 dark:text-pink-200">
<Link href={buildArticleHref(post.slug)}> <Link href={buildArticleHref(post.slug)}>
@@ -283,18 +308,18 @@ const Blog: React.FC<Props> = ({ posts }) => {
<MarketingLayout title={t('blog.title')}> <MarketingLayout title={t('blog.title')}>
<Head title={t('blog.title')} /> <Head title={t('blog.title')} />
<section className="bg-gradient-to-br from-pink-50 via-white to-amber-50 px-4 py-14 dark:from-gray-950 dark:via-gray-900 dark:to-gray-950"> <section className="bg-gradient-to-br from-pink-50 via-white to-amber-50 px-4 py-10 dark:from-gray-950 dark:via-gray-900 dark:to-gray-950 md:py-12">
<div className="container mx-auto max-w-4xl space-y-6 text-center"> <div className="container mx-auto max-w-3xl space-y-5 text-center">
<Badge variant="outline" className="border-pink-200 text-pink-600 dark:border-pink-500/50 dark:text-pink-200"> <Badge variant="outline" className="border-pink-200 text-pink-600 dark:border-pink-500/50 dark:text-pink-200">
Fotospiel Blog Fotospiel Blog
</Badge> </Badge>
<h1 className="text-4xl font-bold text-gray-900 dark:text-gray-50 md:text-5xl">{t('blog.hero_title')}</h1> <h1 className="text-3xl font-bold text-gray-900 dark:text-gray-50 md:text-4xl">{t('blog.hero_title')}</h1>
<p className="text-lg leading-relaxed text-gray-600 dark:text-gray-300">{t('blog.hero_description')}</p> <p className="text-base leading-relaxed text-gray-600 dark:text-gray-300 md:text-lg">{t('blog.hero_description')}</p>
<div className="flex flex-wrap justify-center gap-3"> <div className="flex flex-wrap justify-center gap-2.5">
<Button asChild size="lg" className="bg-[#FFB6C1] hover:bg-[#FF69B4] text-white"> <Button asChild className="bg-[#FFB6C1] hover:bg-[#FF69B4] text-white px-6 py-2.5 text-base">
<Link href={localizedPath('/packages')}>{t('home.cta_explore')}</Link> <Link href={localizedPath('/packages')}>{t('home.cta_explore')}</Link>
</Button> </Button>
<Button asChild size="lg" variant="outline" className="border-gray-300 text-gray-800 hover:bg-gray-100 dark:border-gray-700 dark:text-gray-50 dark:hover:bg-gray-800"> <Button asChild variant="outline" className="border-gray-300 text-gray-800 hover:bg-gray-100 px-6 py-2.5 text-base dark:border-gray-700 dark:text-gray-50 dark:hover:bg-gray-800">
<Link href="#articles">{t('blog.hero_cta')}</Link> <Link href="#articles">{t('blog.hero_cta')}</Link>
</Button> </Button>
</div> </div>

View File

@@ -1,112 +1,336 @@
import React from 'react'; import React from 'react';
import { Head, Link } from '@inertiajs/react'; import { Head, Link } from '@inertiajs/react';
import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes'; import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import MarketingLayout from '@/layouts/mainWebsite'; 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 { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { Button } from '@/components/ui/button'; 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 { interface Props {
post: { post: {
id: number; id: number;
title: string; title: string;
excerpt?: string; excerpt?: string;
excerpt_html?: string;
content: string; content: string;
content_html: string; content_html: string;
headings?: HeadingItem[];
featured_image?: string; featured_image?: string;
published_at: string; published_at: string;
author?: { name: string }; author?: { name: string };
slug: 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 <div className={className} dangerouslySetInnerHTML={{ __html: html }} />;
}
if (!fallback) {
return null;
}
return <p className={className}>{fallback}</p>;
};
const BlogShow: React.FC<Props> = ({ post }) => { const BlogShow: React.FC<Props> = ({ post }) => {
const { localizedPath } = useLocalizedRoutes(); 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 ( return (
<MarketingLayout title={`${post.title} ${t('title_suffix')}`}> <MarketingLayout title={`${post.title} ${t('title_suffix')}`}>
<Head title={`${post.title} ${t('title_suffix')}`} /> <Head title={`${post.title} ${t('title_suffix')}`} />
{/* Hero Section */} <section className="bg-gradient-to-br from-pink-50 via-white to-amber-50 px-4 py-8 dark:from-gray-950 dark:via-gray-900 dark:to-gray-950 md:py-10">
<section className="bg-gradient-to-r from-[#FFB6C1] via-[#FFD700] to-[#87CEEB] py-20 px-4"> <div className="container mx-auto max-w-5xl space-y-5">
<div className="container mx-auto max-w-4xl"> <div className="flex flex-wrap items-center justify-between gap-3 text-sm text-gray-500 dark:text-gray-400">
<Card className="bg-white/10 backdrop-blur-sm border-white/20 text-white shadow-xl"> <nav className="flex flex-wrap items-center gap-2">
<CardContent className="p-8 text-center"> <Link href={localizedPath('/')} className="hover:text-gray-900 dark:hover:text-gray-100">
<h1 className="text-4xl md:text-5xl font-bold mb-6 leading-tight">{post.title}</h1> {t('breadcrumb_home')}
</Link>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4 mb-8 text-lg"> <span>/</span>
<Badge variant="secondary" className="bg-white/20 text-white border-white/30"> <Link href={localizedPath('/blog')} className="hover:text-gray-900 dark:hover:text-gray-100">
{t('by_author')} {post.author?.name || t('team')} {t('breadcrumb_blog')}
</Badge> </Link>
<Separator orientation="vertical" className="hidden sm:block h-6 bg-white/30" /> <span>/</span>
<Badge variant="secondary" className="bg-white/20 text-white border-white/30"> <span className="text-gray-700 dark:text-gray-200">{post.title}</span>
{t('published_on')} {new Date(post.published_at).toLocaleDateString('de-DE', { </nav>
day: 'numeric', <Link href={localizedPath('/blog')} className="text-pink-600 hover:text-pink-700">
month: 'long', {t('back_to_blog')}
year: 'numeric' </Link>
})}
</Badge>
</div> </div>
{post.featured_image && ( <Card className="rounded-[32px] border border-white/60 bg-white/80 text-gray-900 shadow-xl backdrop-blur dark:border-gray-800 dark:bg-gray-900/80 dark:text-gray-50">
<div className="mt-8"> <CardContent className="space-y-6 p-6 md:p-8">
<div className="flex flex-col gap-6 md:grid md:grid-cols-[minmax(0,1fr)_240px] md:items-center md:gap-8">
<div className="space-y-4 text-left">
<Badge variant="outline" className="border-pink-200 bg-white/70 text-pink-600 dark:border-pink-500/60 dark:bg-gray-900/80 dark:text-pink-200">
Fotospiel Stories
</Badge>
<h1 className="text-3xl font-bold leading-tight text-gray-900 dark:text-gray-50 md:text-4xl">{post.title}</h1>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-gray-600 dark:text-gray-300">
<span>{t('by_author')} {post.author?.name || t('team')}</span>
<span className="hidden text-gray-400 sm:inline"></span>
<span>{t('published_on')} {formattedDate}</span>
</div>
<MarkdownPreview
html={post.excerpt_html}
fallback={post.excerpt}
className="text-base leading-relaxed text-gray-700 dark:text-gray-200"
/>
</div>
<div className="relative">
{post.featured_image ? (
<div className="rounded-3xl border border-white/80 bg-white/60 p-2 shadow-2xl dark:border-gray-800/80 dark:bg-gray-900/80">
<img <img
src={post.featured_image} src={post.featured_image}
alt={post.title} alt={post.title}
className="mx-auto rounded-lg shadow-lg max-w-2xl w-full object-cover" className="h-full w-full rounded-2xl object-cover"
/> />
</div> </div>
) : (
<div className="rounded-3xl border border-dashed border-pink-200 bg-gradient-to-br from-pink-100 via-white to-amber-100 p-8 text-center text-lg font-semibold text-pink-700 shadow-lg dark:border-pink-500/40 dark:from-pink-900/30 dark:via-gray-900 dark:to-gray-900">
Fotospiel Stories
</div>
)} )}
</div>
</div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</section> </section>
{/* Post Content */} <section className="bg-white px-4 py-12 dark:bg-gray-950">
<section className="py-20 px-4 bg-white"> <div className="container mx-auto max-w-6xl gap-10 lg:grid lg:grid-cols-[minmax(0,1fr)_320px]">
<div className="container mx-auto max-w-4xl"> <article className="rounded-[32px] border border-gray-100 bg-white p-6 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<Card className="shadow-sm">
<CardContent className="p-8 md:p-12">
<div <div
className="prose prose-lg prose-slate max-w-none className="prose prose-lg prose-slate max-w-none
prose-headings:text-slate-900 prose-headings:font-semibold prose-headings:text-slate-900 prose-headings:font-semibold
prose-p:text-slate-700 prose-p:leading-relaxed prose-p:text-slate-700 prose-p:leading-relaxed
prose-a:text-blue-600 prose-a:no-underline hover:prose-a:underline prose-a:text-pink-600 prose-a:no-underline hover:prose-a:underline
prose-strong:text-slate-900 prose-strong:font-semibold prose-strong:text-slate-900 prose-strong:font-semibold
prose-code:text-slate-900 prose-code:bg-slate-100 prose-code:px-1 prose-code:py-0.5 prose-code:rounded prose-code:text-slate-900 prose-code:bg-slate-100 prose-code:px-1 prose-code:py-0.5 prose-code:rounded
prose-pre:bg-slate-900 prose-pre:text-slate-100 prose-pre:bg-slate-900 prose-pre:text-slate-100
prose-blockquote:border-l-4 prose-blockquote:border-blue-500 prose-blockquote:pl-6 prose-blockquote:italic prose-blockquote:border-l-4 prose-blockquote:border-pink-500 prose-blockquote:pl-6 prose-blockquote:italic
prose-ul:text-slate-700 prose-ol:text-slate-700 prose-ul:text-slate-700 prose-ol:text-slate-700
prose-li:text-slate-700" prose-li:text-slate-700 dark:prose-invert"
dangerouslySetInnerHTML={{ __html: post.content_html }} dangerouslySetInnerHTML={{ __html: post.content_html }}
/> />
</article>
<aside className="mt-10 space-y-6 lg:mt-0 lg:sticky lg:top-24">
<Card className="border-gray-100 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<CardContent className="space-y-3 p-6">
<p className="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
{t('summary_title')}
</p>
<MarkdownPreview
html={post.excerpt_html}
fallback={post.excerpt}
className="text-base leading-relaxed text-gray-700 dark:text-gray-200"
/>
</CardContent> </CardContent>
</Card> </Card>
<Card className="border-gray-100 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<CardContent className="space-y-4 p-6">
<p className="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
{t('toc_title')}
</p>
{post.headings && post.headings.length > 0 ? (
<ul className="space-y-3 text-sm text-gray-700 dark:text-gray-200">
{post.headings.map((heading) => (
<li key={heading.slug} className="border-l-2 border-pink-100 pl-3 dark:border-pink-500/40">
<a href={`#${heading.slug}`} className="hover:text-pink-600">
{heading.text}
</a>
</li>
))}
</ul>
) : (
<p className="text-sm text-gray-500 dark:text-gray-400">{t('toc_empty')}</p>
)}
</CardContent>
</Card>
<Card className="border-gray-100 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<CardContent className="space-y-3 p-6">
<p className="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
{t('sidebar_author_title')}
</p>
<p className="text-lg font-semibold text-gray-900 dark:text-gray-50">{post.author?.name || t('team')}</p>
<p className="text-sm text-gray-600 dark:text-gray-300">{t('sidebar_author_description')}</p>
</CardContent>
</Card>
<Card className="border-gray-100 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<CardContent className="space-y-4 p-6">
<div>
<p className="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
{t('share_title')}
</p>
<p className="text-sm text-gray-500 dark:text-gray-400">{t('share_hint')}</p>
</div>
<div className="flex flex-wrap gap-3">
<Button variant="outline" size="sm" onClick={handleCopyLink} className="border-pink-200 text-pink-600 dark:border-pink-500/50 dark:text-pink-200">
{copied ? t('share_copied') : t('share_copy')}
</Button>
{canUseNativeShare && (
<Button
variant="ghost"
size="sm"
className="text-gray-700 hover:text-pink-600 dark:text-gray-200"
onClick={() => navigator.share?.({ title: post.title, url: shareUrl })}
>
{t('share_native')}
</Button>
)}
</div>
<Separator className="my-2" />
<div className="flex flex-col gap-2">
{shareLinks.map((link) => (
<Button key={link.key} variant="outline" size="sm" asChild className="justify-start border-gray-200 text-gray-700 hover:border-pink-200 hover:text-pink-600 dark:border-gray-700 dark:text-gray-50">
<a href={link.href} target="_blank" rel="noreferrer">
{link.label}
</a>
</Button>
))}
</div>
</CardContent>
</Card>
</aside>
</div> </div>
</section> </section>
{/* Back to Blog */} {(post.previous_post || post.next_post) && (
<section className="py-10 px-4 bg-gray-50"> <section className="bg-gradient-to-br from-pink-50 via-white to-amber-50 px-4 py-12 dark:from-gray-950 dark:via-gray-900 dark:to-gray-950">
<div className="container mx-auto max-w-4xl"> <div className="container mx-auto max-w-6xl">
<Card className="shadow-sm"> <div className="grid gap-6 md:grid-cols-2">
<CardContent className="p-8 text-center"> {post.previous_post && (
<Separator className="mb-6" /> <Card className="border-none bg-gradient-to-br from-pink-500 to-pink-400 text-white shadow-lg">
<Button <CardContent className="space-y-4 p-6">
asChild <p className="text-sm font-semibold uppercase tracking-wide text-white/80">
size="lg" {t('previous_post')}
className="bg-[#FFB6C1] hover:bg-[#FF69B4] text-white px-8 py-3 rounded-full font-semibold transition-colors" </p>
> <h3 className="text-2xl font-semibold">{post.previous_post.title}</h3>
<Link href={localizedPath('/blog')}> <MarkdownPreview
{t('back_to_blog')} html={post.previous_post.excerpt_html}
fallback={post.previous_post.excerpt}
className="text-white/90"
/>
<Button asChild variant="secondary" className="bg-white/20 text-white hover:bg-white/30">
<Link href={buildArticleHref(post.previous_post.slug)}>
{t('read_story')}
</Link> </Link>
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
)}
{post.next_post && (
<Card className="border-none bg-gradient-to-br from-amber-400 to-pink-400 text-gray-900 shadow-lg">
<CardContent className="space-y-4 p-6">
<p className="text-sm font-semibold uppercase tracking-wide text-gray-800/80">
{t('next_post')}
</p>
<h3 className="text-2xl font-semibold">{post.next_post.title}</h3>
<MarkdownPreview
html={post.next_post.excerpt_html}
fallback={post.next_post.excerpt}
className="text-gray-900/80"
/>
<Button asChild className="bg-gray-900 text-white hover:bg-gray-800">
<Link href={buildArticleHref(post.next_post.slug)}>
{t('read_story')}
</Link>
</Button>
</CardContent>
</Card>
)}
</div>
</div> </div>
</section> </section>
)}
</MarketingLayout> </MarketingLayout>
); );
}; };

View File

@@ -103,7 +103,7 @@ const HowItWorks: React.FC = () => {
</p> </p>
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<Button asChild size="lg" className="bg-pink-500 hover:bg-pink-600"> <Button asChild size="lg" className="bg-pink-500 hover:bg-pink-600">
<Link href={localizedPath('/register')}> <Link href={localizedPath('/packages')}>
{hero.primaryCta} {hero.primaryCta}
</Link> </Link>
</Button> </Button>
@@ -192,7 +192,7 @@ const HowItWorks: React.FC = () => {
{t('how_it_works_page.timeline_title', 'Der Ablauf im Detail')} {t('how_it_works_page.timeline_title', 'Der Ablauf im Detail')}
</Badge> </Badge>
<h2 className="mt-3 text-3xl font-bold text-gray-900 dark:text-gray-50"> <h2 className="mt-3 text-3xl font-bold text-gray-900 dark:text-gray-50">
Ein klarer Fahrplan für dein Event {t('how_it_works_page.labels.timeline_heading', 'Ein klarer Fahrplan für dein Event')}
</h2> </h2>
</div> </div>
<Accordion type="single" collapsible className="w-full"> <Accordion type="single" collapsible className="w-full">
@@ -211,7 +211,7 @@ const HowItWorks: React.FC = () => {
{item.tips?.length ? ( {item.tips?.length ? (
<div className="mt-4 rounded-lg border border-pink-100 bg-white p-4 text-sm text-gray-600 shadow-sm dark:border-pink-900/40 dark:bg-gray-900 dark:text-gray-300"> <div className="mt-4 rounded-lg border border-pink-100 bg-white p-4 text-sm text-gray-600 shadow-sm dark:border-pink-900/40 dark:bg-gray-900 dark:text-gray-300">
<p className="mb-2 font-semibold text-pink-600 dark:text-pink-300"> <p className="mb-2 font-semibold text-pink-600 dark:text-pink-300">
{t('marketing.actions.tips', 'Tipps')} {t('how_it_works_page.labels.tips', 'Tipps')}
</p> </p>
<ul className="space-y-1"> <ul className="space-y-1">
{item.tips.map((tip) => ( {item.tips.map((tip) => (
@@ -271,7 +271,7 @@ const HowItWorks: React.FC = () => {
<CardContent className="grid gap-6 md:grid-cols-2"> <CardContent className="grid gap-6 md:grid-cols-2">
<div> <div>
<p className="mb-3 text-sm font-semibold uppercase tracking-wide text-pink-600 dark:text-pink-300"> <p className="mb-3 text-sm font-semibold uppercase tracking-wide text-pink-600 dark:text-pink-300">
{t('marketing.labels.recommendations', 'Empfehlungen')} {t('how_it_works_page.labels.recommendations', 'Empfehlungen')}
</p> </p>
<ul className="space-y-2 text-sm text-gray-600 dark:text-gray-300"> <ul className="space-y-2 text-sm text-gray-600 dark:text-gray-300">
{tab.recommendations.map((item) => ( {tab.recommendations.map((item) => (
@@ -284,7 +284,7 @@ const HowItWorks: React.FC = () => {
</div> </div>
<div> <div>
<p className="mb-3 text-sm font-semibold uppercase tracking-wide text-pink-600 dark:text-pink-300"> <p className="mb-3 text-sm font-semibold uppercase tracking-wide text-pink-600 dark:text-pink-300">
{t('marketing.labels.challengeIdeas', 'Ideen für Challenges')} {t('how_it_works_page.labels.challenge_ideas', 'Ideen für Challenges')}
</p> </p>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{tab.ideas.map((idea) => ( {tab.ideas.map((idea) => (
@@ -308,7 +308,7 @@ const HowItWorks: React.FC = () => {
<CardHeader> <CardHeader>
<CardTitle>{checklist.title}</CardTitle> <CardTitle>{checklist.title}</CardTitle>
<CardDescription> <CardDescription>
{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.')}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>

File diff suppressed because it is too large Load Diff

View File

@@ -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.", "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", "cta": "Paket wählen",
"weddings": { "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.", "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", "benefits_title": "Vorteile für Hochzeiten",
"benefit1": "QR-Code für Gäste: Einfaches Teilen ohne App-Download.", "benefit1": "QR-Code für Gäste: Einfaches Teilen ohne App-Download.",
@@ -221,13 +221,38 @@
"title_suffix": " - Fotospiel Blog", "title_suffix": " - Fotospiel Blog",
"by_author": "Von", "by_author": "Von",
"published_on": "Veröffentlicht am", "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": { "nav": {
"home": "Startseite", "home": "Startseite",
"how_it_works": "So funktioniert es", "how_it_works": "So funktioniert es",
"features": "Features", "features": "Features",
"occasions": "Anlässe", "occasions": "Anlässe",
"occasions_types": {
"weddings": "Hochzeiten",
"birthdays": "Geburtstage",
"corporate": "Firmenevents",
"confirmation": "Konfirmation & Jugendweihe"
},
"blog": "Blog", "blog": "Blog",
"packages": "Pakete", "packages": "Pakete",
"contact": "Kontakt", "contact": "Kontakt",

View File

@@ -117,6 +117,10 @@ return [
'read_more' => 'Lesen', 'read_more' => 'Lesen',
'back' => 'Zurück zum Blog', 'back' => 'Zurück zum Blog',
'empty' => 'Noch keine Posts verfügbar. Bleib dran!', 'empty' => 'Noch keine Posts verfügbar. Bleib dran!',
'pagination' => [
'previous' => 'Zurück',
'next' => 'Weiter',
],
], ],
'occasions' => [ 'occasions' => [
'title' => 'Fotospiel für :type', '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.', 'hero_description' => 'Sammle unvergessliche Fotos von deinen Gästen mit QR-Codes. Perfekt für :type einfach, mobil und datenschutzkonform.',
'cta' => 'Package wählen', 'cta' => 'Package wählen',
'weddings' => [ '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.', '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', 'benefits_title' => 'Vorteile für Hochzeiten',
'benefit1' => 'QR-Code für Gäste: Einfaches Teilen ohne App-Download.', 'benefit1' => 'QR-Code für Gäste: Einfaches Teilen ohne App-Download.',

View File

@@ -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": [] }
]
}

View File

@@ -141,7 +141,7 @@
"hero_description": "Collect unforgettable photos from your guests with QR-Codes. Perfect for :type simple, mobile and privacy-compliant.", "hero_description": "Collect unforgettable photos from your guests with QR-Codes. Perfect for :type simple, mobile and privacy-compliant.",
"cta": "Choose Package", "cta": "Choose Package",
"weddings": { "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.", "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", "benefits_title": "Benefits for Weddings",
"benefit1": "QR-Code for Guests: Easy sharing without app download.", "benefit1": "QR-Code for Guests: Easy sharing without app download.",
@@ -221,13 +221,38 @@
"title_suffix": " - Fotospiel Blog", "title_suffix": " - Fotospiel Blog",
"by_author": "By", "by_author": "By",
"published_on": "Published on", "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": { "nav": {
"home": "Home", "home": "Home",
"how_it_works": "How it works", "how_it_works": "How it works",
"features": "Features", "features": "Features",
"occasions": "Occasions", "occasions": "Occasions",
"occasions_types": {
"weddings": "Weddings",
"birthdays": "Birthdays",
"corporate": "Corporate Events",
"confirmation": "Confirmations"
},
"blog": "Blog", "blog": "Blog",
"packages": "Packages", "packages": "Packages",
"contact": "Contact", "contact": "Contact",

View File

@@ -117,6 +117,10 @@ return [
'read_more' => 'Read', 'read_more' => 'Read',
'back' => 'Back to Blog', 'back' => 'Back to Blog',
'empty' => 'No posts available yet. Stay tuned!', 'empty' => 'No posts available yet. Stay tuned!',
'pagination' => [
'previous' => 'Previous',
'next' => 'Next',
],
], ],
'occasions' => [ 'occasions' => [
'title' => 'Fotospiel for :type', '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.', 'hero_description' => 'Collect unforgettable photos from your guests with QR-Codes. Perfect for :type simple, mobile and privacy-compliant.',
'cta' => 'Choose Package', 'cta' => 'Choose Package',
'weddings' => [ '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.', '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', 'benefits_title' => 'Benefits for Weddings',
'benefit1' => 'QR-Code for Guests: Easy sharing without app download.', 'benefit1' => 'QR-Code for Guests: Easy sharing without app download.',

View File

@@ -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": "Well 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." }
}

View File

@@ -1,28 +1,28 @@
<x-filament-panels::page> <x-filament-panels::page>
<div class="space-y-6"> <div class="space-y-6">
<x-filament::section heading="Application Controls"> <x-filament::section heading="Compose Controls">
<div class="grid gap-4 md:grid-cols-2"> <div class="grid gap-4 md:grid-cols-2">
@foreach($applications as $application) @foreach($composes as $compose)
<div class="rounded-2xl border border-slate-200/70 bg-white/80 p-4 shadow-sm dark:border-white/10 dark:bg-slate-900/60"> <div class="rounded-2xl border border-slate-200/70 bg-white/80 p-4 shadow-sm dark:border-white/10 dark:bg-slate-900/60">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<p class="text-sm font-semibold text-slate-700 dark:text-slate-200">{{ $application['label'] }}</p> <p class="text-sm font-semibold text-slate-700 dark:text-slate-200">{{ $compose['label'] }}</p>
<p class="text-xs text-slate-500 dark:text-slate-400">{{ $application['application_id'] }}</p> <p class="text-xs text-slate-500 dark:text-slate-400">{{ $compose['compose_id'] }}</p>
</div> </div>
<span class="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-700 dark:bg-slate-800 dark:text-slate-100"> <span class="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-700 dark:bg-slate-800 dark:text-slate-100">
{{ ucfirst($application['status'] ?? 'unknown') }} {{ ucfirst($compose['status'] ?? 'unknown') }}
</span> </span>
</div> </div>
<div class="mt-4 flex flex-wrap gap-2"> <div class="mt-4 flex flex-wrap gap-2">
<x-filament::button size="sm" color="warning" wire:click="reload('{{ $application['application_id'] }}')"> <x-filament::button size="sm" color="warning" wire:click="redeploy('{{ $compose['compose_id'] }}')">
Reload
</x-filament::button>
<x-filament::button size="sm" color="gray" wire:click="redeploy('{{ $application['application_id'] }}')">
Redeploy Redeploy
</x-filament::button> </x-filament::button>
<x-filament::button size="sm" color="danger" wire:click="stop('{{ $compose['compose_id'] }}')">
Stop
</x-filament::button>
@if($dokployWebUrl) @if($dokployWebUrl)
<x-filament::button tag="a" size="sm" color="gray" href="{{ rtrim($dokployWebUrl, '/') }}/applications/{{ $application['application_id'] }}" target="_blank"> <x-filament::button tag="a" size="sm" color="gray" href="{{ rtrim($dokployWebUrl, '/') }}" target="_blank">
Open in Dokploy Open in Dokploy
</x-filament::button> </x-filament::button>
@endif @endif
@@ -39,7 +39,7 @@
<tr class="text-left text-xs uppercase tracking-wide text-slate-500"> <tr class="text-left text-xs uppercase tracking-wide text-slate-500">
<th class="px-3 py-2">When</th> <th class="px-3 py-2">When</th>
<th class="px-3 py-2">User</th> <th class="px-3 py-2">User</th>
<th class="px-3 py-2">Application</th> <th class="px-3 py-2">Target</th>
<th class="px-3 py-2">Action</th> <th class="px-3 py-2">Action</th>
<th class="px-3 py-2">Status</th> <th class="px-3 py-2">Status</th>
</tr> </tr>

View File

@@ -1,52 +1,54 @@
<x-filament-widgets::widget> <x-filament-widgets::widget>
<x-filament::section heading="Infra Status (Dokploy)"> <x-filament::section heading="Infra Status (Dokploy)">
<div class="grid gap-4 md:grid-cols-2"> <div class="grid gap-4 md:grid-cols-2">
@forelse($applications as $application) @forelse($composes as $compose)
<div class="rounded-2xl border border-slate-200/70 bg-white/80 p-4 shadow-sm dark:border-white/10 dark:bg-slate-900/60"> <div class="rounded-2xl border border-slate-200/70 bg-white/80 p-4 shadow-sm dark:border-white/10 dark:bg-slate-900/60">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<p class="text-sm font-semibold text-slate-600 dark:text-slate-200">{{ $application['label'] }}</p> <p class="text-sm font-semibold text-slate-600 dark:text-slate-200">{{ $compose['label'] }}</p>
<p class="text-xs text-slate-500 dark:text-slate-400">{{ $application['app_name'] ?? $application['application_id'] }}</p> <p class="text-xs text-slate-500 dark:text-slate-400">{{ $compose['name'] }}</p>
<p class="text-[11px] text-slate-400 dark:text-slate-500">{{ $application['application_id'] }}</p> <p class="text-[11px] text-slate-400 dark:text-slate-500">{{ $compose['compose_id'] }}</p>
</div> </div>
<span @class([ <span @class([
'rounded-full px-3 py-1 text-xs font-semibold', 'rounded-full px-3 py-1 text-xs font-semibold',
'bg-emerald-100 text-emerald-800' => $application['status'] === 'running', 'bg-emerald-100 text-emerald-800' => $compose['status'] === 'done',
'bg-amber-100 text-amber-800' => in_array($application['status'], ['deploying', 'idle']), 'bg-amber-100 text-amber-800' => in_array($compose['status'], ['deploying', 'pending']),
'bg-rose-100 text-rose-800' => in_array($application['status'], ['unreachable', 'error']), 'bg-rose-100 text-rose-800' => in_array($compose['status'], ['unreachable', 'error', 'failed']),
'bg-slate-100 text-slate-600' => ! in_array($application['status'], ['running', 'deploying', 'idle', 'unreachable', 'error']), 'bg-slate-100 text-slate-600' => ! in_array($compose['status'], ['done', 'deploying', 'pending', 'unreachable', 'error', 'failed']),
])> ])>
{{ ucfirst($application['status']) }} {{ ucfirst($compose['status']) }}
</span> </span>
</div> </div>
@if(isset($application['error'])) @if(isset($compose['error']))
<p class="mt-3 text-xs text-rose-600 dark:text-rose-400">{{ $application['error'] }}</p> <p class="mt-3 text-xs text-rose-600 dark:text-rose-400">{{ $compose['error'] }}</p>
@else @else
<dl class="mt-3 grid grid-cols-3 gap-2 text-xs"> <div class="mt-3 space-y-1">
<div> <p class="text-xs font-semibold text-slate-500 dark:text-slate-400">Services</p>
<dt class="text-slate-500 dark:text-slate-400">CPU</dt> @forelse($compose['services'] as $service)
<dd class="font-semibold text-slate-900 dark:text-white"> <div class="flex items-center justify-between rounded-lg bg-slate-50 px-3 py-1 text-[11px] font-medium text-slate-700 dark:bg-slate-800 dark:text-slate-200">
{{ isset($application['cpu']) ? $application['cpu'].'%' : '—' }} <span>{{ $service['name'] }}</span>
</dd> <span @class([
'rounded-full px-2 py-0.5 text-[10px] uppercase tracking-wide',
'bg-emerald-200/70 text-emerald-900' => 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') }}
</span>
</div> </div>
<div> @empty
<dt class="text-slate-500 dark:text-slate-400">Memory</dt> <p class="text-xs text-slate-500 dark:text-slate-400">No services reported.</p>
<dd class="font-semibold text-slate-900 dark:text-white"> @endforelse
{{ isset($application['memory']) ? $application['memory'].'%' : '—' }}
</dd>
</div> </div>
<div> <p class="mt-3 text-xs text-slate-500 dark:text-slate-400">
<dt class="text-slate-500 dark:text-slate-400">Last Deploy</dt> Last deploy: {{ $compose['last_deploy'] ? \Illuminate\Support\Carbon::parse($compose['last_deploy'])->diffForHumans() : '—' }}
<dd class="font-semibold text-slate-900 dark:text-white"> </p>
{{ $application['last_deploy'] ? \Illuminate\Support\Carbon::parse($application['last_deploy'])->diffForHumans() : '—' }}
</dd>
</div>
</dl>
@endif @endif
</div> </div>
@empty @empty
<p class="text-sm text-slate-500 dark:text-slate-300">No Dokploy applications configured.</p> <p class="text-sm text-slate-500 dark:text-slate-300">No Dokploy compose stacks configured.</p>
@endforelse @endforelse
</div> </div>
</x-filament::section> </x-filament::section>

View File

@@ -7,15 +7,26 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg> </svg>
</div> </div>
@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
<nav class="hidden md:flex space-x-6 items-center"> <nav class="hidden md:flex space-x-6 items-center">
<a href="{{ route('marketing.home', ['locale' => app()->getLocale()]) }}#how-it-works" class="text-gray-600 hover:text-gray-900">{{ __('marketing.nav.how_it_works') }}</a> <a href="{{ route('marketing.home', ['locale' => app()->getLocale()]) }}#how-it-works" class="text-gray-600 hover:text-gray-900">{{ __('marketing.nav.how_it_works') }}</a>
<a href="{{ route('marketing.home', ['locale' => app()->getLocale()]) }}#features" class="text-gray-600 hover:text-gray-900">{{ __('marketing.nav.features') }}</a> <a href="{{ route('marketing.home', ['locale' => app()->getLocale()]) }}#features" class="text-gray-600 hover:text-gray-900">{{ __('marketing.nav.features') }}</a>
<div x-data="{ open: false }" @mouseenter="open = true" @mouseleave="open = false" @click.away="open = false" class="relative"> <div x-data="{ open: false }" @mouseenter="open = true" @mouseleave="open = false" @click.away="open = false" class="relative">
<button class="text-gray-600 hover:text-gray-900" @click.stop="open = !open">{{ __('marketing.nav.occasions') }}</button> <button class="text-gray-600 hover:text-gray-900" @click.stop="open = !open">{{ __('marketing.nav.occasions') }}</button>
<div x-show="open" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0 transform scale-95" x-transition:enter-end="opacity-100 transform scale-100" x-transition:leave="transition ease-in duration-150" x-transition:leave-start="opacity-100 transform scale-100" x-transition:leave-end="opacity-0 transform scale-95" class="absolute top-full left-0 mt-2 bg-white border rounded shadow-lg z-10"> <div x-show="open" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0 transform scale-95" x-transition:enter-end="opacity-100 transform scale-100" x-transition:leave="transition ease-in duration-150" x-transition:leave-start="opacity-100 transform scale-100" x-transition:leave-end="opacity-0 transform scale-95" class="absolute top-full left-0 mt-2 bg-white border rounded shadow-lg z-10">
<a href="{{ route('anlaesse.type', ['locale' => app()->getLocale(), 'type' => 'hochzeit']) }}" class="block px-4 py-2 text-gray-600 hover:text-gray-900 hover:bg-gray-50 transition">{{ __('marketing.nav.occasions_types.weddings') }}</a> <a href="{{ route($occasionRouteName, ['locale' => $currentLocale, 'type' => $occasionSlugs['hochzeit']]) }}" class="block px-4 py-2 text-gray-600 hover:text-gray-900 hover:bg-gray-50 transition">{{ __('marketing.nav.occasions_types.weddings') }}</a>
<a href="{{ route('anlaesse.type', ['locale' => app()->getLocale(), 'type' => 'geburtstag']) }}" class="block px-4 py-2 text-gray-600 hover:text-gray-900 hover:bg-gray-50 transition">{{ __('marketing.nav.occasions_types.birthdays') }}</a> <a href="{{ route($occasionRouteName, ['locale' => $currentLocale, 'type' => $occasionSlugs['geburtstag']]) }}" class="block px-4 py-2 text-gray-600 hover:text-gray-900 hover:bg-gray-50 transition">{{ __('marketing.nav.occasions_types.birthdays') }}</a>
<a href="{{ route('anlaesse.type', ['locale' => app()->getLocale(), 'type' => 'firmenevent']) }}" class="block px-4 py-2 text-gray-600 hover:text-gray-900 hover:bg-gray-50 transition">{{ __('marketing.nav.occasions_types.corporate') }}</a> <a href="{{ route($occasionRouteName, ['locale' => $currentLocale, 'type' => $occasionSlugs['firmenevent']]) }}" class="block px-4 py-2 text-gray-600 hover:text-gray-900 hover:bg-gray-50 transition">{{ __('marketing.nav.occasions_types.corporate') }}</a>
<a href="{{ route($occasionRouteName, ['locale' => $currentLocale, 'type' => $occasionSlugs['konfirmation']]) }}" class="block px-4 py-2 text-gray-600 hover:text-gray-900 hover:bg-gray-50 transition">{{ __('marketing.nav.occasions_types.confirmation') }}</a>
</div> </div>
</div> </div>
<a href="{{ route('blog', ['locale' => app()->getLocale()]) }}" class="text-gray-600 hover:text-gray-900">{{ __('marketing.nav.blog') }}</a> <a href="{{ route('blog', ['locale' => app()->getLocale()]) }}" class="text-gray-600 hover:text-gray-900">{{ __('marketing.nav.blog') }}</a>

View File

@@ -0,0 +1,66 @@
<?php
namespace Tests\Feature\Marketing;
use App\Models\BlogCategory;
use App\Models\BlogPost;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Testing\Fluent\AssertableJson;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;
class BlogIndexTest extends TestCase
{
use RefreshDatabase;
public function test_blog_index_returns_html_excerpts(): void
{
$category = BlogCategory::query()->create([
'slug' => 'blog',
'name' => [
'de' => 'Blog',
'en' => 'Blog',
],
'description' => [
'de' => 'Hochzeitsblog',
'en' => 'Wedding blog',
],
'is_visible' => true,
]);
BlogPost::query()->create([
'blog_category_id' => $category->id,
'slug' => 'markdown-vorschau',
'title' => [
'de' => 'Markdown Vorschau',
'en' => 'Markdown Preview',
],
'excerpt' => [
'de' => '**Fette** Vorschau für Gäste',
'en' => '**Bold** preview for guests',
],
'content' => [
'de' => '## Inhalt',
'en' => '## Content',
],
'is_published' => true,
'published_at' => Carbon::now()->subDay(),
]);
$response = $this->get('/de/blog');
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->component('marketing/Blog')
->has('posts.data', 1, fn (AssertableJson $post) => $post
->where('excerpt', '**Fette** Vorschau für Gäste')
->where('excerpt_html', fn ($value) => is_string($value) && str_contains($value, '<strong>Fette</strong>'))
->etc()
)
->where('posts.links.0.label', __('marketing.blog.pagination.previous', [], 'de'))
->where('posts.links.2.label', __('marketing.blog.pagination.next', [], 'de'))
);
}
}

View File

@@ -0,0 +1,101 @@
<?php
namespace Tests\Feature\Marketing;
use App\Models\BlogCategory;
use App\Models\BlogPost;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;
class BlogShowTest extends TestCase
{
use RefreshDatabase;
public function test_blog_show_returns_headings_and_adjacent_posts(): void
{
$category = BlogCategory::query()->create([
'slug' => 'blog',
'name' => [
'de' => 'Blog',
'en' => 'Blog',
],
'description' => [
'de' => 'Hochzeitsblog',
'en' => 'Wedding blog',
],
'is_visible' => true,
]);
$older = BlogPost::query()->create([
'blog_category_id' => $category->id,
'slug' => 'older-story',
'title' => [
'de' => 'Ältere Story',
'en' => 'Older Story',
],
'excerpt' => [
'de' => '**Alt**',
'en' => '**Old**',
],
'content' => [
'de' => '## Start\nÄlterer Inhalt',
'en' => '## Start\nOlder content',
],
'is_published' => true,
'published_at' => Carbon::parse('2024-01-01'),
]);
$current = BlogPost::query()->create([
'blog_category_id' => $category->id,
'slug' => 'spotlight-story',
'title' => [
'de' => 'Spotlight Story',
'en' => 'Spotlight Story',
],
'excerpt' => [
'de' => 'Zusammenfassung',
'en' => 'Summary',
],
'content' => [
'de' => "## Einleitung\nWillkommen\n### Schritte\nLos geht's",
'en' => "## Intro\nWelcome\n### Steps\nLet's go",
],
'is_published' => true,
'published_at' => Carbon::parse('2024-02-01'),
]);
$newer = BlogPost::query()->create([
'blog_category_id' => $category->id,
'slug' => 'new-story',
'title' => [
'de' => 'Neue Story',
'en' => 'New Story',
],
'excerpt' => [
'de' => 'Neu',
'en' => 'New',
],
'content' => [
'de' => '## Vorschau\nNeuer Inhalt',
'en' => '## Preview\nNew content',
],
'is_published' => true,
'published_at' => Carbon::parse('2024-03-01'),
]);
$response = $this->get('/de/blog/'.$current->slug);
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->component('marketing/BlogShow')
->where('post.previous_post.slug', $older->slug)
->where('post.next_post.slug', $newer->slug)
->where('post.headings.0.text', 'Einleitung')
->where('post.headings.0.slug', fn ($slug) => is_string($slug) && str_contains($slug, 'einleitung'))
->where('post.content_html', fn ($html) => is_string($html) && str_contains($html, 'id="einleitung"'))
);
}
}

7
translate.py Normal file
View File

@@ -0,0 +1,7 @@
import sys
from translate import Translator
language = sys.argv[1]
text = sys.stdin.read().strip()
translator = Translator(to_lang=language)
print(translator.translate(text))

19
translator.py Normal file
View File

@@ -0,0 +1,19 @@
import sys
import json
# Usage: python translator.py <target_lang>
# Expects JSON on stdin: {"text": "..."}
# For now, simple dictionary-based approximation / placeholder logic.
lang = sys.argv[1]
params = json.loads(sys.stdin.read())
text = params.get('text', '')
# TODO: Replace with proper translation logic or API call.
def fake_translate(text, lang):
if lang.lower().startswith('en'):
return text
# For example, a trivial & inaccurate mock transformation for demonstration.
return text[::-1]
print(json.dumps({"translation": fake_translate(text, lang)}))