Marketing packages now use localized name/description data plus seeded placeholder-

driven breakdown tables, with frontend/cards/dialog updated accordingly (database/
  migrations/2025_10_17_000001_add_description_table_to_packages.php, database/
  migrations/2025_10_17_000002_add_translation_columns_to_packages.php, database/seeders/PackageSeeder.php, app/
  Http/Controllers/MarketingController.php, resources/js/pages/marketing/Packages.tsx).
  Filament Package resource gains locale tabs, markdown editor, numeric/toggle inputs, and simplified feature
  management (app/Filament/Resources/PackageResource.php, app/Filament/Resources/PackageResource/Pages/
  CreatePackage.php, .../EditPackage.php).
  Legal pages now render markdown-backed content inside the main layout via a new controller/view route setup and
  updated footer links (app/Http/Controllers/LegalPageController.php, routes/web.php, resources/views/partials/
  footer.blade.php, resources/js/pages/legal/Show.tsx, remove old static pages).
  Translation files and shared assets updated to cover new marketing/legal strings and styling tweaks (public/
  lang/*/marketing.json, resources/lang/*/marketing.php, resources/css/app.css, resources/js/admin/components/
  LanguageSwitcher.tsx).
This commit is contained in:
Codex Agent
2025-10-17 21:20:54 +02:00
parent 25e8f0511b
commit 48a2974152
30 changed files with 1702 additions and 711 deletions

View File

@@ -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',
];
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,90 @@
<?php
namespace App\Http\Controllers;
use App\Models\LegalPage;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Inertia\Response;
use League\CommonMark\Environment\Environment;
use League\CommonMark\Extension\Autolink\AutolinkExtension;
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
use League\CommonMark\Extension\Strikethrough\StrikethroughExtension;
use League\CommonMark\Extension\Table\TableExtension;
use League\CommonMark\Extension\TaskList\TaskListExtension;
use League\CommonMark\MarkdownConverter;
class LegalPageController extends Controller
{
public function show(?string $slug = null): Response
{
$resolvedSlug = $this->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));
}
}

View File

@@ -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));
}
}

View File

@@ -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',
];

View File

@@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('packages', function (Blueprint $table) {
if (!Schema::hasColumn('packages', 'description_table')) {
$table->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');
}
});
}
};

View File

@@ -0,0 +1,57 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
Schema::table('packages', function (Blueprint $table) {
if (! Schema::hasColumn('packages', 'name_translations')) {
$table->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');
}
});
}
};

View File

@@ -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' => <<<TEXT
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.
TEXT,
'description_translations' => [
'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' => <<<TEXT
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.
TEXT,
'description_translations' => [
'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' => <<<TEXT
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.
TEXT,
'description_translations' => [
'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' => <<<TEXT
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.
TEXT,
'description_translations' => [
'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' => <<<TEXT
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.
TEXT,
'description_translations' => [
'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' => <<<TEXT
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.
TEXT,
'description_translations' => [
'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' => <<<TEXT
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.
TEXT,
'description_translations' => [
'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' => <<<TEXT
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.
TEXT,
'description_translations' => [
'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,
])
);
}
}
}

View File

@@ -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.

View File

@@ -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"
"paypal_privacy": "PayPal Datenschutz",
"agb": "Allgemeine Geschäftsbedingungen",
"effective_from": "Gültig seit {{date}}",
"version": "Version {{version}}"
}

View File

@@ -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",

View File

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

View File

@@ -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": {

View File

@@ -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);

View File

@@ -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 {

View File

@@ -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);
}
});

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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 = () => {
<div>
<h3 className="font-semibold font-display text-gray-900 mb-4">Rechtliches</h3>
<ul className="space-y-2 text-sm text-gray-600 font-sans-marketing">
<li><Link href="/impressum" className="hover:text-pink-500">{t('nav.impressum')}</Link></li>
<li><Link href="/datenschutz" className="hover:text-pink-500">{t('nav.privacy')}</Link></li>
<li><Link href="/agb" className="hover:text-pink-500">AGB</Link></li>
<li><Link href="/kontakt" className="hover:text-pink-500">{t('nav.contact')}</Link></li>
<li><Link href="/impressum" className="hover:text-pink-500 transition-colors">{t('legal:impressum')}</Link></li>
<li><Link href="/datenschutz" className="hover:text-pink-500 transition-colors">{t('legal:datenschutz')}</Link></li>
<li><Link href="/agb" className="hover:text-pink-500 transition-colors">{t('legal:agb')}</Link></li>
<li><Link href="/kontakt" className="hover:text-pink-500 transition-colors">{t('marketing:nav.contact')}</Link></li>
</ul>
</div>

View File

@@ -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 (
<MarketingLayout title={t('datenschutz_title')}>
<Head title={t('datenschutz_title')} />
<div className="container mx-auto px-4 py-8">
<h1 className="text-2xl font-bold mb-4 font-display">{t('datenschutz')}</h1>
<p className="mb-4 font-sans-marketing">{t('datenschutz_intro')}</p>
<p className="mb-4 font-sans-marketing">{t('responsible')}</p>
<p className="mb-4 font-sans-marketing">{t('data_collection')}</p>
<h2 className="text-xl font-semibold mb-2 font-display">{t('payments')}</h2>
<p className="mb-4 font-sans-marketing">
{t('payments_desc')} <a href="https://stripe.com/de/privacy" target="_blank" rel="noopener noreferrer">{t('stripe_privacy')}</a> {t('and')} <a href="https://www.paypal.com/de/webapps/mpp/ua/privacy-full" target="_blank" rel="noopener noreferrer">{t('paypal_privacy')}</a>.
</p>
<p className="mb-4 font-sans-marketing">{t('data_retention')}</p>
<p className="mb-4 font-sans-marketing">
{t('rights')} <Link href={localizedPath('/kontakt')}>{t('contact')}</Link>.
</p>
<p className="mb-4 font-sans-marketing">{t('cookies')}</p>
<h2 className="text-xl font-semibold mb-2 font-display">{t('personal_data')}</h2>
<p className="mb-4 font-sans-marketing">{t('personal_data_desc')}</p>
<h2 className="text-xl font-semibold mb-2 font-display">{t('account_deletion')}</h2>
<p className="mb-4 font-sans-marketing">{t('account_deletion_desc')}</p>
<h2 className="text-xl font-semibold mb-2 font-display">{t('data_security')}</h2>
<p className="mb-4 font-sans-marketing">{t('data_security_desc')}</p>
</div>
</MarketingLayout>
);
};
export default Datenschutz;

View File

@@ -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 (
<MarketingLayout title={t('impressum_title')}>
<Head title={t('impressum_title')} />
<div className="container mx-auto px-4 py-8">
<h1 className="text-2xl font-bold mb-4 font-display">{t('impressum')}</h1>
<p className="mb-4 font-sans-marketing">{t('impressum_section')}</p>
<p className="mb-4 font-sans-marketing">
{t('company')}<br />
{t('address')}<br />
{t('representative')}<br />
{t('contact')}: <Link href={localizedPath('/kontakt')}>{t('contact')}</Link>
</p>
<p className="mb-4 font-sans-marketing">{t('vat_id')}</p>
<h2 className="text-xl font-semibold mb-2 font-display">{t('monetization')}</h2>
<p className="mb-4 font-sans-marketing">{t('monetization_desc')}</p>
<p className="mb-4 font-sans-marketing">{t('register_court')}</p>
<p className="mb-4 font-sans-marketing">{t('commercial_register')}</p>
</div>
</MarketingLayout>
);
};
export default Impressum;

View File

@@ -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 (
<MarketingLayout title={seoTitle}>
<section className="bg-white py-16">
<div className="mx-auto max-w-4xl px-6">
<header className="mb-10">
<p className="text-sm uppercase tracking-[0.2em] text-gray-400">
FotoSpiel.App
</p>
<h1 className="mt-2 text-3xl font-semibold text-gray-900 md:text-4xl">
{title}
</h1>
{(effectiveFromLabel || versionLabel) && (
<p className="mt-3 text-sm text-gray-500">
{[effectiveFromLabel, versionLabel].filter(Boolean).join(' · ')}
</p>
)}
</header>
<article
className="prose prose-slate max-w-none prose-headings:font-display"
dangerouslySetInnerHTML={{ __html: content }}
/>
</div>
</section>
</MarketingLayout>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -29,6 +29,9 @@ 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',

View File

@@ -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',

View File

@@ -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).',
'agb' => 'Terms & Conditions',
'effective_from' => 'Effective from :date',
'version' => 'Version :version',
];

View File

@@ -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',

View File

@@ -1,10 +1,12 @@
<footer class="bg-gray-800 text-white py-8 px-4">
<div class="container mx-auto text-center">
<p>&copy; 2025 {{ __('marketing.footer.company') }}. {{ __('marketing.footer.rights_reserved') }}</p>
<div class="mt-4 space-x-4">
<a href="{{ route('impressum') }}" class="hover:text-[#FFB6C1]">{{ __('legal.impressum') }}</a>
<a href="{{ route('datenschutz') }}" class="hover:text-[#FFB6C1]">{{ __('legal.datenschutz') }}</a>
<a href="{{ route('kontakt') }}" class="hover:text-[#FFB6C1]">{{ __('marketing.nav.contact') }}</a>
<div class="mt-4 flex flex-wrap items-center justify-center gap-3 text-sm font-medium">
<a href="{{ route('impressum') }}" class="transition-colors hover:text-[#FFB6C1]">{{ __('legal.impressum') }}</a>
<span class="text-white/40">&bull;</span>
<a href="{{ route('datenschutz') }}" class="transition-colors hover:text-[#FFB6C1]">{{ __('legal.datenschutz') }}</a>
<span class="text-white/40">&bull;</span>
<a href="{{ route('agb') }}" class="transition-colors hover:text-[#FFB6C1]">{{ __('legal.agb') }}</a>
</div>
</div>
</footer>

View File

@@ -1,8 +1,9 @@
<?php
use App\Http\Controllers\MarketingController;
use App\Http\Controllers\CheckoutController;
use App\Http\Controllers\LocaleController;
use App\Http\Controllers\LegalPageController;
use App\Http\Controllers\MarketingController;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
@@ -17,13 +18,15 @@ Route::get('/contact', [MarketingController::class, 'contactView'])->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');