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')
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('Type')
->label('Paket-Typ')
->options([
'endcustomer' => 'Endcustomer',
'endcustomer' => 'Endkunde',
'reseller' => 'Reseller',
])
->required(),
TextInput::make('price')
->label('Price')
->prefix('€')
->label('Preis')
->numeric()
->step(0.01)
->required()
->default(0),
->prefix('€')
->required(),
TextInput::make('max_photos')
->label('Max Photos')
->label('Max. Fotos')
->numeric()
->minValue(0)
->nullable(),
TextInput::make('max_guests')
->label('Max Guests')
->label('Max. Gäste')
->numeric()
->minValue(0)
->nullable(),
TextInput::make('gallery_days')
->label('Gallery Days')
->label('Galeriedauer (Tage)')
->numeric()
->minValue(0)
->nullable(),
TextInput::make('max_tasks')
->label('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('Watermark Allowed')
->label('Wasserzeichen erlaubt')
->default(true),
Toggle::make('branding_allowed')
->label('Branding Allowed')
->label('Eigenes Branding erlaubt')
->default(false),
TextInput::make('max_events_per_year')
->label('Max Events per Year')
->numeric()
->nullable(),
Repeater::make('features')
->label('Features')
]),
Section::make('Features & Kennzahlen')
->columns(1)
->schema([
TextInput::make('key')
->label('Feature Key'),
TextInput::make('value')
->label('Feature Value'),
])
CheckboxList::make('features')
->label('Aktive Features')
->options($featureOptions)
->columns(2)
->defaultItems(0),
->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 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;
}
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',
$packages = [
[
'slug' => 'free-package',
'name' => 'Free / Test',
'name_translations' => [
'de' => 'Free / Test',
'en' => 'Free / Test',
],
'type' => PackageType::ENDCUSTOMER,
'price' => 0.00,
'max_photos' => 30,
'max_guests' => 50,
'max_photos' => 120,
'max_guests' => 25,
'gallery_days' => 7,
'max_tasks' => 5,
'max_tasks' => 8,
'watermark_allowed' => true,
'branding_allowed' => false,
'features' => json_encode([
'basic_uploads' => true,
'limited_sharing' => true,
'no_branding' => true,
]),
]);
Package::create([
'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',
'slug' => Str::slug('Starter'),
'name_translations' => [
'de' => 'Starter',
'en' => 'Starter',
],
'type' => PackageType::ENDCUSTOMER,
'price' => 29.00,
'max_photos' => 200,
'max_guests' => 100,
'gallery_days' => 30,
'max_tasks' => 10,
'price' => 59.00,
'max_photos' => 300,
'max_guests' => 50,
'gallery_days' => 14,
'max_tasks' => 30,
'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'),
'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' => 79.00,
'price' => 129.00,
'max_photos' => 1000,
'max_guests' => 500,
'gallery_days' => 90,
'max_tasks' => 20,
'max_guests' => 150,
'gallery_days' => 30,
'max_tasks' => 100,
'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)'),
'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' => 199.00,
'max_photos' => 500, // per event limit
'max_guests' => null, // unlimited
'gallery_days' => null,
'max_tasks' => null, // unlimited
'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()->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)'),
'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' => 399.00,
'max_photos' => 1000, // per event limit
'max_guests' => null, // unlimited
'gallery_days' => null,
'max_tasks' => null, // unlimited
'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()->addYear(),
'features' => json_encode([
'reseller_dashboard' => true,
'custom_branding' => true,
'priority_support' => true,
'advanced_reporting' => true,
]),
]);
'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>
);
}

View File

@@ -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<PackagesProps> = ({ 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<PackagesProps> = ({ 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,7 +121,7 @@ const Packages: React.FC<PackagesProps> = ({ endcustomerPackages, resellerPackag
// nextStep entfernt, da Tabs nun parallel sind
const getFeatureIcon = (feature: string) => {
const getFeatureIcon = (feature: string) => {
switch (feature) {
case 'basic_uploads': return <Image className="w-4 h-4" />;
case 'unlimited_sharing': return <ArrowRight className="w-4 h-4" />;
@@ -85,7 +133,293 @@ const Packages: React.FC<PackagesProps> = ({ endcustomerPackages, resellerPackag
case 'custom_branding': return <Image className="w-4 h-4" />;
default: return <Check className="w-4 h-4" />;
}
};
};
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 (
<Card
className={cn(
'group relative h-full overflow-hidden border border-gray-200/80 bg-white/95 px-0 pb-6 pt-14 transition-all duration-500 dark:border-gray-700 dark:bg-gray-900',
highlight && `bg-gradient-to-br ${accent.gradient} ${accent.highlightShadow} ${accent.ring}`,
!highlight && !compact && 'hover:-translate-y-2 hover:shadow-2xl',
compact && 'pt-10 shadow-none hover:translate-y-0',
className,
)}
>
<div
className={cn(
'pointer-events-none absolute inset-x-0 top-0 h-1 bg-gradient-to-r',
accent.topBar,
highlight ? 'opacity-100' : 'opacity-0 transition-opacity duration-500 group-hover:opacity-90',
)}
/>
<CardHeader className="relative flex flex-col gap-3 px-6">
<div className="flex items-center justify-between">
<Badge
variant="outline"
className={cn(
'rounded-full border-transparent px-3 py-1 text-xs font-semibold uppercase tracking-wider',
accent.badge,
)}
>
{typeLabel}
</Badge>
{badgeLabel && (
<Badge
className={cn(
'flex items-center gap-1 rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-wider shadow-sm',
highlight
? 'bg-gray-900 text-white dark:bg-white dark:text-gray-900'
: accent.badge,
)}
>
{highlight && <Sparkles className="h-3.5 w-3.5" aria-hidden />}
{badgeLabel}
</Badge>
)}
</div>
<CardTitle className="text-2xl font-display text-gray-900 dark:text-white">
{pkg.name}
</CardTitle>
<CardDescription className="text-sm text-gray-600 dark:text-gray-300">
{pkg.description}
</CardDescription>
</CardHeader>
<CardContent className={cn('mt-2 flex flex-col gap-6 px-6', compact && 'gap-4')}>
<div className="space-y-2">
<div className="flex items-baseline gap-2">
<span className={cn('text-4xl font-bold', accent.price)}>{priceLabel}</span>
{pkg.price !== 0 && (
<span className="text-sm font-medium text-muted-foreground">
/ {cadenceLabel}
</span>
)}
</div>
{variant === 'endcustomer' && (
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-gray-400 dark:text-gray-500">
{pkg.events} × {t('packages.one_time')}
</p>
)}
</div>
<Separator className="bg-gray-200/80 dark:bg-gray-700/60" />
<div className={cn('flex flex-wrap gap-2', compact && 'gap-1.5')}>
{featureBadges.map((feature) => (
<Badge
key={feature}
variant="outline"
className={cn(
'flex items-center gap-1 rounded-full border-transparent bg-white/70 px-3 py-1 text-xs font-medium text-gray-700 shadow-sm transition-colors group-hover:bg-white dark:bg-gray-800/70 dark:text-gray-200',
highlight && 'bg-white/80 dark:bg-white/10',
)}
>
{getFeatureIcon(feature)}
<span>{t(`packages.feature_${feature}`)}</span>
</Badge>
))}
{pkg.watermark_allowed === false && (
<Badge
variant="outline"
className="rounded-full border-transparent bg-emerald-100/80 px-3 py-1 text-xs font-medium text-emerald-700 shadow-sm dark:bg-emerald-500/20 dark:text-emerald-100"
>
<Shield className="mr-1 h-3.5 w-3.5" aria-hidden />
{t('packages.no_watermark')}
</Badge>
)}
{pkg.branding_allowed && (
<Badge
variant="outline"
className="rounded-full border-transparent bg-sky-100/80 px-3 py-1 text-xs font-medium text-sky-700 shadow-sm dark:bg-sky-500/20 dark:text-sky-100"
>
<Sparkles className="mr-1 h-3.5 w-3.5" aria-hidden />
{t('packages.custom_branding')}
</Badge>
)}
{extraFeatureCount > 0 && (
<Badge
variant="outline"
className="rounded-full border-dashed border-gray-300 bg-transparent px-3 py-1 text-xs font-medium text-gray-500 dark:border-gray-600 dark:text-gray-300"
>
{t('packages.more_features', { count: extraFeatureCount })}
</Badge>
)}
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{metrics.map((metric) => (
<div
key={metric.key}
className={cn(
'rounded-xl border border-gray-200/70 bg-white/80 px-3 py-3 text-center shadow-sm transition-colors dark:border-gray-700/70 dark:bg-gray-800/70',
highlight && 'border-transparent bg-white/90 dark:bg-white/5',
)}
>
<p className="text-lg font-semibold text-gray-900 dark:text-white">
{metric.value}
</p>
<p className="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
{metric.label}
</p>
</div>
))}
</div>
</CardContent>
{showCTA && onSelect && (
<CardFooter className="mt-auto px-6 pt-0">
<Button
onClick={() => onSelect(pkg)}
className={cn(
'w-full justify-center gap-2 text-sm font-semibold tracking-wide transition-all duration-300',
accent.ctaShadow,
highlight ? accent.buttonHighlight : accent.buttonDefault,
)}
>
{ctaLabel ?? t('packages.view_details')}
<ArrowRight className="h-4 w-4" aria-hidden />
</Button>
</CardFooter>
)}
</Card>
);
}
return (
<MarketingLayout title={t('packages.title')}>
@@ -104,47 +438,18 @@ const Packages: React.FC<PackagesProps> = ({ endcustomerPackages, resellerPackag
<div className="container mx-auto">
<h2 className="text-3xl font-bold text-center mb-12 font-display">{t('packages.section_endcustomer')}</h2>
{/* Mobile Carousel for Endcustomer Packages */}
<div className="block md:hidden">
<Carousel className="w-full max-w-md mx-auto" opts={{ loop: true }}>
<CarouselContent className="-ml-1">
<Carousel className="mx-auto w-full max-w-md" opts={{ loop: true }}>
<CarouselContent className="-ml-2">
{endcustomerPackages.map((pkg) => (
<CarouselItem key={pkg.id} className="pl-1 basis-full">
<div
className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md border hover:shadow-lg transition-all duration-300"
onTouchStart={(e) => 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')}
>
<div className="text-center mb-4">
<ShoppingCart className="w-12 h-12 text-[#FFB6C1] mx-auto" />
</div>
<h3 className="text-xl font-semibold mb-2 font-sans-marketing">{pkg.name}</h3>
<p className="text-gray-600 dark:text-gray-300 mb-4 font-serif-custom">{pkg.description}</p>
<p className="text-2xl font-bold text-[#FFB6C1] mb-4 font-sans-marketing">
{pkg.price === 0 ? t('packages.free') : `${pkg.price} ${t('packages.currency.euro')}`}
</p>
<ul className="text-sm text-gray-600 dark:text-gray-300 mb-6 space-y-1 font-sans-marketing">
<li> {pkg.events} {t('packages.one_time')}</li>
{pkg.features.map((feature, index) => (
<li key={index} className="flex items-center">
{getFeatureIcon(feature)} {t(`packages.feature_${feature}`)}
</li>
))}
{pkg.limits?.max_photos && <li> {t('packages.max_photos')} {pkg.limits.max_photos}</li>}
{pkg.limits?.gallery_days && <li> {t('packages.gallery_days')} {pkg.limits.gallery_days}</li>}
{pkg.limits?.max_guests && <li> {t('packages.max_guests')} {pkg.limits.max_guests}</li>}
{pkg.watermark_allowed === false && <li><Badge variant="secondary">{t('packages.no_watermark')}</Badge></li>}
{pkg.branding_allowed && <li><Badge variant="secondary">{t('packages.custom_branding')}</Badge></li>}
</ul>
<Button
variant="outline"
onClick={() => handleCardClick(pkg)}
className="w-full mt-4 font-sans-marketing"
>
{t('packages.view_details')}
</Button>
</div>
<CarouselItem key={pkg.id} className="basis-full pl-2">
<PackageCard
pkg={pkg}
variant="endcustomer"
highlight={pkg.id === highlightEndcustomerId}
onSelect={handleCardClick}
className="h-full"
/>
</CarouselItem>
))}
</CarouselContent>
@@ -153,45 +458,16 @@ const Packages: React.FC<PackagesProps> = ({ endcustomerPackages, resellerPackag
</Carousel>
</div>
{/* Desktop Grid for Endcustomer Packages */}
<div className="hidden md:block">
<div className="grid md:grid-cols-3 gap-8">
<div className="hidden gap-6 md:grid md:grid-cols-2 lg:gap-8 lg:grid-cols-3">
{endcustomerPackages.map((pkg) => (
<div
<PackageCard
key={pkg.id}
className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md border hover:shadow-lg transition-all duration-300"
>
<div className="text-center mb-4">
<ShoppingCart className="w-12 h-12 text-[#FFB6C1] mx-auto" />
</div>
<h3 className="text-xl font-semibold mb-2 font-sans-marketing">{pkg.name}</h3>
<p className="text-gray-600 dark:text-gray-300 mb-4 font-serif-custom">{pkg.description}</p>
<p className="text-2xl font-bold text-[#FFB6C1] mb-4 font-sans-marketing">
{pkg.price === 0 ? t('packages.free') : `${pkg.price} ${t('packages.currency.euro')}`}
</p>
<ul className="text-sm text-gray-600 dark:text-gray-300 mb-6 space-y-1 font-sans-marketing">
<li> {pkg.events} {t('packages.one_time')}</li>
{pkg.features.map((feature, index) => (
<li key={index} className="flex items-center">
{getFeatureIcon(feature)} {t(`packages.feature_${feature}`)}
</li>
pkg={pkg}
variant="endcustomer"
highlight={pkg.id === highlightEndcustomerId}
onSelect={handleCardClick}
/>
))}
{pkg.limits?.max_photos && <li> {t('packages.max_photos')} {pkg.limits.max_photos}</li>}
{pkg.limits?.gallery_days && <li> {t('packages.gallery_days')} {pkg.limits.gallery_days}</li>}
{pkg.limits?.max_guests && <li> {t('packages.max_guests')} {pkg.limits.max_guests}</li>}
{pkg.watermark_allowed === false && <li><Badge variant="secondary">{t('packages.no_watermark')}</Badge></li>}
{pkg.branding_allowed && <li><Badge variant="secondary">{t('packages.custom_branding')}</Badge></li>}
</ul>
<Button
variant="outline"
onClick={() => handleCardClick(pkg)}
className="w-full mt-4 font-sans-marketing"
>
{t('packages.view_details')}
</Button>
</div>
))}
</div>
</div>
</div>
@@ -207,7 +483,7 @@ const Packages: React.FC<PackagesProps> = ({ endcustomerPackages, resellerPackag
{endcustomerPackages.map((pkg) => (
<div key={pkg.id} className="text-center">
<p className="font-bold">{pkg.name}</p>
<p>{pkg.price === 0 ? t('packages.free') : `${pkg.price} ${t('currency.euro')}`}</p>
<p>{pkg.price === 0 ? t('packages.free') : `${pkg.price} ${t('packages.currency.euro')}`}</p>
</div>
))}
</div>
@@ -220,7 +496,7 @@ const Packages: React.FC<PackagesProps> = ({ endcustomerPackages, resellerPackag
{endcustomerPackages.map((pkg) => (
<div key={pkg.id} className="text-center">
<p className="font-bold">{pkg.name}</p>
<p>{pkg.limits?.max_photos || t('common.unlimited')}</p>
<p>{pkg.limits?.max_photos || tCommon('unlimited')}</p>
</div>
))}
</div>
@@ -233,7 +509,7 @@ const Packages: React.FC<PackagesProps> = ({ endcustomerPackages, resellerPackag
{endcustomerPackages.map((pkg) => (
<div key={pkg.id} className="text-center">
<p className="font-bold">{pkg.name}</p>
<p>{pkg.limits?.max_guests || t('common.unlimited')}</p>
<p>{pkg.limits?.max_guests || tCommon('unlimited')}</p>
</div>
))}
</div>
@@ -246,7 +522,7 @@ const Packages: React.FC<PackagesProps> = ({ endcustomerPackages, resellerPackag
{endcustomerPackages.map((pkg) => (
<div key={pkg.id} className="text-center">
<p className="font-bold">{pkg.name}</p>
<p>{pkg.limits?.gallery_days || t('common.unlimited')}</p>
<p>{pkg.limits?.gallery_days || tCommon('unlimited')}</p>
</div>
))}
</div>
@@ -284,7 +560,7 @@ const Packages: React.FC<PackagesProps> = ({ endcustomerPackages, resellerPackag
<TableCell className="font-semibold">{t('packages.price')}</TableCell>
{endcustomerPackages.map((pkg) => (
<TableCell key={pkg.id} className="text-center">
{pkg.price === 0 ? t('packages.free') : `${pkg.price} ${t('currency.euro')}`}
{pkg.price === 0 ? t('packages.free') : `${pkg.price} ${t('packages.currency.euro')}`}
</TableCell>
))}
</TableRow>
@@ -292,7 +568,7 @@ const Packages: React.FC<PackagesProps> = ({ endcustomerPackages, resellerPackag
<TableCell className="font-semibold">{t('packages.max_photos_label')} {getFeatureIcon('max_photos')}</TableCell>
{endcustomerPackages.map((pkg) => (
<TableCell key={pkg.id} className="text-center">
{pkg.limits?.max_photos || t('common.unlimited')}
{pkg.limits?.max_photos || tCommon('unlimited')}
</TableCell>
))}
</TableRow>
@@ -300,7 +576,7 @@ const Packages: React.FC<PackagesProps> = ({ endcustomerPackages, resellerPackag
<TableCell className="font-semibold">{t('packages.max_guests_label')} {getFeatureIcon('max_guests')}</TableCell>
{endcustomerPackages.map((pkg) => (
<TableCell key={pkg.id} className="text-center">
{pkg.limits?.max_guests || t('common.unlimited')}
{pkg.limits?.max_guests || tCommon('unlimited')}
</TableCell>
))}
</TableRow>
@@ -308,7 +584,7 @@ const Packages: React.FC<PackagesProps> = ({ endcustomerPackages, resellerPackag
<TableCell className="font-semibold">{t('packages.gallery_days_label')} {getFeatureIcon('gallery_days')}</TableCell>
{endcustomerPackages.map((pkg) => (
<TableCell key={pkg.id} className="text-center">
{pkg.limits?.gallery_days || t('common.unlimited')}
{pkg.limits?.gallery_days || tCommon('unlimited')}
</TableCell>
))}
</TableRow>
@@ -330,44 +606,18 @@ const Packages: React.FC<PackagesProps> = ({ endcustomerPackages, resellerPackag
<div className="container mx-auto">
<h2 className="text-3xl font-bold text-center mb-12 font-display">{t('packages.section_reseller')}</h2>
{/* Mobile Carousel for Reseller Packages */}
<div className="block md:hidden">
<Carousel className="w-full max-w-md mx-auto" opts={{ loop: true }}>
<CarouselContent className="-ml-1">
<Carousel className="mx-auto w-full max-w-md" opts={{ loop: true }}>
<CarouselContent className="-ml-2">
{resellerPackages.map((pkg) => (
<CarouselItem key={pkg.id} className="pl-1 basis-full">
<div
className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md border hover:shadow-lg transition-all duration-300"
onTouchStart={(e) => 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')}
>
<div className="text-center mb-4">
<ShoppingCart className="w-12 h-12 text-[#FFD700] mx-auto" />
</div>
<h3 className="text-xl font-semibold mb-2 font-sans-marketing">{pkg.name}</h3>
<p className="text-gray-600 dark:text-gray-300 mb-4 font-serif-custom">{pkg.description}</p>
<p className="text-2xl font-bold text-[#FFD700] mb-4 font-sans-marketing">
{pkg.price} {t('packages.currency.euro')} / {t('packages.year')}
</p>
<ul className="text-sm text-gray-600 dark:text-gray-300 mb-6 space-y-1 font-sans-marketing">
{pkg.features.map((feature, index) => (
<li key={index} className="flex items-center">
{getFeatureIcon(feature)} {t(`packages.feature_${feature}`)}
</li>
))}
{pkg.limits?.max_tenants && <li> {t('packages.max_tenants')} {pkg.limits.max_tenants}</li>}
{pkg.limits?.max_events && <li> {t('packages.max_events_year')} {pkg.limits.max_events}</li>}
{pkg.branding_allowed && <li><Badge variant="secondary">{t('packages.custom_branding')}</Badge></li>}
</ul>
<Button
variant="outline"
onClick={() => handleCardClick(pkg)}
className="w-full mt-4 font-sans-marketing"
>
{t('packages.view_details')}
</Button>
</div>
<CarouselItem key={pkg.id} className="basis-full pl-2">
<PackageCard
pkg={pkg}
variant="reseller"
highlight={pkg.id === highlightResellerId}
onSelect={handleCardClick}
className="h-full"
/>
</CarouselItem>
))}
</CarouselContent>
@@ -376,42 +626,16 @@ const Packages: React.FC<PackagesProps> = ({ endcustomerPackages, resellerPackag
</Carousel>
</div>
{/* Desktop Grid for Reseller Packages */}
<div className="hidden md:block">
<div className="grid md:grid-cols-2 gap-8">
<div className="hidden gap-6 md:grid md:grid-cols-2 lg:gap-8 xl:grid-cols-3">
{resellerPackages.map((pkg) => (
<div
<PackageCard
key={pkg.id}
className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md border hover:shadow-lg transition-all duration-300"
>
<div className="text-center mb-4">
<ShoppingCart className="w-12 h-12 text-[#FFD700] mx-auto" />
</div>
<h3 className="text-xl font-semibold mb-2 font-sans-marketing">{pkg.name}</h3>
<p className="text-gray-600 dark:text-gray-300 mb-4 font-serif-custom">{pkg.description}</p>
<p className="text-2xl font-bold text-[#FFD700] mb-4 font-sans-marketing">
{pkg.price} {t('packages.currency.euro')} / {t('packages.year')}
</p>
<ul className="text-sm text-gray-600 dark:text-gray-300 mb-6 space-y-1 font-sans-marketing">
{pkg.features.map((feature, index) => (
<li key={index} className="flex items-center">
{getFeatureIcon(feature)} {t(`packages.feature_${feature}`)}
</li>
pkg={pkg}
variant="reseller"
highlight={pkg.id === highlightResellerId}
onSelect={handleCardClick}
/>
))}
{pkg.limits?.max_tenants && <li> {t('packages.max_tenants')} {pkg.limits.max_tenants}</li>}
{pkg.limits?.max_events && <li> {t('packages.max_events_year')} {pkg.limits.max_events}</li>}
{pkg.branding_allowed && <li><Badge variant="secondary">{t('packages.custom_branding')}</Badge></li>}
</ul>
<Button
variant="outline"
onClick={() => handleCardClick(pkg)}
className="w-full mt-4 font-sans-marketing"
>
{t('packages.view_details')}
</Button>
</div>
))}
</div>
</div>
</div>
</section>
@@ -444,89 +668,214 @@ const Packages: React.FC<PackagesProps> = ({ endcustomerPackages, resellerPackag
{/* Modal */}
{selectedPackage && (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-2xl font-display">{selectedPackage.name} - {t('packages.details')}</DialogTitle>
<DialogContent className="max-w-5xl max-h-[90vh] overflow-y-auto border border-rose-100/70 bg-gradient-to-br from-white via-rose-50/60 to-amber-50/40 p-0 shadow-2xl dark:border-gray-700 dark:from-gray-950 dark:via-gray-900/95 dark:to-gray-900">
<div className="space-y-6 p-6 md:p-8">
<DialogHeader className="space-y-4 text-left">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex flex-1 flex-col gap-3">
<div className="flex flex-wrap items-center gap-3">
<Badge
variant="outline"
className="rounded-full border border-rose-200/70 bg-white/70 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-rose-600 shadow-sm dark:border-rose-500/30 dark:bg-gray-900/80 dark:text-rose-100"
>
{selectedVariant === 'reseller' ? t('packages.subscription') : t('packages.one_time')}
</Badge>
{selectedHighlight && (
<Badge className="flex items-center gap-1 rounded-full bg-gray-900 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-white dark:bg-white dark:text-gray-900">
<Sparkles className="h-3.5 w-3.5" />
{selectedVariant === 'reseller'
? t('packages.badge_best_value')
: t('packages.badge_most_popular')}
</Badge>
)}
</div>
<div className="flex flex-wrap items-end gap-4">
<DialogTitle className="text-3xl font-display font-semibold text-gray-900 dark:text-white">
{selectedPackage.name}
</DialogTitle>
<div className="flex items-baseline gap-2">
<span className={cn('text-3xl font-bold', getAccentTheme(selectedVariant).price)}>
{selectedPackage.price === 0
? t('packages.free')
: `${selectedPackage.price.toLocaleString()} ${t('packages.currency.euro')}`}
</span>
{selectedPackage.price !== 0 && (
<span className="text-sm font-medium text-gray-500 dark:text-gray-400">
/ {selectedVariant === 'reseller' ? t('packages.billing_per_year') : t('packages.billing_per_event')}
</span>
)}
</div>
</div>
{selectedPackage.description && (
<p className="text-sm text-gray-600 dark:text-gray-300">
{selectedPackage.description}
</p>
)}
</div>
</div>
</DialogHeader>
<Tabs value={currentStep} onValueChange={setCurrentStep} className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="step1">{t('packages.details')}</TabsTrigger>
<TabsTrigger value="step2">{t('packages.customer_opinions')}</TabsTrigger>
<TabsList className="grid w-full grid-cols-2 rounded-full bg-white/60 p-1 text-sm shadow-sm dark:bg-gray-900/60">
<TabsTrigger className="rounded-full" value="step1">{t('packages.details')}</TabsTrigger>
<TabsTrigger className="rounded-full" value="step2">{t('packages.customer_opinions')}</TabsTrigger>
</TabsList>
<TabsContent value="step1" className="mt-4">
<div className="space-y-4">
<div className="text-center">
<h2 className="text-3xl font-bold font-display">{selectedPackage.name}</h2>
<p className="text-2xl font-bold text-[#FFB6C1] mt-2">
{selectedPackage.price === 0 ? t('packages.free') : `${selectedPackage.price} ${t('packages.currency.euro')}`}
</p>
</div>
<p className="text-gray-600 dark:text-gray-300 font-sans-marketing">{selectedPackage.description}</p>
<div className="grid grid-cols-2 gap-4">
{selectedPackage.features.map((feature, index) => (
<Badge key={`feature-${index}`} variant="secondary" className="flex items-center justify-center gap-1">
{getFeatureIcon(feature)} {t(`packages.feature_${feature}`)}
<TabsContent value="step1" className="mt-6 space-y-6">
{(() => {
const accent = getAccentTheme(selectedVariant);
const metrics = resolvePackageMetrics(selectedPackage, selectedVariant, t, tCommon);
const descriptionEntries = selectedPackage.description_breakdown ?? [];
return (
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.2fr),minmax(0,0.8fr)]">
<div
className={cn(
'relative overflow-hidden rounded-3xl border border-gray-200/80 bg-white/95 p-6 shadow-xl dark:border-gray-700/70 dark:bg-gray-900/90',
selectedHighlight && `bg-gradient-to-br ${accent.gradient} ${accent.highlightShadow}`,
)}
>
<div
className="pointer-events-none absolute inset-0 opacity-70 mix-blend-overlay"
style={{
background:
'radial-gradient(circle at top left, rgba(255,182,193,0.45), transparent 55%), radial-gradient(circle at bottom right, rgba(250,204,21,0.35), transparent 55%)',
}}
/>
<div className="relative space-y-4">
<Badge className="inline-flex w-fit items-center gap-1 rounded-full bg-gray-900/90 px-3 py-1 text-xs font-semibold uppercase tracking-wider text-white shadow-md dark:bg-white/90 dark:text-gray-900">
<Sparkles className="h-3.5 w-3.5" />
{t('packages.features_label')}
</Badge>
<div className="flex flex-wrap gap-2">
{selectedPackage.features.map((feature) => (
<Badge
key={feature}
variant="outline"
className="flex items-center gap-1 rounded-full border-transparent bg-white/80 px-3 py-1 text-xs font-medium text-gray-700 shadow-sm dark:bg-gray-800/70 dark:text-gray-200"
>
{getFeatureIcon(feature)}
<span>{t(`packages.feature_${feature}`)}</span>
</Badge>
))}
{selectedPackage.limits?.max_photos && (
<Badge variant="outline" className="flex items-center justify-center gap-1">
<Image className="w-4 h-4" /> {t('packages.max_photos')} {selectedPackage.limits.max_photos}
</Badge>
)}
{selectedPackage.limits?.max_guests && (
<Badge variant="outline" className="flex items-center justify-center gap-1">
<Users className="w-4 h-4" /> {t('packages.max_guests')} {selectedPackage.limits.max_guests}
</Badge>
)}
{selectedPackage.limits?.gallery_days && (
<Badge variant="outline" className="flex items-center justify-center gap-1">
<Calendar className="w-4 h-4" /> {t('packages.gallery_days')} {selectedPackage.limits.gallery_days}
</Badge>
)}
{selectedPackage.watermark_allowed === false && (
<Badge variant="secondary" className="flex items-center justify-center gap-1">
<Shield className="w-4 h-4" /> {t('packages.no_watermark')}
<Badge className="flex items-center gap-1 rounded-full bg-emerald-100/80 px-3 py-1 text-xs font-medium text-emerald-700 shadow-sm dark:bg-emerald-500/20 dark:text-emerald-100">
<Shield className="h-3.5 w-3.5" />
{t('packages.no_watermark')}
</Badge>
)}
{selectedPackage.branding_allowed && (
<Badge variant="secondary" className="flex items-center justify-center gap-1">
<Image className="w-4 h-4" /> {t('packages.custom_branding')}
<Badge className="flex items-center gap-1 rounded-full bg-sky-100/80 px-3 py-1 text-xs font-medium text-sky-700 shadow-sm dark:bg-sky-500/20 dark:text-sky-100">
<Sparkles className="h-3.5 w-3.5" />
{t('packages.custom_branding')}
</Badge>
)}
</div>
<div className="text-center">
</div>
</div>
<div className="space-y-6">
{descriptionEntries.length > 0 && (
<div className="rounded-3xl border border-gray-200/80 bg-white/90 p-6 shadow-xl dark:border-gray-700/70 dark:bg-gray-900/90">
<h3 className="text-sm font-semibold uppercase tracking-[0.3em] text-gray-400 dark:text-gray-500">
{t('packages.breakdown_label')}
</h3>
<div className="mt-4 grid gap-3">
{descriptionEntries.map((entry, index) => (
<div
key={`${entry.title}-${index}`}
className="rounded-2xl bg-gradient-to-r from-rose-50/90 via-white to-white p-4 shadow-sm dark:from-gray-800 dark:via-gray-900 dark:to-gray-900"
>
{entry.title && (
<p className="text-xs font-semibold uppercase tracking-wide text-rose-500 dark:text-rose-200">
{entry.title}
</p>
)}
<p className="mt-1 text-sm text-gray-700 dark:text-gray-300">{entry.value}</p>
</div>
))}
</div>
</div>
)}
<div className="rounded-3xl border border-gray-200/80 bg-white/90 p-6 shadow-xl dark:border-gray-700/70 dark:bg-gray-900/90">
<h3 className="text-sm font-semibold uppercase tracking-[0.3em] text-gray-400 dark:text-gray-500">
{t('packages.limits_label')}
</h3>
<div className="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-3">
{metrics.map((metric) => (
<div
key={metric.key}
className="rounded-2xl border border-gray-200/70 bg-white/80 p-4 text-center shadow-sm dark:border-gray-700/70 dark:bg-gray-800/70"
>
<p className="text-lg font-semibold text-gray-900 dark:text-white">{metric.value}</p>
<p className="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
{metric.label}
</p>
</div>
))}
</div>
</div>
<div className="space-y-3">
<Button
asChild
className={cn(
'w-full justify-center gap-2 rounded-full py-3 text-base font-semibold transition-all duration-300',
accent.ctaShadow,
selectedHighlight ? accent.buttonHighlight : accent.buttonDefault,
)}
>
<Link
href={`/purchase-wizard/${selectedPackage.id}`}
className="w-full block bg-[#FFB6C1] text-white py-3 rounded-md font-semibold font-sans-marketing hover:bg-[#FF69B4] transition text-center"
onClick={() => {
localStorage.setItem('preferred_package', JSON.stringify(selectedPackage));
}}
>
{t('packages.to_order')}
<ArrowRight className="ml-2 h-4 w-4" aria-hidden />
</Link>
</Button>
<p className="text-xs text-center text-gray-500 dark:text-gray-400">
{t('packages.order_hint')}
</p>
</div>
</div>
</div>
);
})()}
</TabsContent>
<TabsContent value="step2" className="mt-4">
<div className="space-y-4">
<h3 className="text-xl font-semibold mb-4 font-display">{t('packages.what_customers_say')}</h3>
<div className="grid md:grid-cols-3 gap-4">
<TabsContent value="step2" className="mt-6">
<div className="space-y-6">
<h3 className="text-xl font-semibold font-display text-gray-900 dark:text-white">
{t('packages.what_customers_say')}
</h3>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{testimonials.map((testimonial, index) => (
<div key={index} className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md">
<p className="text-gray-600 dark:text-gray-300 font-sans-marketing mb-2">"{testimonial.text}"</p>
<p className="font-semibold font-sans-marketing">{testimonial.name}</p>
<div className="flex">
{[...Array(testimonial.rating)].map((_, i) => <Star key={i} className="w-4 h-4 text-yellow-400 fill-current" />)}
<div
key={index}
className="rounded-2xl border border-gray-200/60 bg-white/90 p-5 shadow-md dark:border-gray-700/60 dark:bg-gray-900/80"
>
<p className="text-sm text-gray-600 dark:text-gray-300">{testimonial.text}</p>
<div className="mt-3 flex items-center justify-between">
<span className="font-semibold text-gray-900 dark:text-white">{testimonial.name}</span>
<div className="flex gap-1">
{[...Array(testimonial.rating)].map((_, i) => (
<Star key={i} className="h-3.5 w-3.5 text-amber-400" fill="currentColor" />
))}
</div>
</div>
</div>
))}
</div>
<button onClick={() => setOpen(false)} className="w-full mt-4 text-gray-500 dark:text-gray-400 underline">
<div className="text-center">
<Button
variant="ghost"
className="text-sm font-medium text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
onClick={() => setOpen(false)}
>
{t('packages.close')}
</button>
</Button>
</div>
</div>
</TabsContent>
</Tabs>
</div>
</DialogContent>
</Dialog>
)}

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