diff --git a/app/Filament/Resources/PackageResource.php b/app/Filament/Resources/PackageResource.php index 0ae7bad..8b0fd38 100644 --- a/app/Filament/Resources/PackageResource.php +++ b/app/Filament/Resources/PackageResource.php @@ -4,22 +4,26 @@ namespace App\Filament\Resources; use App\Filament\Resources\PackageResource\Pages; use App\Models\Package; -use Filament\Forms; -use Filament\Resources\Resource; -use Filament\Tables; -use Filament\Tables\Table; -use Filament\Actions\Action; -use Filament\Forms\Components\TextInput; -use Filament\Forms\Components\Select; -use Filament\Forms\Components\Toggle; -use Filament\Forms\Components\Repeater; -use Filament\Schemas\Schema; -use Filament\Tables\Columns\TextColumn; -use Filament\Tables\Columns\IconColumn; -use Filament\Actions\EditAction; -use Filament\Actions\DeleteAction; use Filament\Actions\BulkActionGroup; +use Filament\Actions\DeleteAction; use Filament\Actions\DeleteBulkAction; +use Filament\Actions\EditAction; +use Filament\Actions\ViewAction; +use Filament\Forms\Components\CheckboxList; +use Filament\Forms\Components\MarkdownEditor; +use Filament\Forms\Components\Repeater; +use Filament\Schemas\Components\Section; +use Filament\Forms\Components\Select; +use Filament\Schemas\Components\Tabs as SchemaTabs; +use Filament\Schemas\Components\Tabs\Tab as SchemaTab; +use Filament\Forms\Components\TextInput; +use Filament\Forms\Components\Toggle; +use Filament\Resources\Resource; +use Filament\Schemas\Schema; +use Filament\Tables; +use Filament\Tables\Columns\BadgeColumn; +use Filament\Tables\Columns\TextColumn; +use Filament\Tables\Table; use UnitEnum; use BackedEnum; @@ -35,190 +39,141 @@ class PackageResource extends Resource public static function form(Schema $schema): Schema { + $featureOptions = static::featureLabelMap(); + return $schema->schema([ - TextInput::make('name') - ->label('Name') - ->required() - ->maxLength(255), - Select::make('type') - ->label('Type') - ->options([ - 'endcustomer' => 'Endcustomer', - 'reseller' => 'Reseller', - ]) - ->required(), - TextInput::make('price') - ->label('Price') - ->prefix('€') - ->numeric() - ->step(0.01) - ->required() - ->default(0), - TextInput::make('max_photos') - ->label('Max Photos') - ->numeric() - ->nullable(), - TextInput::make('max_guests') - ->label('Max Guests') - ->numeric() - ->nullable(), - TextInput::make('gallery_days') - ->label('Gallery Days') - ->numeric() - ->nullable(), - TextInput::make('max_tasks') - ->label('Max Tasks') - ->numeric() - ->nullable(), - Toggle::make('watermark_allowed') - ->label('Watermark Allowed') - ->default(true), - Toggle::make('branding_allowed') - ->label('Branding Allowed') - ->default(false), - TextInput::make('max_events_per_year') - ->label('Max Events per Year') - ->numeric() - ->nullable(), - Repeater::make('features') - ->label('Features') - ->schema([ - TextInput::make('key') - ->label('Feature Key'), - TextInput::make('value') - ->label('Feature Value'), - ]) - ->columns(2) - ->defaultItems(0), - ]); - } - - - public static function featuresToRepeaterItems(mixed $features): array - { - if (is_string($features)) { - $decoded = json_decode($features, true); - - if (is_string($decoded)) { - $decoded = json_decode($decoded, true); - } - - $features = json_last_error() === JSON_ERROR_NONE ? $decoded : null; - } - - if ($features === null) { - return []; - } - - if (! is_array($features)) { - return []; - } - - if (! array_is_list($features)) { - return collect($features) - ->map(function ($value, $key) { - return [ - 'key' => (string) $key, - 'value' => is_bool($value) ? ($value ? 'true' : 'false') : (string) $value, - ]; - }) - ->values() - ->all(); - } - - return collect($features) - ->map(function ($item) { - if (is_array($item)) { - return [ - 'key' => (string) ($item['key'] ?? ''), - 'value' => (string) ($item['value'] ?? ''), - ]; - } - - return [ - 'key' => (string) $item, - 'value' => 'true', - ]; - }) - ->values() - ->all(); - } - - public static function featuresFromRepeaterItems(mixed $items): array - { - if (! is_array($items)) { - return []; - } - - $features = []; - - foreach ($items as $item) { - if (! is_array($item)) { - continue; - } - - $key = isset($item['key']) ? trim((string) $item['key']) : ''; - - if ($key === '') { - continue; - } - - $value = $item['value'] ?? true; - - if (is_string($value)) { - $normalized = strtolower(trim($value)); - - if (in_array($normalized, ['1', 'true', 'yes', 'on'], true)) { - $value = true; - } elseif (in_array($normalized, ['0', 'false', 'no', 'off'], true)) { - $value = false; - } - } - - if (is_array($value)) { - $value = $value['value'] ?? $value['enabled'] ?? true; - } - - $features[$key] = (bool) $value; - } - - return $features; + SchemaTabs::make('translations') + ->columnSpanFull() + ->tabs([ + SchemaTab::make('Deutsch') + ->schema([ + TextInput::make('name_translations.de') + ->label('Name (DE)') + ->required() + ->maxLength(255), + MarkdownEditor::make('description_translations.de') + ->label('Beschreibung (DE)') + ->required() + ->columnSpanFull(), + ]), + SchemaTab::make('English') + ->schema([ + TextInput::make('name_translations.en') + ->label('Name (EN)') + ->required() + ->maxLength(255), + MarkdownEditor::make('description_translations.en') + ->label('Description (EN)') + ->required() + ->columnSpanFull(), + ]), + ]), + Section::make('Allgemeine Einstellungen') + ->columns(3) + ->schema([ + TextInput::make('slug') + ->label('Slug') + ->required() + ->maxLength(191) + ->unique(ignoreRecord: true), + Select::make('type') + ->label('Paket-Typ') + ->options([ + 'endcustomer' => 'Endkunde', + 'reseller' => 'Reseller', + ]) + ->required(), + TextInput::make('price') + ->label('Preis') + ->numeric() + ->step(0.01) + ->prefix('€') + ->required(), + TextInput::make('max_photos') + ->label('Max. Fotos') + ->numeric() + ->minValue(0) + ->nullable(), + TextInput::make('max_guests') + ->label('Max. Gäste') + ->numeric() + ->minValue(0) + ->nullable(), + TextInput::make('gallery_days') + ->label('Galeriedauer (Tage)') + ->numeric() + ->minValue(0) + ->nullable(), + TextInput::make('max_tasks') + ->label('Max. Fotoaufgaben') + ->numeric() + ->minValue(0) + ->nullable(), + TextInput::make('max_events_per_year') + ->label('Events pro Jahr') + ->numeric() + ->minValue(0) + ->nullable() + ->visible(fn ($get) => $get('type') === 'reseller'), + Toggle::make('watermark_allowed') + ->label('Wasserzeichen erlaubt') + ->default(true), + Toggle::make('branding_allowed') + ->label('Eigenes Branding erlaubt') + ->default(false), + ]), + Section::make('Features & Kennzahlen') + ->columns(1) + ->schema([ + CheckboxList::make('features') + ->label('Aktive Features') + ->options($featureOptions) + ->columns(2) + ->default([]), + Repeater::make('description_table') + ->label('Kenndaten') + ->schema([ + TextInput::make('title') + ->label('Titel') + ->maxLength(255), + TextInput::make('value') + ->label('Wert / Beschreibung') + ->maxLength(255), + ]) + ->addActionLabel('Eintrag hinzufügen') + ->reorderable() + ->columnSpanFull() + ->default([]), + ]), + ]); } public static function formatFeaturesForDisplay(mixed $features): string { - $map = $features; - - if (! is_array($map)) { - if (is_string($map)) { - $decoded = json_decode($map, true); - - if (is_string($decoded)) { - $decoded = json_decode($decoded, true); - } - - $map = json_last_error() === JSON_ERROR_NONE ? $decoded : []; - } else { - $map = []; + if (is_string($features)) { + $decoded = json_decode($features, true); + if (json_last_error() === JSON_ERROR_NONE) { + $features = $decoded; } } - if (! array_is_list($map)) { - return collect($map) - ->filter(fn ($value) => (bool) $value) - ->keys() + if (! is_array($features)) { + return ''; + } + + $labels = static::featureLabelMap(); + + if (array_is_list($features)) { + return collect($features) + ->filter(fn ($value) => is_string($value) && $value !== '') + ->map(fn ($value) => $labels[$value] ?? $value) ->implode(', '); } - return collect($map) - ->map(function ($item) { - if (is_array($item)) { - return (string) ($item['key'] ?? ''); - } - - return (string) $item; - }) - ->filter() + return collect($features) + ->filter(fn ($value) => (bool) $value) + ->keys() + ->map(fn ($value) => $labels[$value] ?? $value) ->implode(', '); } @@ -226,35 +181,45 @@ class PackageResource extends Resource { return $table ->columns([ - TextColumn::make('name') - ->label('Name') + TextColumn::make('name_translations.de') + ->label('Name (DE)') ->searchable() ->sortable(), - TextColumn::make('type') - ->label('Type') - ->badge() - ->color(fn (string $state): string => match ($state) { - 'endcustomer' => 'info', - 'reseller' => 'warning', - default => 'gray', - }), + TextColumn::make('name_translations.en') + ->label('Name (EN)') + ->toggleable(isToggledHiddenByDefault: true), + BadgeColumn::make('type') + ->label('Typ') + ->colors([ + 'info' => 'endcustomer', + 'warning' => 'reseller', + ]), TextColumn::make('price') - ->label('Price') + ->label('Preis') ->money('EUR') ->sortable(), - IconColumn::make('max_photos') - ->label('Max Photos') - ->icon('heroicon-o-photo') - ->color('primary'), + TextColumn::make('max_photos') + ->label('Fotos') + ->sortable(), + TextColumn::make('max_guests') + ->label('Gäste') + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), TextColumn::make('features') ->label('Features') - ->formatStateUsing(fn ($state) => static::formatFeaturesForDisplay($state)) - ->limit(50), + ->wrap() + ->formatStateUsing(fn ($state) => static::formatFeaturesForDisplay($state)), ]) ->filters([ - // + Tables\Filters\SelectFilter::make('type') + ->label('Typ') + ->options([ + 'endcustomer' => 'Endkunde', + 'reseller' => 'Reseller', + ]), ]) ->actions([ + ViewAction::make(), EditAction::make(), DeleteAction::make(), ]) @@ -265,13 +230,6 @@ class PackageResource extends Resource ]); } - public static function getRelations(): array - { - return [ - // - ]; - } - public static function getPages(): array { return [ @@ -280,4 +238,19 @@ class PackageResource extends Resource 'edit' => Pages\EditPackage::route('/{record}/edit'), ]; } + + protected static function featureLabelMap(): array + { + return [ + 'basic_uploads' => 'Basis-Uploads', + 'unlimited_sharing' => 'Unbegrenztes Teilen', + 'no_watermark' => 'Kein Wasserzeichen', + 'custom_branding' => 'Eigenes Branding', + 'custom_tasks' => 'Eigene Aufgaben', + 'advanced_analytics' => 'Erweiterte Analytics', + 'advanced_reporting' => 'Erweiterte Reports', + 'live_slideshow' => 'Live-Slideshow', + 'priority_support' => 'Priorisierter Support', + ]; + } } diff --git a/app/Filament/Resources/PackageResource/Pages/CreatePackage.php b/app/Filament/Resources/PackageResource/Pages/CreatePackage.php index 8bd4a1a..2111007 100644 --- a/app/Filament/Resources/PackageResource/Pages/CreatePackage.php +++ b/app/Filament/Resources/PackageResource/Pages/CreatePackage.php @@ -8,12 +8,4 @@ use Filament\Resources\Pages\CreateRecord; class CreatePackage extends CreateRecord { protected static string $resource = PackageResource::class; - - protected function mutateFormDataBeforeCreate(array $data): array - { - $data['features'] = PackageResource::featuresFromRepeaterItems($data['features'] ?? []); - - return $data; - } - } diff --git a/app/Filament/Resources/PackageResource/Pages/EditPackage.php b/app/Filament/Resources/PackageResource/Pages/EditPackage.php index c6763b9..b899994 100644 --- a/app/Filament/Resources/PackageResource/Pages/EditPackage.php +++ b/app/Filament/Resources/PackageResource/Pages/EditPackage.php @@ -17,19 +17,4 @@ class EditPackage extends EditRecord Actions\DeleteAction::make(), ]; } - - protected function mutateFormDataBeforeFill(array $data): array - { - $data['features'] = PackageResource::featuresToRepeaterItems($data['features'] ?? null); - - return $data; - } - - protected function mutateFormDataBeforeSave(array $data): array - { - $data['features'] = PackageResource::featuresFromRepeaterItems($data['features'] ?? []); - - return $data; - } - } diff --git a/app/Http/Controllers/LegalPageController.php b/app/Http/Controllers/LegalPageController.php new file mode 100644 index 0000000..a9f9492 --- /dev/null +++ b/app/Http/Controllers/LegalPageController.php @@ -0,0 +1,90 @@ +resolveSlug($slug); + + $page = LegalPage::query() + ->where('slug', $resolvedSlug) + ->where('is_published', true) + ->orderByDesc('version') + ->first(); + + if (! $page) { + abort(404); + } + + $locale = app()->getLocale(); + $title = $page->title[$locale] + ?? $page->title[$page->locale_fallback] + ?? $page->title['de'] + ?? $page->title['en'] + ?? Str::title($resolvedSlug); + + $bodyMarkdown = $page->body_markdown[$locale] + ?? $page->body_markdown[$page->locale_fallback] + ?? reset($page->body_markdown) + ?? ''; + + $effectiveFrom = optional($page->effective_from); + + return Inertia::render('legal/Show', [ + 'seoTitle' => $title . ' - ' . config('app.name', 'Fotospiel'), + 'title' => $title, + 'content' => $this->convertMarkdownToHtml($bodyMarkdown), + 'effectiveFrom' => $effectiveFrom ? $effectiveFrom->toDateString() : null, + 'effectiveFromLabel' => $effectiveFrom + ? __('legal.effective_from', ['date' => $effectiveFrom->translatedFormat('d. F Y')]) + : null, + 'versionLabel' => __('legal.version', ['version' => $page->version]), + 'slug' => $resolvedSlug, + ]); + } + + private function resolveSlug(?string $slug): string + { + $slug = strtolower($slug ?? ''); + + $aliases = [ + 'imprint' => 'impressum', + 'privacy' => 'datenschutz', + 'terms' => 'agb', + ]; + + return $aliases[$slug] ?? $slug ?: 'impressum'; + } + + private function convertMarkdownToHtml(string $markdown): string + { + $environment = new Environment([ + 'html_input' => 'strip', + 'allow_unsafe_links' => false, + ]); + + $environment->addExtension(new CommonMarkCoreExtension()); + $environment->addExtension(new TableExtension()); + $environment->addExtension(new AutolinkExtension()); + $environment->addExtension(new StrikethroughExtension()); + $environment->addExtension(new TaskListExtension()); + + $converter = new MarkdownConverter($environment); + + return trim((string) $converter->convert($markdown)); + } +} diff --git a/app/Http/Controllers/MarketingController.php b/app/Http/Controllers/MarketingController.php index f93de4d..d847817 100644 --- a/app/Http/Controllers/MarketingController.php +++ b/app/Http/Controllers/MarketingController.php @@ -480,14 +480,22 @@ class MarketingController extends Controller public function packagesIndex() { - $endcustomerPackages = Package::where('type', 'endcustomer')->orderBy('price')->get()->map(function ($p) { - return $p->append(['features', 'limits']); - }); - $resellerPackages = Package::where('type', 'reseller')->orderBy('price')->get()->map(function ($p) { - return $p->append(['features', 'limits']); - }); + $endcustomerPackages = Package::where('type', 'endcustomer') + ->orderBy('price') + ->get() + ->map(fn (Package $package) => $this->presentPackage($package)) + ->values(); - return Inertia::render('marketing/Packages', compact('endcustomerPackages', 'resellerPackages')); + $resellerPackages = Package::where('type', 'reseller') + ->orderBy('price') + ->get() + ->map(fn (Package $package) => $this->presentPackage($package)) + ->values(); + + return Inertia::render('marketing/Packages', [ + 'endcustomerPackages' => $endcustomerPackages, + 'resellerPackages' => $resellerPackages, + ]); } public function occasionsType($type) @@ -508,5 +516,170 @@ class MarketingController extends Controller return Inertia::render('marketing/Occasions', ['type' => $type]); } -} + private function presentPackage(Package $package): array + { + $package->append('limits'); + + $packageArray = $package->toArray(); + $features = $packageArray['features'] ?? []; + $features = $this->normaliseFeatures($features); + + $locale = app()->getLocale(); + $name = $this->resolveTranslation($package->name_translations ?? null, $package->name ?? '', $locale); + $descriptionTemplate = $this->resolveTranslation($package->description_translations ?? null, $package->description ?? '', $locale); + + $replacements = $this->buildPlaceholderReplacements($package); + + $description = trim($this->applyPlaceholders($descriptionTemplate, $replacements)); + + $table = $package->description_table ?? []; + if (is_string($table)) { + $decoded = json_decode($table, true); + $table = is_array($decoded) ? $decoded : []; + } + + $table = array_map(function (array $row) use ($replacements) { + return [ + 'title' => trim($this->applyPlaceholders($row['title'] ?? '', $replacements)), + 'value' => trim($this->applyPlaceholders($row['value'] ?? '', $replacements)), + ]; + }, $table); + $table = array_values($table); + + $galleryDuration = $replacements['{{gallery_duration}}'] ?? null; + + return [ + 'id' => $package->id, + 'name' => $name, + 'slug' => $package->slug, + 'type' => $package->type, + 'price' => $package->price, + 'description' => $description, + 'description_breakdown' => $table, + 'gallery_duration_label' => $galleryDuration, + 'events' => $package->type === 'endcustomer' ? 1 : ($package->max_events_per_year ?? null), + 'features' => $features, + 'limits' => $package->limits, + 'max_photos' => $package->max_photos, + 'max_guests' => $package->max_guests, + 'max_tasks' => $package->max_tasks, + 'gallery_days' => $package->gallery_days, + 'max_events_per_year' => $package->max_events_per_year, + 'watermark_allowed' => (bool) $package->watermark_allowed, + 'branding_allowed' => (bool) $package->branding_allowed, + ]; + } + + private function buildPlaceholderReplacements(Package $package): array + { + $locale = app()->getLocale(); + + return [ + '{{max_photos}}' => $this->formatCount($package->max_photos, [ + 'de' => 'unbegrenzt viele', + 'en' => 'unlimited', + ]), + '{{max_guests}}' => $this->formatCount($package->max_guests, [ + 'de' => 'beliebig viele', + 'en' => 'any number of', + ]), + '{{max_tasks}}' => $this->formatCount($package->max_tasks, [ + 'de' => 'individuelle', + 'en' => 'custom', + ]), + '{{max_events_per_year}}' => $this->formatCount($package->max_events_per_year, [ + 'de' => 'unbegrenzte', + 'en' => 'unlimited', + ]), + '{{gallery_duration}}' => $this->formatGalleryDuration($package->gallery_days), + ]; + } + + private function applyPlaceholders(string $template, array $replacements): string + { + if ($template === '') { + return $template; + } + + return str_replace(array_keys($replacements), array_values($replacements), $template); + } + + private function formatCount(?int $value, array $fallbackByLocale): string + { + $locale = app()->getLocale(); + + if ($value === null) { + return $fallbackByLocale[$locale] ?? reset($fallbackByLocale) ?? ''; + } + + $decimal = $locale === 'de' ? ',' : '.'; + $thousands = $locale === 'de' ? '.' : ','; + + return number_format($value, 0, $decimal, $thousands); + } + + private function formatGalleryDuration(?int $days): string + { + $locale = app()->getLocale(); + + if (!$days || $days <= 0) { + return $locale === 'en' ? 'permanent' : 'dauerhaft'; + } + + if ($days % 30 === 0) { + $months = (int) ($days / 30); + if ($locale === 'en') { + return $months === 1 ? '1 month' : $months . ' months'; + } + + return $months === 1 ? '1 Monat' : $months . ' Monate'; + } + + return $locale === 'en' ? $days . ' days' : $days . ' Tage'; + } + + private function normaliseFeatures(mixed $features): array + { + if (is_string($features)) { + $decoded = json_decode($features, true); + if (json_last_error() === JSON_ERROR_NONE) { + $features = $decoded; + } + } + + if (! is_array($features)) { + return []; + } + + $list = []; + foreach ($features as $key => $value) { + if (is_string($value)) { + $list[] = $value; + continue; + } + + if (is_string($key) && (bool) $value) { + $list[] = $key; + } + } + + return array_values(array_unique(array_filter($list, fn ($item) => is_string($item) && $item !== ''))); + } + + private function resolveTranslation(mixed $value, string $fallback, string $locale): string + { + if (is_string($value)) { + $decoded = json_decode($value, true); + if (json_last_error() === JSON_ERROR_NONE) { + $value = $decoded; + } + } + + if (is_array($value)) { + return trim((string) ($value[$locale] ?? $value['en'] ?? $value['de'] ?? $fallback)); + } + + return trim((string) ($value ?? $fallback)); + } +} diff --git a/app/Models/Package.php b/app/Models/Package.php index c447d23..a43fcdc 100644 --- a/app/Models/Package.php +++ b/app/Models/Package.php @@ -13,6 +13,8 @@ class Package extends Model protected $fillable = [ 'name', + 'name_translations', + 'slug', 'type', 'price', 'max_photos', @@ -25,6 +27,8 @@ class Package extends Model 'expires_after', 'features', 'description', + 'description_translations', + 'description_table', ]; protected $casts = [ @@ -38,6 +42,9 @@ class Package extends Model 'watermark_allowed' => 'boolean', 'branding_allowed' => 'boolean', 'features' => 'array', + 'name_translations' => 'array', + 'description_translations' => 'array', + 'description_table' => 'array', ]; diff --git a/database/migrations/2025_10_17_000001_add_description_table_to_packages.php b/database/migrations/2025_10_17_000001_add_description_table_to_packages.php new file mode 100644 index 0000000..7f6bc9c --- /dev/null +++ b/database/migrations/2025_10_17_000001_add_description_table_to_packages.php @@ -0,0 +1,26 @@ +json('description_table')->nullable()->after('description'); + } + }); + } + + public function down(): void + { + Schema::table('packages', function (Blueprint $table) { + if (Schema::hasColumn('packages', 'description_table')) { + $table->dropColumn('description_table'); + } + }); + } +}; diff --git a/database/migrations/2025_10_17_000002_add_translation_columns_to_packages.php b/database/migrations/2025_10_17_000002_add_translation_columns_to_packages.php new file mode 100644 index 0000000..27f5892 --- /dev/null +++ b/database/migrations/2025_10_17_000002_add_translation_columns_to_packages.php @@ -0,0 +1,57 @@ +json('name_translations')->nullable()->after('name'); + } + + if (! Schema::hasColumn('packages', 'description_translations')) { + $table->json('description_translations')->nullable()->after('description'); + } + }); + + // Bootstrap existing values into the new translation columns + if (Schema::hasColumn('packages', 'name')) { + DB::table('packages')->select('id', 'name', 'description')->get()->each(function ($package) { + $nameTranslations = [ + 'de' => $package->name, + 'en' => $package->name, + ]; + + $descriptionTranslations = [ + 'de' => $package->description ?? '', + 'en' => $package->description ?? '', + ]; + + DB::table('packages') + ->where('id', $package->id) + ->update([ + 'name_translations' => json_encode($nameTranslations, JSON_UNESCAPED_UNICODE), + 'description_translations' => json_encode($descriptionTranslations, JSON_UNESCAPED_UNICODE), + ]); + }); + } + } + + public function down(): void + { + Schema::table('packages', function (Blueprint $table) { + if (Schema::hasColumn('packages', 'name_translations')) { + $table->dropColumn('name_translations'); + } + + if (Schema::hasColumn('packages', 'description_translations')) { + $table->dropColumn('description_translations'); + } + }); + } +}; diff --git a/database/seeders/PackageSeeder.php b/database/seeders/PackageSeeder.php index dfb089f..f819157 100644 --- a/database/seeders/PackageSeeder.php +++ b/database/seeders/PackageSeeder.php @@ -5,7 +5,6 @@ namespace Database\Seeders; use Illuminate\Database\Seeder; use App\Models\Package; use App\Enums\PackageType; -use Illuminate\Support\Str; class PackageSeeder extends Seeder { @@ -14,106 +13,265 @@ class PackageSeeder extends Seeder */ public function run(): void { - // Endcustomer Packages - Package::create([ - 'name' => 'Free / Test', - 'slug' => 'free-package', - 'type' => PackageType::ENDCUSTOMER, - 'price' => 0.00, - 'max_photos' => 30, - 'max_guests' => 50, - 'gallery_days' => 7, - 'max_tasks' => 5, - 'watermark_allowed' => true, - 'branding_allowed' => false, - 'features' => json_encode([ - 'basic_uploads' => true, - 'limited_sharing' => true, - 'no_branding' => true, - ]), - ]); - - Package::create([ - 'name' => 'Starter', - 'slug' => Str::slug('Starter'), - 'type' => PackageType::ENDCUSTOMER, - 'price' => 29.00, - 'max_photos' => 200, - 'max_guests' => 100, - 'gallery_days' => 30, - 'max_tasks' => 10, - 'watermark_allowed' => true, - 'branding_allowed' => false, - 'features' => json_encode([ - 'basic_uploads' => true, - 'unlimited_sharing' => true, - 'no_watermark' => true, - 'custom_tasks' => true, - ]), - ]); - - Package::create([ - 'name' => 'Pro', - 'slug' => Str::slug('Pro'), - 'type' => PackageType::ENDCUSTOMER, - 'price' => 79.00, - 'max_photos' => 1000, - 'max_guests' => 500, - 'gallery_days' => 90, - 'max_tasks' => 20, - 'watermark_allowed' => false, - 'branding_allowed' => false, - 'features' => json_encode([ - 'basic_uploads' => true, - 'unlimited_sharing' => true, - 'no_watermark' => true, - 'custom_tasks' => true, - 'advanced_analytics' => true, - 'priority_support' => true, - ]), - ]); - - // Reseller Packages - Package::create([ - 'name' => 'S (Small Reseller)', - 'slug' => Str::slug('S (Small Reseller)'), - 'type' => PackageType::RESELLER, - 'price' => 199.00, - 'max_photos' => 500, // per event limit - 'max_guests' => null, // unlimited - 'gallery_days' => null, - 'max_tasks' => null, // unlimited - 'watermark_allowed' => true, - 'branding_allowed' => true, - 'max_events_per_year' => 5, - 'expires_after' => now()->addYear(), - 'features' => json_encode([ - 'reseller_dashboard' => true, - 'custom_branding' => true, - 'priority_support' => true, - ]), - ]); - - Package::create([ - 'name' => 'M (Medium Reseller)', - 'slug' => Str::slug('M (Medium Reseller)'), - 'type' => PackageType::RESELLER, - 'price' => 399.00, - 'max_photos' => 1000, // per event limit - 'max_guests' => null, // unlimited - 'gallery_days' => null, - 'max_tasks' => null, // unlimited - 'watermark_allowed' => true, - 'branding_allowed' => true, - 'max_events_per_year' => 15, - 'expires_after' => now()->addYear(), - 'features' => json_encode([ - 'reseller_dashboard' => true, - 'custom_branding' => true, - 'priority_support' => true, - 'advanced_reporting' => true, - ]), - ]); + $packages = [ + [ + 'slug' => 'free-package', + 'name' => 'Free / Test', + 'name_translations' => [ + 'de' => 'Free / Test', + 'en' => 'Free / Test', + ], + 'type' => PackageType::ENDCUSTOMER, + 'price' => 0.00, + 'max_photos' => 120, + 'max_guests' => 25, + 'gallery_days' => 7, + 'max_tasks' => 8, + 'watermark_allowed' => true, + 'branding_allowed' => false, + 'features' => ['basic_uploads', 'limited_sharing'], + 'description' => << [ + 'de' => 'Perfekt zum Ausprobieren: Teile erste Eindrücke mit {{max_guests}} Gästen und sammle {{max_photos}} Bilder in einer Test-Galerie, die {{gallery_duration}} online bleibt. Ideal für kleine Runden oder interne Demos.', + 'en' => 'Perfect for trying it out: share first impressions with {{max_guests}} guests and collect {{max_photos}} photos in a test gallery that stays online for {{gallery_duration}}. Ideal for small groups or internal demos.', + ], + 'description_table' => [ + ['title' => 'Fotos', 'value' => '{{max_photos}}'], + ['title' => 'Gäste', 'value' => '{{max_guests}}'], + ['title' => 'Aufgaben', 'value' => '{{max_tasks}} Fotoaufgaben'], + ['title' => 'Galerie', 'value' => '{{gallery_duration}}'], + ['title' => 'Branding', 'value' => 'Fotospiel Standard Branding'], + ], + ], + [ + 'slug' => 'starter', + 'name' => 'Starter', + 'name_translations' => [ + 'de' => 'Starter', + 'en' => 'Starter', + ], + 'type' => PackageType::ENDCUSTOMER, + 'price' => 59.00, + 'max_photos' => 300, + 'max_guests' => 50, + 'gallery_days' => 14, + 'max_tasks' => 30, + 'watermark_allowed' => true, + 'branding_allowed' => false, + 'features' => ['basic_uploads', 'limited_sharing', 'custom_tasks'], + 'description' => << [ + 'de' => 'Ideal für Geburtstage, Gartenpartys oder Polterabende! {{max_guests}} Gäste teilen ihre besten Schnappschüsse, lösen {{max_tasks}} Fotoaufgaben und haben {{gallery_duration}} Zugriff auf die Online-Galerie. {{max_photos}} Bilder sind inklusive – genug Platz für jede Menge Lieblingsmomente.', + 'en' => 'Ideal for birthdays, garden parties or rehearsal dinners! {{max_guests}} guests share their favourite snapshots, take on {{max_tasks}} photo challenges and enjoy gallery access for {{gallery_duration}}. {{max_photos}} photos included for all those memories.', + ], + 'description_table' => [ + ['title' => 'Fotos', 'value' => '{{max_photos}}'], + ['title' => 'Gäste', 'value' => '{{max_guests}}'], + ['title' => 'Aufgaben', 'value' => '{{max_tasks}} Fotoaufgaben'], + ['title' => 'Galerie', 'value' => '{{gallery_duration}}'], + ['title' => 'Branding', 'value' => 'Fotospiel Branding'], + ], + ], + [ + 'slug' => 'standard', + 'name' => 'Standard', + 'name_translations' => [ + 'de' => 'Standard', + 'en' => 'Standard', + ], + 'type' => PackageType::ENDCUSTOMER, + 'price' => 129.00, + 'max_photos' => 1000, + 'max_guests' => 150, + 'gallery_days' => 30, + 'max_tasks' => 100, + 'watermark_allowed' => false, + 'branding_allowed' => true, + 'features' => ['basic_uploads', 'unlimited_sharing', 'no_watermark', 'custom_branding', 'custom_tasks'], + 'description' => << [ + 'de' => 'Das Rundum-Sorglos-Paket für Hochzeiten, Firmenfeiern oder Jubiläen. {{max_photos}} Bilder, {{max_guests}} Gäste und {{max_tasks}} Fotoaufgaben – dazu eine Galerie, die {{gallery_duration}} online bleibt. Eigenes Logo oder Wasserzeichen inklusive.', + 'en' => 'The all-in-one package for weddings, corporate events or anniversaries. {{max_photos}} photos, {{max_guests}} guests and {{max_tasks}} photo challenges—plus a gallery that stays online for {{gallery_duration}}. Includes your own logo or watermark.', + ], + 'description_table' => [ + ['title' => 'Fotos', 'value' => '{{max_photos}}'], + ['title' => 'Gäste', 'value' => '{{max_guests}}'], + ['title' => 'Aufgaben', 'value' => '{{max_tasks}} Fotoaufgaben'], + ['title' => 'Galerie', 'value' => '{{gallery_duration}}'], + ['title' => 'Branding', 'value' => 'Eigenes Logo & Wasserzeichen'], + ], + ], + [ + 'slug' => 'pro', + 'name' => 'Premium', + 'name_translations' => [ + 'de' => 'Premium', + 'en' => 'Premium', + ], + 'type' => PackageType::ENDCUSTOMER, + 'price' => 249.00, + 'max_photos' => 3000, + 'max_guests' => 500, + 'gallery_days' => 180, + 'max_tasks' => 200, + 'watermark_allowed' => false, + 'branding_allowed' => true, + 'features' => ['basic_uploads', 'unlimited_sharing', 'no_watermark', 'custom_branding', 'advanced_analytics', 'live_slideshow', 'priority_support'], + 'description' => << [ + 'de' => 'Das volle Erlebnis für alle, die keine Kompromisse machen wollen. {{max_photos}} Bilder, {{max_guests}} Gäste, {{gallery_duration}} Galerie-Zugang und {{max_tasks}} Aufgaben – dazu kein Wasserzeichen, Live-Slideshow und Premium-Support.', + 'en' => 'The full experience for anyone who refuses to compromise. {{max_photos}} photos, {{max_guests}} guests, {{gallery_duration}} of gallery access and {{max_tasks}} challenges—no watermark, live slideshow and premium support included.', + ], + 'description_table' => [ + ['title' => 'Fotos', 'value' => '{{max_photos}}'], + ['title' => 'Gäste', 'value' => '{{max_guests}}'], + ['title' => 'Aufgaben', 'value' => '{{max_tasks}} Fotoaufgaben'], + ['title' => 'Galerie', 'value' => '{{gallery_duration}}'], + ['title' => 'Extras', 'value' => 'Live-Slideshow & Premium-Support'], + ], + ], + [ + 'slug' => 's-small-reseller', + 'name' => 'Reseller S', + 'name_translations' => [ + 'de' => 'Reseller S', + 'en' => 'Reseller S', + ], + 'type' => PackageType::RESELLER, + 'price' => 299.00, + 'max_photos' => 1000, + 'max_guests' => null, + 'gallery_days' => 30, + 'max_tasks' => null, + 'watermark_allowed' => true, + 'branding_allowed' => true, + 'max_events_per_year' => 5, + 'expires_after' => now()->copy()->addYear(), + 'features' => ['reseller_dashboard', 'custom_branding', 'priority_support'], + 'description' => << [ + 'de' => 'Das perfekte Paket für Fotografen oder Planer, die erste Erfahrungen mit Fotospiel sammeln wollen. Enthalten sind {{max_events_per_year}} Events pro Jahr mit Standard-Leistung – Branding-Optionen inklusive.', + 'en' => 'Perfect for photographers or planners getting started with Fotospiel. Includes {{max_events_per_year}} events per year with the standard feature set—branding options included.', + ], + 'description_table' => [ + ['title' => 'Events/Jahr', 'value' => '{{max_events_per_year}} Events'], + ['title' => 'Galerie', 'value' => '{{gallery_duration}}'], + ['title' => 'Branding', 'value' => 'Logo & Farben pro Event'], + ], + ], + [ + 'slug' => 'm-medium-reseller', + 'name' => 'Reseller M', + 'name_translations' => [ + 'de' => 'Reseller M', + 'en' => 'Reseller M', + ], + 'type' => PackageType::RESELLER, + 'price' => 599.00, + 'max_photos' => 1500, + 'max_guests' => null, + 'gallery_days' => 60, + 'max_tasks' => null, + 'watermark_allowed' => true, + 'branding_allowed' => true, + 'max_events_per_year' => 15, + 'expires_after' => now()->copy()->addYear(), + 'features' => ['reseller_dashboard', 'custom_branding', 'priority_support', 'advanced_reporting'], + 'description' => << [ + 'de' => 'Wenn du regelmäßig Hochzeiten, Firmenfeste oder private Events betreust, ist dieses Paket ideal. {{max_events_per_year}} Events pro Jahr mit Branding-Optionen, verlängerter Galerie-Laufzeit und Reporting inklusive.', + 'en' => 'Designed for professionals who regularly support weddings, corporate events or private parties. {{max_events_per_year}} events per year with branding options, extended gallery runtime and reporting included.', + ], + 'description_table' => [ + ['title' => 'Events/Jahr', 'value' => '{{max_events_per_year}} Events'], + ['title' => 'Galerie', 'value' => '{{gallery_duration}}'], + ['title' => 'Reporting', 'value' => 'Erweiterte Auswertungen'], + ], + ], + [ + 'slug' => 'l-large-reseller', + 'name' => 'Reseller L', + 'name_translations' => [ + 'de' => 'Reseller L', + 'en' => 'Reseller L', + ], + 'type' => PackageType::RESELLER, + 'price' => 1199.00, + 'max_photos' => 3000, + 'max_guests' => null, + 'gallery_days' => 90, + 'max_tasks' => null, + 'watermark_allowed' => false, + 'branding_allowed' => true, + 'max_events_per_year' => 40, + 'expires_after' => now()->copy()->addYear(), + 'features' => ['reseller_dashboard', 'custom_branding', 'priority_support', 'advanced_reporting', 'live_slideshow'], + 'description' => << [ + 'de' => 'Ideal für Agenturen, Fotografen oder Eventdienstleister mit vielen Veranstaltungen im Jahr. {{max_events_per_year}} Events inklusive, White-Label-Branding und alle Premium-Funktionen sorgen für maximale Flexibilität.', + 'en' => 'Ideal for agencies, photographers or event providers with a packed calendar. {{max_events_per_year}} events included, white-label branding and all premium features for maximum flexibility.', + ], + 'description_table' => [ + ['title' => 'Events/Jahr', 'value' => '{{max_events_per_year}} Events'], + ['title' => 'Branding', 'value' => 'White-Label & eigene Domains'], + ['title' => 'Extras', 'value' => 'Live-Slideshow & Premium-Features'], + ], + ], + [ + 'slug' => 'enterprise-unlimited', + 'name' => 'Enterprise / Unlimited', + 'name_translations' => [ + 'de' => 'Enterprise / Unlimited', + 'en' => 'Enterprise / Unlimited', + ], + 'type' => PackageType::RESELLER, + 'price' => 0.00, + 'max_photos' => null, + 'max_guests' => null, + 'gallery_days' => null, + 'max_tasks' => null, + 'watermark_allowed' => false, + 'branding_allowed' => true, + 'max_events_per_year' => null, + 'expires_after' => now()->copy()->addYear(), + 'features' => ['reseller_dashboard', 'custom_branding', 'priority_support', 'advanced_reporting', 'live_slideshow', 'unlimited_sharing'], + 'description' => << [ + 'de' => 'Das Rundum-Paket für Unternehmen und Agenturen, die maximale Flexibilität brauchen. {{max_events_per_year}} Events, volles White-Label-Branding, eigene Subdomain oder App-Branding – alles individuell anpassbar, inklusive persönlicher Betreuung.', + 'en' => 'The all-round package for enterprises and agencies needing maximum flexibility. {{max_events_per_year}} events, full white-label branding, your own subdomain or app branding—fully customisable with dedicated support.', + ], + 'description_table' => [ + ['title' => 'Events/Jahr', 'value' => '{{max_events_per_year}} Events'], + ['title' => 'Branding', 'value' => 'Eigene Subdomain oder App'], + ['title' => 'Support', 'value' => 'Persönliche Betreuung'], + ], + ], + ]; + foreach ($packages as $data) { + $descriptionTable = $data['description_table'] ?? []; + Package::updateOrCreate( + ['slug' => $data['slug']], + array_merge($data, [ + 'description_table' => $descriptionTable, + ]) + ); + } } -} \ No newline at end of file +} diff --git a/docs/Paketbeschreibungen.txt b/docs/Paketbeschreibungen.txt new file mode 100644 index 0000000..c68ef53 --- /dev/null +++ b/docs/Paketbeschreibungen.txt @@ -0,0 +1,40 @@ +🟡 Starter – Der kleine Spaßmacher + +Ideal für Geburtstage, Gartenpartys oder Polterabende! +Bis zu 50 Gäste teilen ihre besten Schnappschüsse, lösen 30 spannende Aufgaben und haben 14 Tage lang Zugriff auf die Online-Galerie. 300 Bilder sind inklusive – genug Platz für jede Menge Lieblingsmomente. Dein Event bekommt sein eigenes Fotospiel mit persönlicher Note! + +🟠 Standard – Für das große Fest + +Das Rundum-Sorglos-Paket für Hochzeiten, Firmenfeiern oder Jubiläen. +1.000 Bilder, 150 Gäste, 100 Fotoaufgaben – und eine Galerie, die 1 Monat online bleibt. +Du kannst sogar dein eigenes Logo oder Wasserzeichen integrieren. So bleibt jedes Bild unverwechselbar und dein Event bekommt ein individuelles Branding. + +🔴 Premium – Für unvergessliche Events + +Das volle Erlebnis für alle, die keine Kompromisse machen wollen. +3.000 Bilder, bis zu 500 Gäste, 6 Monate Galerie-Zugang, 200 Aufgaben, kein Wasserzeichen und exklusive Zusatz-Features wie eine Live-Slideshow. +Ideal für große Hochzeiten, Firmen-Events oder Festivals. Hier wird jedes Foto zum Highlight – und dein Event unvergesslich. + + +🤝 Reseller- / Agentur-Pakete (Jahresmodell) + +🧩 Reseller S – Für den Einstieg ins Eventgeschäft + +Das perfekte Paket für Fotografen oder Planer, die erste Erfahrungen mit Fotospiel sammeln wollen. +Bis zu 5 Events pro Jahr sind inklusive, jeweils mit Standard-Leistung. Einfach, flexibel und mit Branding-Optionen, die zu deinem Stil passen. + +🎯 Reseller M – Für Profis mit regelmäßigem Einsatz + +Wenn du regelmäßig Hochzeiten, Firmenfeste oder private Events betreust, ist dieses Paket wie für dich gemacht. +Bis zu 15 Events pro Jahr mit Branding-Optionen, längerer Galerie-Laufzeit und vollem Zugriff auf Standard-Features. So kannst du deinen Kunden jedes Mal ein professionelles Erlebnis bieten – ohne dich um Lizenzen oder Abrechnung zu kümmern. + +🚀 Reseller L – Für die Vielnutzer und Markenmacher + +Ideal für Agenturen, Fotografen oder Eventdienstleister mit vielen Veranstaltungen im Jahr. +Bis zu 40 Events inklusive, mit Premium-Leistung, White-Label-Branding und allen Funktionen. Dein Branding steht im Mittelpunkt – Fotospiel arbeitet im Hintergrund. So stärkst du deine Marke und bietest gleichzeitig ein erstklassiges digitales Erlebnis. + +🏆 Enterprise / Unlimited – Für Marken und Großveranstalter + +Das Rundum-Paket für Unternehmen und Agenturen, die maximale Flexibilität brauchen. +Unbegrenzte Events, volles White-Label-Branding, eigene Subdomain oder App-Branding – ganz nach deinen Vorstellungen. +Dieses Paket ist individuell anpassbar und bietet alle Premium-Features plus persönliche Betreuung. Perfekt für Corporate-Events, Festivals oder Franchise-Konzepte. \ No newline at end of file diff --git a/public/lang/de/legal.json b/public/lang/de/legal.json index c121e25..982f8d8 100644 --- a/public/lang/de/legal.json +++ b/public/lang/de/legal.json @@ -29,5 +29,8 @@ "data_security_desc": "Wir verwenden HTTPS, verschlüsselte Speicherung (Passwörter hashed) und regelmäßige Backups. Zugriff auf Daten ist rollebasierend beschränkt (Tenant vs SuperAdmin).", "and": "und", "stripe_privacy": "Stripe Datenschutz", - "paypal_privacy": "PayPal Datenschutz" -} \ No newline at end of file + "paypal_privacy": "PayPal Datenschutz", + "agb": "Allgemeine Geschäftsbedingungen", + "effective_from": "Gültig seit {{date}}", + "version": "Version {{version}}" +} diff --git a/public/lang/de/marketing.json b/public/lang/de/marketing.json index 9e36f6f..7b66b7b 100644 --- a/public/lang/de/marketing.json +++ b/public/lang/de/marketing.json @@ -106,6 +106,17 @@ "feature_reseller_dashboard": "Reseller-Dashboard", "feature_custom_branding": "Benutzerdefiniertes Branding", "feature_advanced_reporting": "Erweiterte Berichterstattung", + "badge_most_popular": "Beliebteste Wahl", + "badge_best_value": "Bestes Preis-Leistungs-Verhältnis", + "badge_starter": "Perfekt für den Start", + "billing_per_event": "pro Event", + "billing_per_year": "pro Jahr", + "more_features": "+{{count}} weitere Features", + "feature_overview": "Feature-Überblick", + "order_hint": "Sofort startklar – keine versteckten Kosten, sichere Zahlung via Stripe oder PayPal.", + "features_label": "Features", + "breakdown_label": "Leistungsübersicht", + "limits_label": "Limits & Kapazitäten", "for_endcustomers": "Für Endkunden", "for_resellers": "Für Reseller", "details_show": "Details anzeigen", @@ -419,4 +430,4 @@ "google_coming_soon": "Google-Login kommt bald im Comfort-Delta." } } -} \ No newline at end of file +} diff --git a/public/lang/en/legal.json b/public/lang/en/legal.json index 418cd3a..b3d9707 100644 --- a/public/lang/en/legal.json +++ b/public/lang/en/legal.json @@ -26,5 +26,8 @@ "account_deletion": "Account Deletion", "account_deletion_desc": "You have the right to have your personal data deleted at any time (right to erasure, Art. 17 GDPR). Contact us at [Email] to delete your account. All associated data (events, photos, purchases) will be deleted, unless legal retention obligations exist.", "data_security": "Data Security", - "data_security_desc": "We use HTTPS, encrypted storage (passwords hashed) and regular backups. Access to data is role-based restricted (Tenant vs SuperAdmin)." -} \ No newline at end of file + "data_security_desc": "We use HTTPS, encrypted storage (passwords hashed) and regular backups. Access to data is role-based restricted (Tenant vs SuperAdmin).", + "agb": "Terms & Conditions", + "effective_from": "Effective from {{date}}", + "version": "Version {{version}}" +} diff --git a/public/lang/en/marketing.json b/public/lang/en/marketing.json index 1de238e..13575db 100644 --- a/public/lang/en/marketing.json +++ b/public/lang/en/marketing.json @@ -96,6 +96,17 @@ "feature_reseller_dashboard": "Reseller Dashboard", "feature_custom_branding": "Custom Branding", "feature_advanced_reporting": "Advanced Reporting", + "badge_most_popular": "Most Popular", + "badge_best_value": "Best Value", + "badge_starter": "Perfect Starter", + "billing_per_event": "per event", + "billing_per_year": "per year", + "more_features": "+{{count}} more features", + "feature_overview": "Feature overview", + "order_hint": "Launch instantly – secure Stripe or PayPal checkout, no hidden fees.", + "features_label": "Features", + "breakdown_label": "At-a-glance", + "limits_label": "Limits & Capacity", "for_endcustomers": "For End Customers", "for_resellers": "For Resellers", "details_show": "Show Details", @@ -125,6 +136,9 @@ "customer_opinions": "Customer Opinions", "errors": { "select_package": "Please select a package." + }, + "currency": { + "euro": "€" } }, "blog": { @@ -410,4 +424,4 @@ "google_coming_soon": "Google Login coming soon in Comfort-Delta." } } -} \ No newline at end of file +} diff --git a/resources/css/app.css b/resources/css/app.css index 9a0e727..5f32dd3 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -119,6 +119,108 @@ font-display: swap; } +/* Basic typography styling for rendered markdown (prose) without Tailwind plugin */ +.prose { + color: rgb(55 65 81); + font-family: var(--font-sans-marketing); + line-height: 1.75; + max-width: 100%; +} + +.prose.prose-slate { + color: rgb(51 65 85); +} + +.prose :where(p, ul, ol, blockquote, pre, table, figure) { + margin-bottom: 1.25em; +} + +.prose h1 { + font-family: var(--font-display); + font-size: clamp(2.25rem, 4vw, 3rem); + line-height: 1.1; + margin-top: 0; + margin-bottom: 0.75em; +} + +.prose h2 { + font-family: var(--font-display); + font-size: clamp(1.875rem, 3vw, 2.5rem); + line-height: 1.2; + margin-top: 1.6em; + margin-bottom: 0.6em; +} + +.prose h3 { + font-size: clamp(1.5rem, 2.5vw, 2rem); + font-weight: 600; + line-height: 1.25; + margin-top: 1.4em; + margin-bottom: 0.5em; +} + +.prose h4 { + font-size: 1.25rem; + font-weight: 600; + margin-top: 1.2em; + margin-bottom: 0.4em; +} + +.prose p { + margin: 0 0 1.25em; +} + +.prose a { + color: rgb(236 72 153); + text-decoration: underline; + text-decoration-thickness: 2px; + text-underline-offset: 4px; +} + +.prose strong { + font-weight: 600; +} + +.prose ul, +.prose ol { + padding-left: 1.5em; +} + +.prose ul { + list-style-type: disc; +} + +.prose ol { + list-style-type: decimal; +} + +.prose blockquote { + border-left: 4px solid rgba(236, 72, 153, 0.4); + color: rgb(75 85 99); + font-style: italic; + margin-left: 0; + padding-left: 1.25em; +} + +.prose table { + border-collapse: collapse; + width: 100%; +} + +.prose table th, +.prose table td { + border: 1px solid rgba(148, 163, 184, 0.6); + padding: 0.75em; + text-align: left; +} + +.prose code { + background-color: rgba(148, 163, 184, 0.15); + border-radius: 0.375rem; + font-family: 'Fira Code', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; + padding: 0.15em 0.35em; +} + @layer utilities { .font-display { font-family: var(--font-display); diff --git a/resources/js/admin/components/LanguageSwitcher.tsx b/resources/js/admin/components/LanguageSwitcher.tsx index 3bf544c..6e6ff63 100644 --- a/resources/js/admin/components/LanguageSwitcher.tsx +++ b/resources/js/admin/components/LanguageSwitcher.tsx @@ -60,7 +60,7 @@ export function LanguageSwitcher() { document.documentElement.setAttribute('lang', locale); } catch (error) { if (import.meta.env.DEV) { - // eslint-disable-next-line no-console + console.error('Failed to switch language', error); } } finally { diff --git a/resources/js/admin/i18n/index.ts b/resources/js/admin/i18n/index.ts index 9cb3c84..ef7adab 100644 --- a/resources/js/admin/i18n/index.ts +++ b/resources/js/admin/i18n/index.ts @@ -56,7 +56,7 @@ if (!i18n.isInitialized) { }) .catch((error) => { if (import.meta.env.DEV) { - // eslint-disable-next-line no-console + console.error('Failed to initialize i18n', error); } }); diff --git a/resources/js/guest/queue/queue.ts b/resources/js/guest/queue/queue.ts index 0fd6410..5175001 100644 --- a/resources/js/guest/queue/queue.ts +++ b/resources/js/guest/queue/queue.ts @@ -54,7 +54,7 @@ export async function processQueue() { if (processing) return; processing = true; try { const now = Date.now(); - let items = await list(); + const items = await list(); for (const it of items) { if (it.status === 'done') continue; if (it.nextAttemptAt && it.nextAttemptAt > now) continue; diff --git a/resources/js/guest/services/photosApi.ts b/resources/js/guest/services/photosApi.ts index 5657248..50b84be 100644 --- a/resources/js/guest/services/photosApi.ts +++ b/resources/js/guest/services/photosApi.ts @@ -12,7 +12,7 @@ function getCsrfToken(): string | null { const decodedCookie = decodeURIComponent(document.cookie); const ca = decodedCookie.split(';'); for (let i = 0; i < ca.length; i++) { - let c = ca[i].trimStart(); + const c = ca[i].trimStart(); if (c.startsWith(name)) { const token = c.substring(name.length); try { diff --git a/resources/js/layouts/app/Footer.tsx b/resources/js/layouts/app/Footer.tsx index 4448d5a..d10eef6 100644 --- a/resources/js/layouts/app/Footer.tsx +++ b/resources/js/layouts/app/Footer.tsx @@ -8,7 +8,7 @@ import { useAppearance } from '@/hooks/use-appearance'; const Footer: React.FC = () => { - const { t } = useTranslation('marketing'); + const { t } = useTranslation(['marketing', 'legal']); return ( @@ -32,10 +32,10 @@ const Footer: React.FC = () => {

Rechtliches

    -
  • {t('nav.impressum')}
  • -
  • {t('nav.privacy')}
  • -
  • AGB
  • -
  • {t('nav.contact')}
  • +
  • {t('legal:impressum')}
  • +
  • {t('legal:datenschutz')}
  • +
  • {t('legal:agb')}
  • +
  • {t('marketing:nav.contact')}
@@ -57,4 +57,4 @@ const Footer: React.FC = () => { ); }; -export default Footer; \ No newline at end of file +export default Footer; diff --git a/resources/js/pages/legal/Datenschutz.tsx b/resources/js/pages/legal/Datenschutz.tsx deleted file mode 100644 index 11e077b..0000000 --- a/resources/js/pages/legal/Datenschutz.tsx +++ /dev/null @@ -1,42 +0,0 @@ -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'; - -const Datenschutz: React.FC = () => { - const { t } = useTranslation('legal'); - const { localizedPath } = useLocalizedRoutes(); - - return ( - - -
-

{t('datenschutz')}

-

{t('datenschutz_intro')}

-

{t('responsible')}

-

{t('data_collection')}

-

{t('payments')}

-

- {t('payments_desc')} {t('stripe_privacy')} {t('and')} {t('paypal_privacy')}. -

-

{t('data_retention')}

-

- {t('rights')} {t('contact')}. -

-

{t('cookies')}

- -

{t('personal_data')}

-

{t('personal_data_desc')}

- -

{t('account_deletion')}

-

{t('account_deletion_desc')}

- -

{t('data_security')}

-

{t('data_security_desc')}

-
-
- ); -}; - -export default Datenschutz; \ No newline at end of file diff --git a/resources/js/pages/legal/Impressum.tsx b/resources/js/pages/legal/Impressum.tsx deleted file mode 100644 index e222d8e..0000000 --- a/resources/js/pages/legal/Impressum.tsx +++ /dev/null @@ -1,33 +0,0 @@ -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'; - -const Impressum: React.FC = () => { - const { t } = useTranslation('legal'); - const { localizedPath } = useLocalizedRoutes(); - - return ( - - -
-

{t('impressum')}

-

{t('impressum_section')}

-

- {t('company')}
- {t('address')}
- {t('representative')}
- {t('contact')}: {t('contact')} -

-

{t('vat_id')}

-

{t('monetization')}

-

{t('monetization_desc')}

-

{t('register_court')}

-

{t('commercial_register')}

-
-
- ); -}; - -export default Impressum; \ No newline at end of file diff --git a/resources/js/pages/legal/Show.tsx b/resources/js/pages/legal/Show.tsx new file mode 100644 index 0000000..2dc3103 --- /dev/null +++ b/resources/js/pages/legal/Show.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import MarketingLayout from '@/layouts/mainWebsite'; + +type LegalShowProps = { + seoTitle: string; + title: string; + content: string; + effectiveFrom?: string | null; + effectiveFromLabel?: string | null; + versionLabel?: string | null; + slug: string; +}; + +export default function LegalShow(props: LegalShowProps) { + const { seoTitle, title, content, effectiveFromLabel, versionLabel } = props; + + return ( + + +
+
+
+

+ FotoSpiel.App +

+

+ {title} +

+ {(effectiveFromLabel || versionLabel) && ( +

+ {[effectiveFromLabel, versionLabel].filter(Boolean).join(' · ')} +

+ )} +
+ +
+
+
+
+ ); +} diff --git a/resources/js/pages/marketing/Packages.tsx b/resources/js/pages/marketing/Packages.tsx index 8c23385..79494da 100644 --- a/resources/js/pages/marketing/Packages.tsx +++ b/resources/js/pages/marketing/Packages.tsx @@ -1,35 +1,47 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useMemo } from 'react'; import { Head, Link, usePage } from '@inertiajs/react'; import { useTranslation } from 'react-i18next'; +import type { TFunction } from 'i18next'; import { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious } from "@/components/ui/carousel" -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Badge } from "@/components/ui/badge" -import { Progress } from "@/components/ui/progress" import { Button } from "@/components/ui/button" import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion" +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; +import { Separator } from '@/components/ui/separator'; +import { cn } from '@/lib/utils'; import MarketingLayout from '@/layouts/mainWebsite'; -import { ArrowRight, ShoppingCart, Check, X, Users, Image, Calendar, Shield, Star } from 'lucide-react'; +import { ArrowRight, ShoppingCart, Check, X, Users, Image, Shield, Star, Sparkles } from 'lucide-react'; interface Package { id: number; name: string; + slug: string; description: string; + description_breakdown: DescriptionEntry[]; + gallery_duration_label?: string; price: number; - events: number; + events: number | null; features: string[]; + max_events_per_year?: number | null; limits?: { max_photos?: number; max_guests?: number; max_tenants?: number; - max_events?: number; + max_events_per_year?: number; gallery_days?: number; }; watermark_allowed?: boolean; branding_allowed?: boolean; } +type DescriptionEntry = { + title?: string | null; + value: string; +}; + interface PackagesProps { endcustomerPackages: Package[]; resellerPackages: Package[]; @@ -42,6 +54,7 @@ const Packages: React.FC = ({ endcustomerPackages, resellerPackag const { props } = usePage(); const { auth } = props as any; const { t } = useTranslation('marketing'); + const { t: tCommon } = useTranslation('common'); useEffect(() => { const urlParams = new URLSearchParams(window.location.search); @@ -58,13 +71,48 @@ const Packages: React.FC = ({ endcustomerPackages, resellerPackag }, [endcustomerPackages, resellerPackages]); const testimonials = [ - { name: t('common.testimonials.anna.name'), text: t('packages.testimonials.anna'), rating: 5 }, - { name: t('common.testimonials.max.name'), text: t('packages.testimonials.max'), rating: 5 }, - { name: t('common.testimonials.lisa.name'), text: t('packages.testimonials.lisa'), rating: 5 }, + { name: tCommon('testimonials.anna.name'), text: t('packages.testimonials.anna'), rating: 5 }, + { name: tCommon('testimonials.max.name'), text: t('packages.testimonials.max'), rating: 5 }, + { name: tCommon('testimonials.lisa.name'), text: t('packages.testimonials.lisa'), rating: 5 }, ]; const allPackages = [...endcustomerPackages, ...resellerPackages]; + const highlightEndcustomerId = useMemo(() => { + if (!endcustomerPackages.length) { + return null; + } + const best = endcustomerPackages.reduce((prev, current) => { + if (!prev) return current; + return current.price > prev.price ? current : prev; + }, null as Package | null); + return best?.id ?? endcustomerPackages[0].id; + }, [endcustomerPackages]); + + const highlightResellerId = useMemo(() => { + if (!resellerPackages.length) { + return null; + } + const best = resellerPackages.reduce((prev, current) => { + if (!prev) return current; + return current.price > prev.price ? current : prev; + }, null as Package | null); + return best?.id ?? resellerPackages[0].id; + }, [resellerPackages]); + + function isHighlightedPackage(pkg: Package, variant: 'endcustomer' | 'reseller') { + return variant === 'reseller' ? pkg.id === highlightResellerId : pkg.id === highlightEndcustomerId; + } + + const selectedVariant = useMemo<'endcustomer' | 'reseller'>(() => { + if (!selectedPackage) return 'endcustomer'; + return resellerPackages.some((pkg) => pkg.id === selectedPackage.id) ? 'reseller' : 'endcustomer'; + }, [selectedPackage, resellerPackages]); + + const selectedHighlight = selectedPackage + ? isHighlightedPackage(selectedPackage, selectedVariant) + : false; + const handleCardClick = (pkg: Package) => { setSelectedPackage(pkg); setCurrentStep('step1'); @@ -73,19 +121,305 @@ const Packages: React.FC = ({ endcustomerPackages, resellerPackag // nextStep entfernt, da Tabs nun parallel sind - const getFeatureIcon = (feature: string) => { - switch (feature) { - case 'basic_uploads': return ; - case 'unlimited_sharing': return ; - case 'no_watermark': return ; +const getFeatureIcon = (feature: string) => { + switch (feature) { + case 'basic_uploads': return ; + case 'unlimited_sharing': return ; + case 'no_watermark': return ; case 'custom_tasks': return ; case 'advanced_analytics': return ; case 'priority_support': return ; case 'reseller_dashboard': return ; case 'custom_branding': return ; - default: return ; - } - }; + default: return ; + } +}; + +const getAccentTheme = (variant: 'endcustomer' | 'reseller') => ( + variant === 'reseller' + ? { + gradient: 'from-amber-100/80 via-white to-white', + ring: 'ring-amber-200 dark:ring-amber-500/40', + badge: 'bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-100', + price: 'text-amber-500 dark:text-amber-300', + buttonHighlight: 'bg-gradient-to-r from-amber-500 via-rose-400 to-pink-500 text-white hover:from-amber-500/95 hover:via-rose-400/95 hover:to-pink-500/95', + buttonDefault: 'bg-gradient-to-r from-amber-50 via-white to-rose-50 text-amber-600 border border-amber-100/80 shadow-sm hover:from-amber-100 hover:via-rose-50 hover:to-white hover:text-amber-600 dark:from-amber-500/20 dark:via-amber-500/10 dark:to-rose-500/20 dark:text-amber-200 dark:border-amber-500/30', + highlightShadow: 'shadow-[0_28px_65px_-20px_rgba(245,158,11,0.55)]', + topBar: 'from-amber-400 via-rose-300 to-pink-400', + ctaShadow: 'shadow-lg shadow-amber-500/25', + } + : { + gradient: 'from-rose-100/80 via-white to-white', + ring: 'ring-rose-200 dark:ring-rose-500/40', + badge: 'bg-rose-100 text-rose-700 dark:bg-rose-500/20 dark:text-rose-100', + price: 'text-rose-500 dark:text-rose-300', + buttonHighlight: 'bg-gradient-to-r from-rose-500 via-pink-500 to-amber-400 text-white hover:from-rose-500/95 hover:via-pink-500/95 hover:to-amber-400/95', + buttonDefault: 'bg-gradient-to-r from-rose-50 via-white to-pink-50 text-rose-600 border border-rose-100/80 shadow-sm hover:from-rose-100 hover:via-white hover:to-pink-100 hover:text-rose-600 dark:from-rose-500/15 dark:via-rose-500/10 dark:to-rose-500/20 dark:text-rose-200 dark:border-rose-500/30', + highlightShadow: 'shadow-[0_28px_70px_-25px_rgba(244,63,94,0.55)]', + topBar: 'from-rose-500 via-pink-400 to-amber-300', + ctaShadow: 'shadow-lg shadow-rose-500/30', + } +); + +type PackageMetric = { + key: string; + label: string; + value: string; +}; + +const resolvePackageMetrics = ( + pkg: Package, + variant: 'endcustomer' | 'reseller', + t: TFunction, + tCommon: TFunction, +): PackageMetric[] => { + if (variant === 'reseller') { + return [ + { + key: 'max_tenants', + label: t('packages.max_tenants'), + value: pkg.limits?.max_tenants + ? pkg.limits.max_tenants.toLocaleString() + : tCommon('unlimited'), + }, + { + key: 'max_events_per_year', + label: t('packages.max_events_year'), + value: pkg.limits?.max_events_per_year + ? pkg.limits.max_events_per_year.toLocaleString() + : tCommon('unlimited'), + }, + { + key: 'branding', + label: t('packages.feature_custom_branding'), + value: pkg.branding_allowed ? tCommon('included') : t('packages.feature_no_branding'), + }, + ]; + } + + return [ + { + key: 'max_photos', + label: t('packages.max_photos_label'), + value: pkg.limits?.max_photos + ? pkg.limits.max_photos.toLocaleString() + : tCommon('unlimited'), + }, + { + key: 'max_guests', + label: t('packages.max_guests_label'), + value: pkg.limits?.max_guests + ? pkg.limits.max_guests.toLocaleString() + : tCommon('unlimited'), + }, + { + key: 'gallery_days', + label: t('packages.gallery_days_label'), + value: pkg.gallery_duration_label + ?? (pkg.limits?.gallery_days + ? pkg.limits.gallery_days.toLocaleString() + : tCommon('unlimited')), + }, + ]; +}; + +interface PackageCardProps { + pkg: Package; + variant: 'endcustomer' | 'reseller'; + highlight?: boolean; + onSelect?: (pkg: Package) => void; + className?: string; + showCTA?: boolean; + ctaLabel?: string; + compact?: boolean; +} + +function PackageCard({ + pkg, + variant, + highlight = false, + onSelect, + className, + showCTA = true, + ctaLabel, + compact = false, +}: PackageCardProps) { + const { t } = useTranslation('marketing'); + const { t: tCommon } = useTranslation('common'); + + const accent = getAccentTheme(variant); + + const priceLabel = + pkg.price === 0 + ? t('packages.free') + : `${pkg.price.toLocaleString()} ${t('packages.currency.euro')}`; + const cadenceLabel = + variant === 'reseller' + ? t('packages.billing_per_year') + : t('packages.billing_per_event'); + const typeLabel = + variant === 'reseller' ? t('packages.subscription') : t('packages.one_time'); + + const badgeLabel = highlight + ? (variant === 'reseller' + ? t('packages.badge_best_value') + : t('packages.badge_most_popular')) + : pkg.price === 0 + ? t('packages.badge_starter') + : null; + + const featureBadges = pkg.features.slice(0, 4); + const extraFeatureCount = Math.max(pkg.features.length - featureBadges.length, 0); + + const metrics = resolvePackageMetrics(pkg, variant, t, tCommon); + + return ( + +
+ +
+ + {typeLabel} + + {badgeLabel && ( + + {highlight && } + {badgeLabel} + + )} +
+ + {pkg.name} + + + {pkg.description} + +
+ +
+
+ {priceLabel} + {pkg.price !== 0 && ( + + / {cadenceLabel} + + )} +
+ {variant === 'endcustomer' && ( +

+ {pkg.events} × {t('packages.one_time')} +

+ )} +
+ + + +
+ {featureBadges.map((feature) => ( + + {getFeatureIcon(feature)} + {t(`packages.feature_${feature}`)} + + ))} + {pkg.watermark_allowed === false && ( + + + {t('packages.no_watermark')} + + )} + {pkg.branding_allowed && ( + + + {t('packages.custom_branding')} + + )} + {extraFeatureCount > 0 && ( + + {t('packages.more_features', { count: extraFeatureCount })} + + )} +
+ +
+ {metrics.map((metric) => ( +
+

+ {metric.value} +

+

+ {metric.label} +

+
+ ))} +
+
+ {showCTA && onSelect && ( + + + + )} + + ); +} return ( @@ -104,47 +438,18 @@ const Packages: React.FC = ({ endcustomerPackages, resellerPackag

{t('packages.section_endcustomer')}

- {/* Mobile Carousel for Endcustomer Packages */}
- - + + {endcustomerPackages.map((pkg) => ( - -
console.log('Touch start on carousel item:', e.touches.length)} - onTouchMove={(e) => console.log('Touch move on carousel item:', e.touches.length)} - onTouchEnd={(e) => console.log('Touch end on carousel item')} - > -
- -
-

{pkg.name}

-

{pkg.description}

-

- {pkg.price === 0 ? t('packages.free') : `${pkg.price} ${t('packages.currency.euro')}`} -

-
    -
  • • {pkg.events} {t('packages.one_time')}
  • - {pkg.features.map((feature, index) => ( -
  • - {getFeatureIcon(feature)} {t(`packages.feature_${feature}`)} -
  • - ))} - {pkg.limits?.max_photos &&
  • • {t('packages.max_photos')} {pkg.limits.max_photos}
  • } - {pkg.limits?.gallery_days &&
  • • {t('packages.gallery_days')} {pkg.limits.gallery_days}
  • } - {pkg.limits?.max_guests &&
  • • {t('packages.max_guests')} {pkg.limits.max_guests}
  • } - {pkg.watermark_allowed === false &&
  • {t('packages.no_watermark')}
  • } - {pkg.branding_allowed &&
  • {t('packages.custom_branding')}
  • } -
- -
+ + ))}
@@ -153,45 +458,16 @@ const Packages: React.FC = ({ endcustomerPackages, resellerPackag
- {/* Desktop Grid for Endcustomer Packages */} -
-
- {endcustomerPackages.map((pkg) => ( -
-
- -
-

{pkg.name}

-

{pkg.description}

-

- {pkg.price === 0 ? t('packages.free') : `${pkg.price} ${t('packages.currency.euro')}`} -

-
    -
  • • {pkg.events} {t('packages.one_time')}
  • - {pkg.features.map((feature, index) => ( -
  • - {getFeatureIcon(feature)} {t(`packages.feature_${feature}`)} -
  • - ))} - {pkg.limits?.max_photos &&
  • • {t('packages.max_photos')} {pkg.limits.max_photos}
  • } - {pkg.limits?.gallery_days &&
  • • {t('packages.gallery_days')} {pkg.limits.gallery_days}
  • } - {pkg.limits?.max_guests &&
  • • {t('packages.max_guests')} {pkg.limits.max_guests}
  • } - {pkg.watermark_allowed === false &&
  • {t('packages.no_watermark')}
  • } - {pkg.branding_allowed &&
  • {t('packages.custom_branding')}
  • } -
- -
- ))} -
+
+ {endcustomerPackages.map((pkg) => ( + + ))}
@@ -207,7 +483,7 @@ const Packages: React.FC = ({ endcustomerPackages, resellerPackag {endcustomerPackages.map((pkg) => (

{pkg.name}

-

{pkg.price === 0 ? t('packages.free') : `${pkg.price} ${t('currency.euro')}`}

+

{pkg.price === 0 ? t('packages.free') : `${pkg.price} ${t('packages.currency.euro')}`}

))}
@@ -220,7 +496,7 @@ const Packages: React.FC = ({ endcustomerPackages, resellerPackag {endcustomerPackages.map((pkg) => (

{pkg.name}

-

{pkg.limits?.max_photos || t('common.unlimited')}

+

{pkg.limits?.max_photos || tCommon('unlimited')}

))}
@@ -233,7 +509,7 @@ const Packages: React.FC = ({ endcustomerPackages, resellerPackag {endcustomerPackages.map((pkg) => (

{pkg.name}

-

{pkg.limits?.max_guests || t('common.unlimited')}

+

{pkg.limits?.max_guests || tCommon('unlimited')}

))} @@ -246,7 +522,7 @@ const Packages: React.FC = ({ endcustomerPackages, resellerPackag {endcustomerPackages.map((pkg) => (

{pkg.name}

-

{pkg.limits?.gallery_days || t('common.unlimited')}

+

{pkg.limits?.gallery_days || tCommon('unlimited')}

))} @@ -284,31 +560,31 @@ const Packages: React.FC = ({ endcustomerPackages, resellerPackag {t('packages.price')} {endcustomerPackages.map((pkg) => ( - {pkg.price === 0 ? t('packages.free') : `${pkg.price} ${t('currency.euro')}`} + {pkg.price === 0 ? t('packages.free') : `${pkg.price} ${t('packages.currency.euro')}`} ))} {t('packages.max_photos_label')} {getFeatureIcon('max_photos')} - {endcustomerPackages.map((pkg) => ( - - {pkg.limits?.max_photos || t('common.unlimited')} + {endcustomerPackages.map((pkg) => ( + + {pkg.limits?.max_photos || tCommon('unlimited')} ))} {t('packages.max_guests_label')} {getFeatureIcon('max_guests')} - {endcustomerPackages.map((pkg) => ( - - {pkg.limits?.max_guests || t('common.unlimited')} + {endcustomerPackages.map((pkg) => ( + + {pkg.limits?.max_guests || tCommon('unlimited')} ))} {t('packages.gallery_days_label')} {getFeatureIcon('gallery_days')} - {endcustomerPackages.map((pkg) => ( - - {pkg.limits?.gallery_days || t('common.unlimited')} + {endcustomerPackages.map((pkg) => ( + + {pkg.limits?.gallery_days || tCommon('unlimited')} ))} @@ -330,44 +606,18 @@ const Packages: React.FC = ({ endcustomerPackages, resellerPackag

{t('packages.section_reseller')}

- {/* Mobile Carousel for Reseller Packages */}
- - + + {resellerPackages.map((pkg) => ( - -
console.log('Touch start on reseller carousel item:', e.touches.length)} - onTouchMove={(e) => console.log('Touch move on reseller carousel item:', e.touches.length)} - onTouchEnd={(e) => console.log('Touch end on reseller carousel item')} - > -
- -
-

{pkg.name}

-

{pkg.description}

-

- {pkg.price} {t('packages.currency.euro')} / {t('packages.year')} -

-
    - {pkg.features.map((feature, index) => ( -
  • - {getFeatureIcon(feature)} {t(`packages.feature_${feature}`)} -
  • - ))} - {pkg.limits?.max_tenants &&
  • • {t('packages.max_tenants')} {pkg.limits.max_tenants}
  • } - {pkg.limits?.max_events &&
  • • {t('packages.max_events_year')} {pkg.limits.max_events}
  • } - {pkg.branding_allowed &&
  • {t('packages.custom_branding')}
  • } -
- -
+ + ))}
@@ -376,42 +626,16 @@ const Packages: React.FC = ({ endcustomerPackages, resellerPackag
- {/* Desktop Grid for Reseller Packages */} -
-
- {resellerPackages.map((pkg) => ( -
-
- -
-

{pkg.name}

-

{pkg.description}

-

- {pkg.price} {t('packages.currency.euro')} / {t('packages.year')} -

-
    - {pkg.features.map((feature, index) => ( -
  • - {getFeatureIcon(feature)} {t(`packages.feature_${feature}`)} -
  • - ))} - {pkg.limits?.max_tenants &&
  • • {t('packages.max_tenants')} {pkg.limits.max_tenants}
  • } - {pkg.limits?.max_events &&
  • • {t('packages.max_events_year')} {pkg.limits.max_events}
  • } - {pkg.branding_allowed &&
  • {t('packages.custom_branding')}
  • } -
- -
- ))} -
+
+ {resellerPackages.map((pkg) => ( + + ))}
@@ -444,89 +668,214 @@ const Packages: React.FC = ({ endcustomerPackages, resellerPackag {/* Modal */} {selectedPackage && ( - - - {selectedPackage.name} - {t('packages.details')} - - - - {t('packages.details')} - {t('packages.customer_opinions')} - - -
-
-

{selectedPackage.name}

-

- {selectedPackage.price === 0 ? t('packages.free') : `${selectedPackage.price} ${t('packages.currency.euro')}`} -

-
-

{selectedPackage.description}

-
- {selectedPackage.features.map((feature, index) => ( - - {getFeatureIcon(feature)} {t(`packages.feature_${feature}`)} - - ))} - {selectedPackage.limits?.max_photos && ( - - {t('packages.max_photos')} {selectedPackage.limits.max_photos} + +
+ +
+
+
+ + {selectedVariant === 'reseller' ? t('packages.subscription') : t('packages.one_time')} + {selectedHighlight && ( + + + {selectedVariant === 'reseller' + ? t('packages.badge_best_value') + : t('packages.badge_most_popular')} + + )} +
+
+ + {selectedPackage.name} + +
+ + {selectedPackage.price === 0 + ? t('packages.free') + : `${selectedPackage.price.toLocaleString()} ${t('packages.currency.euro')}`} + + {selectedPackage.price !== 0 && ( + + / {selectedVariant === 'reseller' ? t('packages.billing_per_year') : t('packages.billing_per_event')} + + )} +
+
+ {selectedPackage.description && ( +

+ {selectedPackage.description} +

)} - {selectedPackage.limits?.max_guests && ( - - {t('packages.max_guests')} {selectedPackage.limits.max_guests} - - )} - {selectedPackage.limits?.gallery_days && ( - - {t('packages.gallery_days')} {selectedPackage.limits.gallery_days} - - )} - {selectedPackage.watermark_allowed === false && ( - - {t('packages.no_watermark')} - - )} - {selectedPackage.branding_allowed && ( - - {t('packages.custom_branding')} - - )} -
-
- { - localStorage.setItem('preferred_package', JSON.stringify(selectedPackage)); - }} - > - {t('packages.to_order')} -
- - -
-

{t('packages.what_customers_say')}

-
- {testimonials.map((testimonial, index) => ( -
-

"{testimonial.text}"

-

{testimonial.name}

-
- {[...Array(testimonial.rating)].map((_, i) => )} + + + + {t('packages.details')} + {t('packages.customer_opinions')} + + + {(() => { + const accent = getAccentTheme(selectedVariant); + const metrics = resolvePackageMetrics(selectedPackage, selectedVariant, t, tCommon); + const descriptionEntries = selectedPackage.description_breakdown ?? []; + + return ( +
+
+
+
+ + + {t('packages.features_label')} + +
+ {selectedPackage.features.map((feature) => ( + + {getFeatureIcon(feature)} + {t(`packages.feature_${feature}`)} + + ))} + {selectedPackage.watermark_allowed === false && ( + + + {t('packages.no_watermark')} + + )} + {selectedPackage.branding_allowed && ( + + + {t('packages.custom_branding')} + + )} +
+
+
+
+ {descriptionEntries.length > 0 && ( +
+

+ {t('packages.breakdown_label')} +

+
+ {descriptionEntries.map((entry, index) => ( +
+ {entry.title && ( +

+ {entry.title} +

+ )} +

{entry.value}

+
+ ))} +
+
+ )} +
+

+ {t('packages.limits_label')} +

+
+ {metrics.map((metric) => ( +
+

{metric.value}

+

+ {metric.label} +

+
+ ))} +
+
+
+ +

+ {t('packages.order_hint')} +

+
- ))} + ); + })()} + + +
+

+ {t('packages.what_customers_say')} +

+
+ {testimonials.map((testimonial, index) => ( +
+

“{testimonial.text}”

+
+ {testimonial.name} +
+ {[...Array(testimonial.rating)].map((_, i) => ( + + ))} +
+
+
+ ))} +
+
+ +
- -
-
-
+ + +
)} @@ -536,4 +885,4 @@ const Packages: React.FC = ({ endcustomerPackages, resellerPackag ); }; -export default Packages; \ No newline at end of file +export default Packages; diff --git a/resources/lang/de/legal.php b/resources/lang/de/legal.php index 80fb010..9134a40 100644 --- a/resources/lang/de/legal.php +++ b/resources/lang/de/legal.php @@ -29,7 +29,10 @@ return [ 'account_deletion_desc' => 'Sie haben das Recht, Ihre persönlichen Daten jederzeit löschen zu lassen (Recht auf Löschung, Art. 17 DSGVO). Kontaktieren Sie uns unter [E-Mail] zur Löschung Ihres Accounts. Alle zugehörigen Daten (Events, Photos, Purchases) werden gelöscht, soweit keine gesetzlichen Aufbewahrungspflichten bestehen.', 'data_security' => 'Datensicherheit', 'data_security_desc' => 'Wir verwenden HTTPS, verschlüsselte Speicherung (Passwörter hashed) und regelmäßige Backups. Zugriff auf Daten ist rollebasierend beschränkt (Tenant vs SuperAdmin).', + 'agb' => 'Allgemeine Geschäftsbedingungen', + 'effective_from' => 'Gültig seit :date', + 'version' => 'Version :version', 'and' => 'und', 'stripe_privacy' => 'Stripe Datenschutz', 'paypal_privacy' => 'PayPal Datenschutz', -]; \ No newline at end of file +]; diff --git a/resources/lang/de/marketing.php b/resources/lang/de/marketing.php index c54e73a..d87af45 100644 --- a/resources/lang/de/marketing.php +++ b/resources/lang/de/marketing.php @@ -50,6 +50,20 @@ return [ 'feature_reseller_dashboard' => 'Reseller-Dashboard', 'feature_custom_branding' => 'Benutzerdefiniertes Branding', 'feature_advanced_reporting' => 'Erweiterte Berichterstattung', + 'badge_most_popular' => 'Beliebteste Wahl', + 'badge_best_value' => 'Bestes Preis-Leistungs-Verhältnis', + 'badge_starter' => 'Perfekt für den Start', + 'billing_per_event' => 'pro Event', + 'billing_per_year' => 'pro Jahr', + 'more_features' => '+:count weitere Features', + 'max_photos_label' => 'Max. Fotos', + 'max_guests_label' => 'Max. Gäste', + 'gallery_days_label' => 'Galerie-Tage', + 'feature_overview' => 'Feature-Überblick', + 'order_hint' => 'Sofort startklar – keine versteckten Kosten, sichere Zahlung via Stripe oder PayPal.', + 'features_label' => 'Features', + 'breakdown_label' => 'Leistungsübersicht', + 'limits_label' => 'Limits & Kapazitäten', ], 'nav' => [ 'home' => 'Startseite', @@ -146,4 +160,4 @@ return [ 'currency' => [ 'euro' => '€', ], -]; \ No newline at end of file +]; diff --git a/resources/lang/en/legal.php b/resources/lang/en/legal.php index d62fa0b..65ccadb 100644 --- a/resources/lang/en/legal.php +++ b/resources/lang/en/legal.php @@ -29,4 +29,7 @@ return [ 'account_deletion_desc' => 'You have the right to have your personal data deleted at any time (right to erasure, Art. 17 GDPR). Contact us at [Email] to delete your account. All associated data (events, photos, purchases) will be deleted, unless legal retention obligations exist.', 'data_security' => 'Data Security', 'data_security_desc' => 'We use HTTPS, encrypted storage (passwords hashed) and regular backups. Access to data is role-based restricted (Tenant vs SuperAdmin).', -]; \ No newline at end of file + 'agb' => 'Terms & Conditions', + 'effective_from' => 'Effective from :date', + 'version' => 'Version :version', +]; diff --git a/resources/lang/en/marketing.php b/resources/lang/en/marketing.php index c7e4e48..e33df1f 100644 --- a/resources/lang/en/marketing.php +++ b/resources/lang/en/marketing.php @@ -50,6 +50,20 @@ return [ 'feature_reseller_dashboard' => 'Reseller Dashboard', 'feature_custom_branding' => 'Custom Branding', 'feature_advanced_reporting' => 'Advanced Reporting', + 'badge_most_popular' => 'Most Popular', + 'badge_best_value' => 'Best Value', + 'badge_starter' => 'Perfect Starter', + 'billing_per_event' => 'per event', + 'billing_per_year' => 'per year', + 'more_features' => '+:count more features', + 'max_photos_label' => 'Max. photos', + 'max_guests_label' => 'Max. guests', + 'gallery_days_label' => 'Gallery days', + 'feature_overview' => 'Feature overview', + 'order_hint' => 'Ready to launch instantly – secure Stripe or PayPal checkout, no hidden fees.', + 'features_label' => 'Features', + 'breakdown_label' => 'At-a-glance', + 'limits_label' => 'Limits & Capacity', ], 'nav' => [ 'home' => 'Home', @@ -146,4 +160,4 @@ return [ 'currency' => [ 'euro' => '€', ], -]; \ No newline at end of file +]; diff --git a/resources/views/partials/footer.blade.php b/resources/views/partials/footer.blade.php index 1fb4263..dd0d10a 100644 --- a/resources/views/partials/footer.blade.php +++ b/resources/views/partials/footer.blade.php @@ -1,10 +1,12 @@ \ No newline at end of file + diff --git a/routes/web.php b/routes/web.php index 9cf59d9..ce77a1f 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,8 +1,9 @@ name('conta Route::post('/contact', [MarketingController::class, 'contact'])->name('contact.submit'); // Legal pages -Route::get('/datenschutz', function () { - return Inertia::render('legal/Datenschutz'); -})->name('datenschutz'); - -Route::get('/impressum', function () { - return Inertia::render('legal/Impressum'); -})->name('impressum'); +Route::get('/impressum', [LegalPageController::class, 'show']) + ->name('impressum') + ->defaults('slug', 'impressum'); +Route::get('/datenschutz', [LegalPageController::class, 'show']) + ->name('datenschutz') + ->defaults('slug', 'datenschutz'); +Route::get('/agb', [LegalPageController::class, 'show']) + ->name('agb') + ->defaults('slug', 'agb'); Route::get('/kontakt', function () { return Inertia::render('marketing/Kontakt');