diff --git a/app/Http/Controllers/Api/EventPublicController.php b/app/Http/Controllers/Api/EventPublicController.php index 7e52fc0..1791ec2 100644 --- a/app/Http/Controllers/Api/EventPublicController.php +++ b/app/Http/Controllers/Api/EventPublicController.php @@ -298,8 +298,8 @@ class EventPublicController extends BaseController return ApiError::response( 'token_rate_limited', - 'Too Many Attempts', - 'Too many invalid join token attempts. Try again later.', + $this->tokenErrorTitle('token_rate_limited'), + __('api.join_tokens.invalid_attempts_message'), Response::HTTP_TOO_MANY_REQUESTS, array_merge($context, ['rate_limiter_key' => $rateLimiterKey]) ); @@ -333,21 +333,22 @@ class EventPublicController extends BaseController private function tokenErrorMessage(string $code): string { return match ($code) { - 'invalid_token' => 'The provided join token is invalid.', - 'token_expired' => 'The join token has expired.', - 'token_revoked' => 'The join token has been revoked.', - default => 'Access denied.', + 'invalid_token' => __('api.join_tokens.invalid_message'), + 'token_expired' => __('api.join_tokens.expired_message'), + 'token_revoked' => __('api.join_tokens.revoked_message'), + 'token_rate_limited' => __('api.join_tokens.rate_limited_message'), + default => __('api.join_tokens.default_message'), }; } private function tokenErrorTitle(string $code): string { return match ($code) { - 'invalid_token' => 'Invalid Join Token', - 'token_expired' => 'Join Token Expired', - 'token_revoked' => 'Join Token Revoked', - 'token_rate_limited' => 'Join Token Rate Limited', - default => 'Access Denied', + 'invalid_token' => __('api.join_tokens.invalid_title'), + 'token_expired' => __('api.join_tokens.expired_title'), + 'token_revoked' => __('api.join_tokens.revoked_title'), + 'token_rate_limited' => __('api.join_tokens.rate_limited_title'), + default => __('api.join_tokens.default_title'), }; } diff --git a/app/Http/Controllers/MarketingController.php b/app/Http/Controllers/MarketingController.php index 0530ca3..bded5fa 100644 --- a/app/Http/Controllers/MarketingController.php +++ b/app/Http/Controllers/MarketingController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers; use App\Mail\ContactConfirmation; use App\Models\BlogPost; +use App\Models\Event; use App\Models\CheckoutSession; use App\Models\Package; use App\Models\PackagePurchase; @@ -14,6 +15,7 @@ use App\Services\Paddle\PaddleCheckoutService; use App\Support\Concerns\PresentsPackages; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Mail; use Illuminate\Support\Str; @@ -258,9 +260,9 @@ class MarketingController extends Controller return null; } - public function blogIndex(Request $request) + public function blogIndex(Request $request, string $locale) { - $locale = $request->get('locale', app()->getLocale()); + $locale = $locale ?: app()->getLocale(); Log::info('Blog Index Debug - Initial', [ 'locale' => $locale, 'full_url' => $request->fullUrl(), @@ -288,31 +290,32 @@ class MarketingController extends Controller Log::info('Blog Index Debug - With Translation', ['count' => $totalWithTranslation, 'locale' => $locale]); $posts = $query->orderBy('published_at', 'desc') - ->paginate(8); - - // Transform posts to include translated strings for the current locale - $posts->getCollection()->transform(function ($post) use ($locale) { - $post->title = $post->getTranslation('title', $locale) ?? $post->getTranslation('title', 'de') ?? ''; - $post->excerpt = $post->getTranslation('excerpt', $locale) ?? $post->getTranslation('excerpt', 'de') ?? ''; - $post->content = $post->getTranslation('content', $locale) ?? $post->getTranslation('content', 'de') ?? ''; - - // Author name is a string, no translation needed; author is loaded via with('author') - return $post; - }); + ->paginate(4) + ->through(function (BlogPost $post) use ($locale) { + 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') ?? '', + 'featured_image' => $post->featured_image ?? $post->banner_url ?? null, + 'published_at' => optional($post->published_at)->toDateString(), + 'author' => $post->author ? ['name' => $post->author->name] : null, + ]; + }); Log::info('Blog Index Debug - Final Posts', [ 'count' => $posts->count(), 'total' => $posts->total(), 'posts_data' => $posts->toArray(), - 'first_post_title' => $posts->count() > 0 ? $posts->first()->title : 'No posts', + 'first_post_title' => $posts->count() > 0 ? ($posts->first()['title'] ?? 'No title') : 'No posts', ]); return Inertia::render('marketing/Blog', compact('posts')); } - public function blogShow($slug) + public function blogShow(string $locale, string $slug) { - $locale = app()->getLocale(); + $locale = $locale ?: app()->getLocale(); $postModel = BlogPost::query() ->with('author') ->whereHas('category', function ($query) { @@ -362,7 +365,33 @@ class MarketingController extends Controller public function demo() { - return Inertia::render('marketing/Demo'); + $joinToken = optional(Event::firstWhere('slug', 'demo-wedding-2025')) + ?->joinTokens() + ->latest('id') + ->first(); + + $demoToken = null; + + if ($joinToken) { + if (! empty($joinToken->token_encrypted)) { + try { + $demoToken = Crypt::decryptString($joinToken->token_encrypted); + } catch (\Throwable $exception) { + Log::warning('Failed to decrypt demo join token', [ + 'token_id' => $joinToken->id, + 'exception' => $exception->getMessage(), + ]); + } + } + + if (! $demoToken) { + $demoToken = $joinToken->metadata['plain_token'] ?? null; + } + } + + return Inertia::render('marketing/Demo', [ + 'demoToken' => $demoToken, + ]); } public function packagesIndex() diff --git a/database/seeders/DemoEventSeeder.php b/database/seeders/DemoEventSeeder.php index ba31106..112ce8e 100644 --- a/database/seeders/DemoEventSeeder.php +++ b/database/seeders/DemoEventSeeder.php @@ -11,6 +11,7 @@ use App\Models\Task; use App\Models\TaskCollection; use App\Models\Tenant; use App\Services\EventJoinTokenService; +use Illuminate\Support\Facades\Crypt; use Illuminate\Database\Seeder; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Schema; @@ -41,6 +42,7 @@ class DemoEventSeeder extends Seeder 'event_type' => $weddingType, 'package' => $standardPackage, 'token_label' => 'Demo QR', + 'token_value' => 'W2E3sbt7yclzpkAwNSARHYTVN1sPLBad8hfUjLVHmjkUviPd', 'collection_slugs' => ['wedding-classics-2025'], 'task_slug_prefix' => 'wedding-', 'branding' => [ @@ -91,7 +93,7 @@ class DemoEventSeeder extends Seeder ] ); - $this->ensureJoinToken($event, $config['token_label']); + $this->ensureJoinToken($event, $config['token_label'], $config['token_value'] ?? null); $this->attachEventPackage( event: $event, @@ -106,13 +108,31 @@ class DemoEventSeeder extends Seeder } } - private function ensureJoinToken(Event $event, string $label): void + private function ensureJoinToken(Event $event, string $label, ?string $token = null): void { if ($event->joinTokens()->exists()) { return; } - app(EventJoinTokenService::class)->createToken($event, ['label' => $label]); + $attributes = ['label' => $label]; + + if ($token) { + $attributes['metadata'] = ['seeded' => true, 'plain_token' => $token]; + } + + $tokenModel = app(EventJoinTokenService::class)->createToken($event, $attributes); + + if ($token) { + $hash = hash('sha256', $token); + $preview = strlen($token) <= 10 ? $token : substr($token, 0, 6).'…'.substr($token, -4); + + $tokenModel->forceFill([ + 'token' => $hash, + 'token_hash' => $hash, + 'token_encrypted' => Crypt::encryptString($token), + 'token_preview' => $preview, + ])->save(); + } } private function attachEventPackage(Event $event, Package $package, Tenant $tenant, string $providerId, Carbon $purchasedAt): void diff --git a/gogs.ini b/gogs.ini deleted file mode 100644 index ce5b428..0000000 --- a/gogs.ini +++ /dev/null @@ -1,2 +0,0 @@ -url=http://192.168.78.2:10880/soeren/fotospiel-app.git -token=d3030b8d95a890e1b611e5bb7519f346b310d012 \ No newline at end of file diff --git a/public/lang/de/legal.json b/public/lang/de/legal.json index d2aba88..ef2f0f1 100644 --- a/public/lang/de/legal.json +++ b/public/lang/de/legal.json @@ -4,7 +4,7 @@ "impressum_title": "Impressum - Fotospiel", "datenschutz_title": "Datenschutzerklärung - Fotospiel", "impressum_section": "Angaben gemäß § 5 TMG", - "company": "Fotospiel GmbH", + "company": "S.E.B. Fotografie", "address": "Musterstraße 1, 12345 Musterstadt", "representative": "Vertreten durch: Max Mustermann", "contact": "Kontakt", @@ -14,7 +14,7 @@ "register_court": "Registergericht: Amtsgericht Musterstadt", "commercial_register": "Handelsregister: HRB 12345", "datenschutz_intro": "Wir nehmen den Schutz Ihrer persönlichen Daten sehr ernst und halten uns strikt an die Regeln der Datenschutzgesetze.", - "responsible": "Verantwortlich: Fotospiel GmbH, Musterstraße 1, 12345 Musterstadt", + "responsible": "Verantwortlich: S.E.B. Fotografie, Musterstraße 1, 12345 Musterstadt", "data_collection": "Datenerfassung: Keine PII-Speicherung, anonyme Sessions für Gäste. E-Mails werden nur für Kontaktzwecke verarbeitet.", "payments": "Zahlungen und Packages", "payments_desc": "Wir verarbeiten Zahlungen für Packages über Paddle. Zahlungsdaten werden als Merchant of Record sicher und verschlüsselt durch Paddle verarbeitet.", diff --git a/public/lang/de/marketing.json b/public/lang/de/marketing.json index 5a565fb..e945577 100644 --- a/public/lang/de/marketing.json +++ b/public/lang/de/marketing.json @@ -379,7 +379,7 @@ "register": "Registrieren" }, "footer": { - "company": "Fotospiel GmbH", + "company": "S.E.B. Fotografie", "rights_reserved": "Alle Rechte vorbehalten" }, "register": { @@ -810,7 +810,9 @@ "good_to_know": "Gut zu wissen", "openDemoFull": "Demo im neuen Tab öffnen", "readyToLaunch": "Bereit für dein Event?", - "readyToLaunchCopy": "Registriere dich kostenlos und lege noch heute dein erstes Event an." + "readyToLaunchCopy": "Registriere dich kostenlos und lege noch heute dein erstes Event an.", + "demoUnavailable": "Demo-Link aktuell nicht verfügbar", + "demoUnavailableCopy": "Führe die Demo-Seeds aus oder kontaktiere uns, damit wir dir sofort einen neuen Zugangscode hinterlegen können." }, "actions": { "tips": "Tipps" diff --git a/public/lang/en/legal.json b/public/lang/en/legal.json index ba5e531..871a660 100644 --- a/public/lang/en/legal.json +++ b/public/lang/en/legal.json @@ -4,7 +4,7 @@ "impressum_title": "Imprint - Fotospiel", "datenschutz_title": "Privacy Policy - Fotospiel", "impressum_section": "Information pursuant to § 5 TMG", - "company": "Fotospiel GmbH", + "company": "S.E.B. Fotografie", "address": "Musterstraße 1, 12345 Musterstadt", "representative": "Represented by: Max Mustermann", "contact": "Contact", @@ -14,7 +14,7 @@ "register_court": "Register Court: District Court Musterstadt", "commercial_register": "Commercial Register: HRB 12345", "datenschutz_intro": "We take the protection of your personal data very seriously and strictly adhere to the rules of data protection laws.", - "responsible": "Responsible: Fotospiel GmbH, Musterstraße 1, 12345 Musterstadt", + "responsible": "Responsible: S.E.B. Fotografie, Musterstraße 1, 12345 Musterstadt", "data_collection": "Data collection: No PII storage, anonymous sessions for guests. Emails are only processed for contact purposes.", "payments": "Payments and Packages", "payments_desc": "We process payments for Packages via Paddle. Payment data is handled securely and encrypted by Paddle as the merchant of record.", diff --git a/public/lang/en/marketing.json b/public/lang/en/marketing.json index fea080a..299943c 100644 --- a/public/lang/en/marketing.json +++ b/public/lang/en/marketing.json @@ -373,7 +373,7 @@ "register": "Register" }, "footer": { - "company": "Fotospiel GmbH", + "company": "S.E.B. Fotografie", "rights_reserved": "All Rights Reserved" }, "register": { @@ -804,7 +804,9 @@ "good_to_know": "Good to know", "openDemoFull": "Open demo in new tab", "readyToLaunch": "Ready to launch?", - "readyToLaunchCopy": "Sign up for free and create your first event today." + "readyToLaunchCopy": "Sign up for free and create your first event today.", + "demoUnavailable": "Demo link currently unavailable", + "demoUnavailableCopy": "Run the demo seeds or ping us so we can attach a fresh access code for you right away." }, "actions": { "tips": "Tips" diff --git a/resources/js/layouts/app/Footer.tsx b/resources/js/layouts/app/Footer.tsx index dd6a222..d388944 100644 --- a/resources/js/layouts/app/Footer.tsx +++ b/resources/js/layouts/app/Footer.tsx @@ -31,7 +31,7 @@ const Footer: React.FC = () => { Die Fotospiel App

- {t('marketing:footer.company', 'Fotospiel GmbH')} + {t('marketing:footer.company', 'S.E.B. Fotografie')}

diff --git a/resources/js/pages/marketing/Blog.tsx b/resources/js/pages/marketing/Blog.tsx index cb08abc..91055e9 100644 --- a/resources/js/pages/marketing/Blog.tsx +++ b/resources/js/pages/marketing/Blog.tsx @@ -1,17 +1,34 @@ -import React from 'react'; -import { Head, Link } from '@inertiajs/react'; +import React from 'react'; +import { Head, Link, usePage } 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'; +import { Alert, AlertDescription } from '@/components/ui/alert'; + +interface PostSummary { + id: number; + slug: string; + title: string; + excerpt?: string; + featured_image?: string; + published_at?: string; + author?: { name?: string } | string; +} + +interface PaginationLink { + url: string | null; + label: string; + active: boolean; +} interface Props { posts: { - data: any[]; - links: any[]; + data: PostSummary[]; + links: PaginationLink[]; current_page: number; last_page: number; }; @@ -19,8 +36,47 @@ interface Props { const Blog: React.FC = ({ posts }) => { const { localizedPath } = useLocalizedRoutes(); + const { props } = usePage<{ supportedLocales?: string[] }>(); + const supportedLocales = props.supportedLocales && props.supportedLocales.length > 0 ? props.supportedLocales : ['de', 'en']; const { t, i18n } = useTranslation('marketing'); - const locale = i18n.language || 'de'; // Fallback to 'de' if no locale + const locale = i18n.language || 'de'; + const articles = posts?.data ?? []; + const isLandingLayout = posts.current_page === 1; + const featuredPost = isLandingLayout ? articles[0] : null; + const gridPosts = isLandingLayout ? articles.slice(1, 4) : []; + const listPosts = isLandingLayout ? [] : articles; + const dateLocale = locale === 'en' ? 'en-US' : 'de-DE'; + + const buildArticleHref = React.useCallback( + (slug?: string | null) => { + if (!slug) { + return '#'; + } + + return localizedPath(`/blog/${encodeURIComponent(slug)}`); + }, + [localizedPath] + ); + + const formatPublishedDate = React.useCallback( + (raw?: string) => { + if (!raw) { + return ''; + } + + try { + return new Date(raw).toLocaleDateString(dateLocale, { + day: 'numeric', + month: 'long', + year: 'numeric', + }); + } catch (error) { + console.warn('[Marketing Blog] Unable to parse date', { raw, error }); + return raw; + } + }, + [dateLocale] + ); const resolvePaginationHref = React.useCallback( (raw?: string | null) => { @@ -35,6 +91,12 @@ const Blog: React.FC = ({ posts }) => { ); const normalized = `${parsed.pathname}${parsed.search}`; + const localePrefixRegex = new RegExp(`^/(${supportedLocales.join('|')})(/|$)`); + + if (localePrefixRegex.test(parsed.pathname)) { + return normalized; + } + return localizedPath(normalized); } catch (error) { console.warn('[Marketing Blog] Fallback resolving pagination link', { raw, error }); @@ -45,11 +107,13 @@ const Blog: React.FC = ({ posts }) => { ); const renderPagination = () => { - if (!posts.links || posts.links.length <= 3) return null; + if (!posts.links || posts.links.length <= 3) { + return null; + } return (
- +
{posts.links.map((link, index) => { @@ -59,9 +123,9 @@ const Blog: React.FC = ({ posts }) => { return ( ); })} @@ -88,96 +149,168 @@ const Blog: React.FC = ({ posts }) => { ); }; + const renderPostMeta = (post: PostSummary) => ( +
+ + {t('blog.by')} {typeof post.author === 'string' ? post.author : post.author?.name || t('blog.team')} + + + + {t('blog.published_at')} {formatPublishedDate(post.published_at)} + +
+ ); + + const renderLandingGrid = () => { + if (!featuredPost) { + return ( + + + {t('blog.empty')} + + + ); + } + + return ( +
+
+
+ + {t('blog.posts_title')} + +

+ {featuredPost.title || 'Untitled'} +

+

+ {featuredPost.excerpt || ''} +

+ {renderPostMeta(featuredPost)} + +
+
+
+ {featuredPost.featured_image && ( + {featuredPost.title} + )} + {!featuredPost.featured_image && ( +
+ Fotospiel Stories +
+ )} +
+
+
+ + {gridPosts.length > 0 && ( +
+ {gridPosts.map((post) => ( + + {post.featured_image && ( +
+ {post.title} +
+ )} + +
+

+ {post.title || 'Untitled'} +

+

+ {post.excerpt || ''} +

+
+
+ {renderPostMeta(post)} + + + + ))} +
+ )} +
+ ); + }; + + const renderListView = () => ( +
+ {listPosts.map((post) => ( + +
+ {post.featured_image && ( + {post.title} + )} + +
+

+ {post.title || 'Untitled'} +

+

{post.excerpt || ''}

+ {renderPostMeta(post)} + +
+
+
+
+ ))} + {listPosts.length === 0 && ( + {t('blog.empty')} + )} +
+ ); + return ( - {/* Hero Section */} -
-
- - -

{t('blog.hero_title')}

-

{t('blog.hero_description')}

- -
-
+ +
+
+ + Fotospiel Blog + +

{t('blog.hero_title')}

+

{t('blog.hero_description')}

+
+ + +
- {/* Posts Section */} -
-
-
-

{t('blog.posts_title')}

- +
+
+
+

{t('blog.posts_title')}

+
- {posts.data.length > 0 ? ( - <> -
- {posts.data.map((post) => ( - - {post.featured_image && ( -
- {post.title} -
- )} - - - - {post.title?.[locale] || post.title?.de || post.title || 'No Title'} - - + {isLandingLayout ? renderLandingGrid() : renderListView()} -

- {post.excerpt?.[locale] || post.excerpt?.de || post.excerpt || 'No Excerpt'} -

- -
- - {t('blog.by')} {post.author?.name?.[locale] || post.author?.name?.de || post.author?.name || t('blog.team')} - - - - {t('blog.published_at')} {new Date(post.published_at).toLocaleDateString('de-DE', { - day: 'numeric', - month: 'long', - year: 'numeric' - })} - -
- - -
-
- ))} -
- {renderPagination()} - - ) : ( - -

{t('blog.empty')}

-
- )} + {renderPagination()}
diff --git a/resources/js/pages/marketing/Demo.tsx b/resources/js/pages/marketing/Demo.tsx index cc2095e..921e23b 100644 --- a/resources/js/pages/marketing/Demo.tsx +++ b/resources/js/pages/marketing/Demo.tsx @@ -1,125 +1,148 @@ -import React from 'react'; -import { Head, Link } from '@inertiajs/react'; -import { useTranslation } from 'react-i18next'; -import MarketingLayout from '@/layouts/mainWebsite'; -import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { Button } from '@/components/ui/button'; -import { Badge } from '@/components/ui/badge'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; -import { Sparkles, CheckCircle2 } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes'; +import MarketingLayout from '@/layouts/mainWebsite'; +import { Head, Link } from '@inertiajs/react'; +import { CheckCircle2, Sparkles } from 'lucide-react'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; type DemoFeature = { title: string; description: string }; -const DEMO_TOKEN = 'mdhyA5XSVEVEabF8JhZ85B6fMocyyRMTfmThSeUKPzk7LLTu'; +interface DemoPageProps { + demoToken?: string | null; +} -const DemoPage: React.FC = () => { - const { t } = useTranslation('marketing'); - const { localizedPath } = useLocalizedRoutes(); +const DemoPage: React.FC = ({ demoToken }) => { + const { t } = useTranslation('marketing'); + const { localizedPath } = useLocalizedRoutes(); + const embedUrl = demoToken ? `/e/${demoToken}` : null; - const demo = t('demo_page', { returnObjects: true }) as { - title: string; - subtitle: string; - primaryCta: string; - secondaryCta: string; - iframeNote: string; - openFull: string; - features: DemoFeature[]; - }; + const demo = t('demo_page', { returnObjects: true }) as { + title: string; + subtitle: string; + primaryCta: string; + secondaryCta: string; + iframeNote: string; + openFull: string; + features: DemoFeature[]; + }; - return ( - - + return ( + + -
-
-
-
- - Demo Live - -

- {demo.title} -

-

- {demo.subtitle} -

-
- - -
-
-
-
-
-