further layout preview fixes

This commit is contained in:
Codex Agent
2025-12-12 08:34:19 +01:00
parent 57be7d0030
commit 7cf7c4b8df
8 changed files with 1767 additions and 438 deletions

181
AGENTS.md
View File

@@ -95,7 +95,7 @@ The Laravel Boost guidelines are specifically curated by Laravel maintainers for
## Foundational Context ## Foundational Context
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions. This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
- php - 8.3.6 - php - 8.3.24
- filament/filament (FILAMENT) - v4 - filament/filament (FILAMENT) - v4
- inertiajs/inertia-laravel (INERTIA) - v2 - inertiajs/inertia-laravel (INERTIA) - v2
- laravel/framework (LARAVEL) - v12 - laravel/framework (LARAVEL) - v12
@@ -207,108 +207,20 @@ protected function isAccessible(User $user, ?string $path = null): bool
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`. - Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
=== filament/core rules === === herd rules ===
## Filament ## Laravel Herd
- Filament is used by this application, check how and where to follow existing application conventions.
- Filament is a Server-Driven UI (SDUI) framework for Laravel. It allows developers to define user interfaces in PHP using structured configuration objects. It is built on top of Livewire, Alpine.js, and Tailwind CSS.
- You can use the `search-docs` tool to get information from the official Filament documentation when needed. This is very useful for Artisan command arguments, specific code examples, testing functionality, relationship management, and ensuring you're following idiomatic practices.
- Utilize static `make()` methods for consistent component initialization.
### Artisan - The application is served by Laravel Herd and will be available at: https?://[kebab-case-project-dir].test. Use the `get-absolute-url` tool to generate URLs for the user to ensure valid URLs.
- You must use the Filament specific Artisan commands to create new files or components for Filament. You can find these with the `list-artisan-commands` tool, or with `php artisan` and the `--help` option. - You must not run any commands to make the site available via HTTP(s). It is _always_ available through Laravel Herd.
- Inspect the required options, always pass `--no-interaction`, and valid arguments for other options when applicable.
### Filament's Core Features
- Actions: Handle doing something within the application, often with a button or link. Actions encapsulate the UI, the interactive modal window, and the logic that should be executed when the modal window is submitted. They can be used anywhere in the UI and are commonly used to perform one-time actions like deleting a record, sending an email, or updating data in the database based on modal form input.
- Forms: Dynamic forms rendered within other features, such as resources, action modals, table filters, and more.
- Infolists: Read-only lists of data.
- Notifications: Flash notifications displayed to users within the application.
- Panels: The top-level container in Filament that can include all other features like pages, resources, forms, tables, notifications, actions, infolists, and widgets.
- Resources: Static classes that are used to build CRUD interfaces for Eloquent models. Typically live in `app/Filament/Resources`.
- Schemas: Represent components that define the structure and behavior of the UI, such as forms, tables, or lists.
- Tables: Interactive tables with filtering, sorting, pagination, and more.
- Widgets: Small component included within dashboards, often used for displaying data in charts, tables, or as a stat.
### Relationships
- Determine if you can use the `relationship()` method on form components when you need `options` for a select, checkbox, repeater, or when building a `Fieldset`:
<code-snippet name="Relationship example for Form Select" lang="php">
Forms\Components\Select::make('user_id')
->label('Author')
->relationship('author')
->required(),
</code-snippet>
## Testing === tests rules ===
- It's important to test Filament functionality for user satisfaction.
- Ensure that you are authenticated to access the application within the test.
- Filament uses Livewire, so start assertions with `livewire()` or `Livewire::test()`.
### Example Tests ## Test Enforcement
<code-snippet name="Filament Table Test" lang="php"> - Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
livewire(ListUsers::class) - Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test` with a specific filename or filter.
->assertCanSeeTableRecords($users)
->searchTable($users->first()->name)
->assertCanSeeTableRecords($users->take(1))
->assertCanNotSeeTableRecords($users->skip(1))
->searchTable($users->last()->email)
->assertCanSeeTableRecords($users->take(-1))
->assertCanNotSeeTableRecords($users->take($users->count() - 1));
</code-snippet>
<code-snippet name="Filament Create Resource Test" lang="php">
livewire(CreateUser::class)
->fillForm([
'name' => 'Howdy',
'email' => 'howdy@example.com',
])
->call('create')
->assertNotified()
->assertRedirect();
assertDatabaseHas(User::class, [
'name' => 'Howdy',
'email' => 'howdy@example.com',
]);
</code-snippet>
<code-snippet name="Testing Multiple Panels (setup())" lang="php">
use Filament\Facades\Filament;
Filament::setCurrentPanel('app');
</code-snippet>
<code-snippet name="Calling an Action in a Test" lang="php">
livewire(EditInvoice::class, [
'invoice' => $invoice,
])->callAction('send');
expect($invoice->refresh())->isSent()->toBeTrue();
</code-snippet>
=== filament/v4 rules ===
## Filament 4
### Important Version 4 Changes
- File visibility is now `private` by default.
- The `deferFilters` method from Filament v3 is now the default behavior in Filament v4, so users must click a button before the filters are applied to the table. To disable this behavior, you can use the `deferFilters(false)` method.
- The `Grid`, `Section`, and `Fieldset` layout components no longer span all columns by default.
- The `all` pagination page method is not available for tables by default.
- All action classes extend `Filament\Actions\Action`. No action classes exist in `Filament\Tables\Actions`.
- The `Form` & `Infolist` layout components have been moved to `Filament\Schemas\Components`, for example `Grid`, `Section`, `Fieldset`, `Tabs`, `Wizard`, etc.
- A new `Repeater` component for Forms has been added.
- Icons now use the `Filament\Support\Icons\Heroicon` Enum by default. Other options are available and documented.
### Organize Component Classes Structure
- Schema components: `Schemas/Components/`
- Table columns: `Tables/Columns/`
- Table filters: `Tables/Filters/`
- Actions: `Actions/`
=== inertia-laravel/core rules === === inertia-laravel/core rules ===
@@ -356,7 +268,7 @@ Route::get('/users', function () {
## Do Things the Laravel Way ## Do Things the Laravel Way
- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool. - Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
- If you're creating a generic PHP class, use `artisan make:class`. - If you're creating a generic PHP class, use `php artisan make:class`.
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior. - Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
### Database ### Database
@@ -391,7 +303,7 @@ Route::get('/users', function () {
### Testing ### Testing
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model. - When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`. - Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
- When creating tests, make use of `php artisan make:test [options] <name>` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests. - When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
### Vite Error ### Vite Error
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`. - If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
@@ -421,6 +333,60 @@ Route::get('/users', function () {
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models. - Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
=== wayfinder/core rules ===
## Laravel Wayfinder
Wayfinder generates TypeScript functions and types for Laravel controllers and routes which you can import into your client side code. It provides type safety and automatic synchronization between backend routes and frontend code.
### Development Guidelines
- Always use `search-docs` to check wayfinder correct usage before implementing any features.
- Always Prefer named imports for tree-shaking (e.g., `import { show } from '@/actions/...'`)
- Avoid default controller imports (prevents tree-shaking)
- Run `php artisan wayfinder:generate` after route changes if Vite plugin isn't installed
### Feature Overview
- Form Support: Use `.form()` with `--with-form` flag for HTML form attributes — `<form {...store.form()}>``action="/posts" method="post"`
- HTTP Methods: Call `.get()`, `.post()`, `.patch()`, `.put()`, `.delete()` for specific methods — `show.head(1)``{ url: "/posts/1", method: "head" }`
- Invokable Controllers: Import and invoke directly as functions. For example, `import StorePost from '@/actions/.../StorePostController'; StorePost()`
- Named Routes: Import from `@/routes/` for non-controller routes. For example, `import { show } from '@/routes/post'; show(1)` for route name `post.show`
- Parameter Binding: Detects route keys (e.g., `{post:slug}`) and accepts matching object properties — `show("my-post")` or `show({ slug: "my-post" })`
- Query Merging: Use `mergeQuery` to merge with `window.location.search`, set values to `null` to remove — `show(1, { mergeQuery: { page: 2, sort: null } })`
- Query Parameters: Pass `{ query: {...} }` in options to append params — `show(1, { query: { page: 1 } })``"/posts/1?page=1"`
- Route Objects: Functions return `{ url, method }` shaped objects — `show(1)``{ url: "/posts/1", method: "get" }`
- URL Extraction: Use `.url()` to get URL string — `show.url(1)``"/posts/1"`
### Example Usage
<code-snippet name="Wayfinder Basic Usage" lang="typescript">
// Import controller methods (tree-shakable)
import { show, store, update } from '@/actions/App/Http/Controllers/PostController'
// Get route object with URL and method...
show(1) // { url: "/posts/1", method: "get" }
// Get just the URL...
show.url(1) // "/posts/1"
// Use specific HTTP methods...
show.get(1) // { url: "/posts/1", method: "get" }
show.head(1) // { url: "/posts/1", method: "head" }
// Import named routes...
import { show as postShow } from '@/routes/post' // For route name 'post.show'
postShow(1) // { url: "/posts/1", method: "get" }
</code-snippet>
### Wayfinder + Inertia
If your application uses the `<Form>` component from Inertia, you can use Wayfinder to generate form action and method automatically.
<code-snippet name="Wayfinder Form Component (React)" lang="typescript">
<Form {...store.form()}><input name="title" /></Form>
</code-snippet>
=== livewire/core rules === === livewire/core rules ===
## Livewire Core ## Livewire Core
@@ -516,7 +482,7 @@ document.addEventListener('livewire:init', function () {
## PHPUnit Core ## PHPUnit Core
- This application uses PHPUnit for testing. All tests must be written as PHPUnit classes. Use `php artisan make:test --phpunit <name>` to create a new test. - This application uses PHPUnit for testing. All tests must be written as PHPUnit classes. Use `php artisan make:test --phpunit {name}` to create a new test.
- If you see a test using "Pest", convert it to PHPUnit. - If you see a test using "Pest", convert it to PHPUnit.
- Every time a test has been updated, run that singular test. - Every time a test has been updated, run that singular test.
- When the tests relating to your feature are passing, ask the user if they would like to also run the entire test suite to make sure everything is still passing. - When the tests relating to your feature are passing, ask the user if they would like to also run the entire test suite to make sure everything is still passing.
@@ -613,6 +579,13 @@ export default () => (
- Always use Tailwind CSS v4 - do not use the deprecated utilities. - Always use Tailwind CSS v4 - do not use the deprecated utilities.
- `corePlugins` is not supported in Tailwind v4. - `corePlugins` is not supported in Tailwind v4.
- In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed.
<code-snippet name="Extending Theme in CSS" lang="css">
@theme {
--color-brand: oklch(0.72 0.11 178);
}
</code-snippet>
- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3: - In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3:
<code-snippet name="Tailwind v4 Import Tailwind Diff" lang="diff"> <code-snippet name="Tailwind v4 Import Tailwind Diff" lang="diff">
@@ -640,12 +613,4 @@ export default () => (
| overflow-ellipsis | text-ellipsis | | overflow-ellipsis | text-ellipsis |
| decoration-slice | box-decoration-slice | | decoration-slice | box-decoration-slice |
| decoration-clone | box-decoration-clone | | decoration-clone | box-decoration-clone |
=== tests rules ===
## Test Enforcement
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test` with a specific filename or filter.
</laravel-boost-guidelines> </laravel-boost-guidelines>

View File

@@ -6,6 +6,30 @@ use App\Models\InviteLayout;
class JoinTokenLayoutRegistry class JoinTokenLayoutRegistry
{ {
private const DEFAULT_DESCRIPTION = 'Helft uns, diesen besonderen Tag mit euren schönen Momenten festzuhalten.';
private const DEFAULT_INSTRUCTIONS = [
'QR-Code scannen'."\n".'Kamera-App öffnen und auf den Code richten.',
'Webseite öffnen'."\n".'Der Link öffnet direkt das gemeinsame Jubiläumsalbum.',
'Fotos hochladen'."\n".'Zeigt eure Lieblingsmomente oder erfüllt kleine Fotoaufgaben, um besondere Erinnerungen beizusteuern.',
];
private const SLOTS_PORTRAIT = [
'headline' => ['x' => 0.08, 'y' => 0.1, 'w' => 0.84, 'h' => 0.12, 'fontSize' => 30, 'fontWeight' => 800, 'align' => 'center'],
'subtitle' => ['x' => 0.1, 'y' => 0.13, 'w' => 0.8, 'h' => 0.08, 'fontSize' => 18, 'fontWeight' => 600, 'align' => 'center'],
'description' => ['x' => 0.1, 'y' => 0.18, 'w' => 0.8, 'h' => 0.12, 'fontSize' => 16, 'lineHeight' => 1.4, 'align' => 'center'],
'qr' => ['x' => 0.35, 'y' => 0.4, 'w' => 0.3, 'h' => 0.3],
'instructions' => ['x' => 0.12, 'y' => 0.72, 'w' => 0.76, 'h' => 0.16, 'fontSize' => 12, 'lineHeight' => 1.3, 'align' => 'center'],
];
private const SLOTS_FOLDABLE = [
'headline' => ['x' => 0.1, 'y' => 0.1, 'w' => 0.8, 'h' => 0.12, 'fontSize' => 28, 'fontWeight' => 800, 'align' => 'center'],
'subtitle' => ['x' => 0.12, 'y' => 0.18, 'w' => 0.76, 'h' => 0.08, 'fontSize' => 18, 'fontWeight' => 600, 'align' => 'center'],
'description' => ['x' => 0.12, 'y' => 0.24, 'w' => 0.76, 'h' => 0.12, 'fontSize' => 15, 'lineHeight' => 1.4, 'align' => 'center'],
'qr' => ['x' => 0.36, 'y' => 0.4, 'w' => 0.28, 'h' => 0.28],
'instructions' => ['x' => 0.14, 'y' => 0.72, 'w' => 0.72, 'h' => 0.16, 'fontSize' => 12, 'lineHeight' => 1.3, 'align' => 'center'],
];
/** /**
* Layout definitions for printable invite cards. * Layout definitions for printable invite cards.
* *
@@ -16,15 +40,17 @@ class JoinTokenLayoutRegistry
'id' => 'foldable-table-a5', 'id' => 'foldable-table-a5',
'name' => 'Foldable Table Card (A5)', 'name' => 'Foldable Table Card (A5)',
'subtitle' => 'Doppelseitige Tischkarte zum Falten QR vorn & hinten.', 'subtitle' => 'Doppelseitige Tischkarte zum Falten QR vorn & hinten.',
'description' => 'Zwei identische Hälften auf A4 quer, rechte Seite gespiegelt für sauberes Falten.', 'description' => self::DEFAULT_DESCRIPTION,
'paper' => 'a4', 'paper' => 'a4',
'orientation' => 'landscape', 'orientation' => 'landscape',
'panel_mode' => 'double-mirror', 'panel_mode' => 'double-mirror',
'container_padding_px' => 28, 'format_hint' => 'foldable-a5',
'background' => '#F8FAFC', 'slots' => self::SLOTS_FOLDABLE,
'background_gradient' => [ 'container_padding_px' => 28,
'angle' => 180, 'background' => '#F8FAFC',
'stops' => ['#F8FAFC', '#EEF2FF', '#F8FAFC'], 'background_gradient' => [
'angle' => 180,
'stops' => ['#F8FAFC', '#EEF2FF', '#F8FAFC'],
], ],
'text' => '#0F172A', 'text' => '#0F172A',
'accent' => '#2563EB', 'accent' => '#2563EB',
@@ -38,25 +64,22 @@ class JoinTokenLayoutRegistry
'link_label' => 'fotospiel.app/DEINCODE', 'link_label' => 'fotospiel.app/DEINCODE',
'qr' => ['size_px' => 520], 'qr' => ['size_px' => 520],
'svg' => ['width' => 1754, 'height' => 1240], 'svg' => ['width' => 1754, 'height' => 1240],
'instructions' => [ 'instructions' => self::DEFAULT_INSTRUCTIONS,
'QR-Code scannen oder Kurzlink öffnen.',
'Anzeigenamen wählen kein Account nötig.',
'Fotos hochladen, liken & kommentieren.',
'Challenges spielen und Punkte sammeln.',
],
], ],
'evergreen-vows' => [ 'evergreen-vows' => [
'id' => 'evergreen-vows', 'id' => 'evergreen-vows',
'name' => 'Evergreen Vows', 'name' => 'Evergreen Vows',
'subtitle' => 'Romantische Einladung für Trauung & Empfang.', 'subtitle' => 'Romantische Einladung für Trauung & Empfang.',
'description' => 'Weiche Pastelltöne, florale Akzente und viel Raum für eine herzliche Begrüßung.', 'description' => self::DEFAULT_DESCRIPTION,
'paper' => 'a4', 'paper' => 'a4',
'orientation' => 'portrait', 'orientation' => 'portrait',
'background' => '#FBF7F2', 'format_hint' => 'poster-a4',
'background_gradient' => [ 'slots' => self::SLOTS_PORTRAIT,
'angle' => 165, 'background' => '#FBF7F2',
'stops' => ['#FBF7F2', '#FDECEF', '#F4F0FF'], 'background_gradient' => [
], 'angle' => 165,
'stops' => ['#FBF7F2', '#FDECEF', '#F4F0FF'],
],
'text' => '#2C1A27', 'text' => '#2C1A27',
'accent' => '#B85C76', 'accent' => '#B85C76',
'secondary' => '#E7D6DC', 'secondary' => '#E7D6DC',
@@ -69,26 +92,22 @@ class JoinTokenLayoutRegistry
'cta_caption' => 'Sofort starten', 'cta_caption' => 'Sofort starten',
'qr' => ['size_px' => 640], 'qr' => ['size_px' => 640],
'svg' => ['width' => 1240, 'height' => 1754], 'svg' => ['width' => 1240, 'height' => 1754],
'instructions' => [ 'instructions' => self::DEFAULT_INSTRUCTIONS,
'QR-Code scannen oder fotospiel.app/DEINCODE eingeben.',
'Anzeigenamen wählen kein Account nötig.',
'Fotos hochladen und Aufgaben erfüllen, so oft ihr wollt.',
'Highlights liken, Kommentare und Grüße dalassen.',
'Datenschutz ready: anonyme Sessions, keine App-Installation.',
],
], ],
'midnight-gala' => [ 'midnight-gala' => [
'id' => 'midnight-gala', 'id' => 'midnight-gala',
'name' => 'Midnight Gala', 'name' => 'Midnight Gala',
'subtitle' => 'Eleganter Auftritt für Corporate Events & Galas.', 'subtitle' => 'Eleganter Auftritt für Corporate Events & Galas.',
'description' => 'Dunkle Bühne mit goldenen Akzenten und kräftiger Typografie.', 'description' => self::DEFAULT_DESCRIPTION,
'paper' => 'a4', 'paper' => 'a4',
'orientation' => 'portrait', 'orientation' => 'portrait',
'background' => '#0B132B', 'format_hint' => 'poster-a4',
'background_gradient' => [ 'slots' => self::SLOTS_PORTRAIT,
'angle' => 200, 'background' => '#0B132B',
'stops' => ['#0B132B', '#1C2541', '#274690'], 'background_gradient' => [
], 'angle' => 200,
'stops' => ['#0B132B', '#1C2541', '#274690'],
],
'text' => '#F8FAFC', 'text' => '#F8FAFC',
'accent' => '#F9C74F', 'accent' => '#F9C74F',
'secondary' => '#4E5D8F', 'secondary' => '#4E5D8F',
@@ -101,26 +120,22 @@ class JoinTokenLayoutRegistry
'cta_caption' => 'Keine App nötig', 'cta_caption' => 'Keine App nötig',
'qr' => ['size_px' => 640], 'qr' => ['size_px' => 640],
'svg' => ['width' => 1240, 'height' => 1754], 'svg' => ['width' => 1240, 'height' => 1754],
'instructions' => [ 'instructions' => self::DEFAULT_INSTRUCTIONS,
'QR-Code scannen oder fotospiel.app/DEINCODE eingeben.',
'Anzeigenamen wählen kein Account nötig.',
'Fotos hochladen und Aufgaben erfüllen, so oft ihr wollt.',
'Highlights liken, Kommentare und Grüße dalassen.',
'Datenschutz ready: anonyme Sessions, keine App-Installation.',
],
], ],
'garden-brunch' => [ 'garden-brunch' => [
'id' => 'garden-brunch', 'id' => 'garden-brunch',
'name' => 'Garden Brunch', 'name' => 'Garden Brunch',
'subtitle' => 'Luftiges Layout für Tages-Events & Familienfeiern.', 'subtitle' => 'Luftiges Layout für Tages-Events & Familienfeiern.',
'description' => 'Sanfte Grüntöne, natürliche Formen und Platz für Hinweise.', 'description' => self::DEFAULT_DESCRIPTION,
'paper' => 'a4', 'paper' => 'a4',
'orientation' => 'portrait', 'orientation' => 'portrait',
'background' => '#F6F9F4', 'format_hint' => 'poster-a4',
'background_gradient' => [ 'slots' => self::SLOTS_PORTRAIT,
'angle' => 120, 'background' => '#F6F9F4',
'stops' => ['#F6F9F4', '#EEF5E7', '#F8FAF0'], 'background_gradient' => [
], 'angle' => 120,
'stops' => ['#F6F9F4', '#EEF5E7', '#F8FAF0'],
],
'text' => '#2F4030', 'text' => '#2F4030',
'accent' => '#6BAA75', 'accent' => '#6BAA75',
'secondary' => '#DDE9D8', 'secondary' => '#DDE9D8',
@@ -133,26 +148,22 @@ class JoinTokenLayoutRegistry
'cta_caption' => 'Los gehts', 'cta_caption' => 'Los gehts',
'qr' => ['size_px' => 660], 'qr' => ['size_px' => 660],
'svg' => ['width' => 1240, 'height' => 1754], 'svg' => ['width' => 1240, 'height' => 1754],
'instructions' => [ 'instructions' => self::DEFAULT_INSTRUCTIONS,
'QR-Code scannen oder fotospiel.app/DEINCODE eingeben.',
'Anzeigenamen wählen kein Account nötig.',
'Fotos hochladen und Aufgaben erfüllen, so oft ihr wollt.',
'Highlights liken, Kommentare und Grüße dalassen.',
'Datenschutz ready: anonyme Sessions, keine App-Installation.',
],
], ],
'sparkler-soiree' => [ 'sparkler-soiree' => [
'id' => 'sparkler-soiree', 'id' => 'sparkler-soiree',
'name' => 'Sparkler Soirée', 'name' => 'Sparkler Soirée',
'subtitle' => 'Abendliches Layout mit funkelndem Verlauf.', 'subtitle' => 'Abendliches Layout mit funkelndem Verlauf.',
'description' => 'Dynamische Typografie mit zentralem Fokus auf dem QR-Code.', 'description' => self::DEFAULT_DESCRIPTION,
'paper' => 'a4', 'paper' => 'a4',
'orientation' => 'portrait', 'orientation' => 'portrait',
'background' => '#1B1A44', 'format_hint' => 'poster-a4',
'background_gradient' => [ 'slots' => self::SLOTS_PORTRAIT,
'angle' => 205, 'background' => '#1B1A44',
'stops' => ['#1B1A44', '#42275A', '#734B8F'], 'background_gradient' => [
], 'angle' => 205,
'stops' => ['#1B1A44', '#42275A', '#734B8F'],
],
'text' => '#FDF7FF', 'text' => '#FDF7FF',
'accent' => '#F9A826', 'accent' => '#F9A826',
'secondary' => '#DDB7FF', 'secondary' => '#DDB7FF',
@@ -165,26 +176,22 @@ class JoinTokenLayoutRegistry
'cta_caption' => 'Challenges spielen', 'cta_caption' => 'Challenges spielen',
'qr' => ['size_px' => 680], 'qr' => ['size_px' => 680],
'svg' => ['width' => 1240, 'height' => 1754], 'svg' => ['width' => 1240, 'height' => 1754],
'instructions' => [ 'instructions' => self::DEFAULT_INSTRUCTIONS,
'QR-Code scannen oder fotospiel.app/DEINCODE eingeben.',
'Anzeigenamen wählen kein Account nötig.',
'Fotos hochladen und Aufgaben erfüllen, so oft ihr wollt.',
'Highlights liken, Kommentare und Grüße dalassen.',
'Datenschutz ready: anonyme Sessions, keine App-Installation.',
],
], ],
'confetti-bash' => [ 'confetti-bash' => [
'id' => 'confetti-bash', 'id' => 'confetti-bash',
'name' => 'Confetti Bash', 'name' => 'Confetti Bash',
'subtitle' => 'Verspielter Look für Geburtstage & Jubiläen.', 'subtitle' => 'Verspielter Look für Geburtstage & Jubiläen.',
'description' => 'Konfetti-Sprenkel, fröhliche Farben und viel Platz für Hinweise.', 'description' => self::DEFAULT_DESCRIPTION,
'paper' => 'a4', 'paper' => 'a4',
'orientation' => 'portrait', 'orientation' => 'portrait',
'background' => '#FFF9F0', 'format_hint' => 'poster-a4',
'background_gradient' => [ 'slots' => self::SLOTS_PORTRAIT,
'angle' => 145, 'background' => '#FFF9F0',
'stops' => ['#FFF9F0', '#FFEFEF', '#FFF5D6'], 'background_gradient' => [
], 'angle' => 145,
'stops' => ['#FFF9F0', '#FFEFEF', '#FFF5D6'],
],
'text' => '#31291F', 'text' => '#31291F',
'accent' => '#FF6F61', 'accent' => '#FF6F61',
'secondary' => '#F9D6A5', 'secondary' => '#F9D6A5',
@@ -197,13 +204,7 @@ class JoinTokenLayoutRegistry
'cta_caption' => 'Likes vergeben', 'cta_caption' => 'Likes vergeben',
'qr' => ['size_px' => 680], 'qr' => ['size_px' => 680],
'svg' => ['width' => 1240, 'height' => 1754], 'svg' => ['width' => 1240, 'height' => 1754],
'instructions' => [ 'instructions' => self::DEFAULT_INSTRUCTIONS,
'QR-Code scannen oder fotospiel.app/DEINCODE eingeben.',
'Anzeigenamen wählen kein Account nötig.',
'Fotos hochladen und Aufgaben erfüllen, so oft ihr wollt.',
'Highlights liken, Kommentare und Grüße dalassen.',
'Datenschutz ready: anonyme Sessions, keine App-Installation.',
],
], ],
]; ];
@@ -280,7 +281,7 @@ class JoinTokenLayoutRegistry
'height' => 1754, 'height' => 1754,
], ],
'background_gradient' => null, 'background_gradient' => null,
'instructions' => [], 'instructions' => self::DEFAULT_INSTRUCTIONS,
'formats' => ['pdf', 'png'], 'formats' => ['pdf', 'png'],
]; ];
@@ -308,11 +309,22 @@ class JoinTokenLayoutRegistry
return $normalized; return $normalized;
} }
private static function defaultSlotsPortrait(): array
{
return self::SLOTS_PORTRAIT;
}
private static function defaultSlotsFoldable(): array
{
return self::SLOTS_FOLDABLE;
}
private static function fromModel(InviteLayout $layout): array private static function fromModel(InviteLayout $layout): array
{ {
$preview = $layout->preview ?? []; $preview = $layout->preview ?? [];
$options = $layout->layout_options ?? []; $options = $layout->layout_options ?? [];
$instructions = $layout->instructions ?? []; $instructions = $layout->instructions ?? [];
$slots = $options['slots'] ?? null;
return array_filter([ return array_filter([
'id' => $layout->slug, 'id' => $layout->slug,
@@ -321,6 +333,7 @@ class JoinTokenLayoutRegistry
'description' => $layout->description, 'description' => $layout->description,
'paper' => $layout->paper, 'paper' => $layout->paper,
'orientation' => $layout->orientation, 'orientation' => $layout->orientation,
'format_hint' => self::resolveFormatHint($layout->paper, $layout->orientation, $layout->panel_mode),
'background' => $preview['background'] ?? null, 'background' => $preview['background'] ?? null,
'background_gradient' => $preview['background_gradient'] ?? null, 'background_gradient' => $preview['background_gradient'] ?? null,
'text' => $preview['text'] ?? null, 'text' => $preview['text'] ?? null,
@@ -334,6 +347,7 @@ class JoinTokenLayoutRegistry
'cta_caption' => $options['cta_caption'] ?? null, 'cta_caption' => $options['cta_caption'] ?? null,
'link_label' => $options['link_label'] ?? null, 'link_label' => $options['link_label'] ?? null,
'logo_url' => $options['logo_url'] ?? null, 'logo_url' => $options['logo_url'] ?? null,
'slots' => is_array($slots) ? $slots : null,
'qr' => array_filter([ 'qr' => array_filter([
'size_px' => $preview['qr']['size_px'] ?? $options['qr']['size_px'] ?? $preview['qr_size_px'] ?? $options['qr_size_px'] ?? null, 'size_px' => $preview['qr']['size_px'] ?? $options['qr']['size_px'] ?? $preview['qr_size_px'] ?? $options['qr_size_px'] ?? null,
]), ]),
@@ -346,6 +360,23 @@ class JoinTokenLayoutRegistry
], fn ($value) => $value !== null && $value !== []); ], fn ($value) => $value !== null && $value !== []);
} }
private static function resolveFormatHint(?string $paper, ?string $orientation, ?string $panelMode): ?string
{
$paperVal = strtolower((string) $paper);
$orientationVal = strtolower((string) $orientation);
$panelVal = strtolower((string) $panelMode);
if ($paperVal === 'a4' && $orientationVal === 'portrait' && $panelVal !== 'double-mirror') {
return 'poster-a4';
}
if ($paperVal === 'a4' && $orientationVal === 'landscape' && $panelVal === 'double-mirror') {
return 'foldable-a5';
}
return null;
}
/** /**
* Map layouts into an API-ready response structure, attaching URLs. * Map layouts into an API-ready response structure, attaching URLs.
* *
@@ -365,18 +396,23 @@ class JoinTokenLayoutRegistry
'paper' => $layout['paper'] ?? 'a4', 'paper' => $layout['paper'] ?? 'a4',
'orientation' => $layout['orientation'] ?? 'portrait', 'orientation' => $layout['orientation'] ?? 'portrait',
'panel_mode' => $layout['panel_mode'] ?? null, 'panel_mode' => $layout['panel_mode'] ?? null,
'format_hint' => $layout['format_hint'] ?? self::resolveFormatHint($layout['paper'] ?? null, $layout['orientation'] ?? null, $layout['panel_mode'] ?? null),
'badge_label' => $layout['badge_label'] ?? null, 'badge_label' => $layout['badge_label'] ?? null,
'instructions_heading' => $layout['instructions_heading'] ?? null, 'instructions_heading' => $layout['instructions_heading'] ?? null,
'link_heading' => $layout['link_heading'] ?? null, 'link_heading' => $layout['link_heading'] ?? null,
'cta_label' => $layout['cta_label'] ?? null, 'cta_label' => $layout['cta_label'] ?? null,
'cta_caption' => $layout['cta_caption'] ?? null, 'cta_caption' => $layout['cta_caption'] ?? null,
'instructions' => $layout['instructions'] ?? [], 'instructions' => $layout['instructions'] ?? [],
'slots' => $layout['slots'] ?? null,
'preview' => [ 'preview' => [
'background' => $layout['background'], 'background' => $layout['background'],
'background_gradient' => $layout['background_gradient'], 'background_gradient' => $layout['background_gradient'],
'accent' => $layout['accent'], 'accent' => $layout['accent'],
'text' => $layout['text'], 'text' => $layout['text'],
'qr_size_px' => $layout['qr']['size_px'] ?? null, 'qr_size_px' => $layout['qr']['size_px'] ?? null,
'aspect_ratio' => isset($layout['svg']['width'], $layout['svg']['height']) && $layout['svg']['width'] && $layout['svg']['height']
? (float) $layout['svg']['width'] / (float) $layout['svg']['height']
: null,
], ],
'formats' => $formats, 'formats' => $formats,
'download_urls' => collect($formats) 'download_urls' => collect($formats)

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@ import React from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ChevronRight, RefreshCcw, ArrowLeft } from 'lucide-react'; import { ChevronRight, RefreshCcw, ArrowLeft } from 'lucide-react';
import { YStack, XStack, Stack } from '@tamagui/stacks'; import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text'; import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite'; import { Pressable } from '@tamagui/react-native-web-lite';
import { MobileShell } from './components/MobileShell'; import { MobileShell } from './components/MobileShell';
@@ -14,11 +14,43 @@ import {
getEvent, getEvent,
getEventQrInvites, getEventQrInvites,
createQrInvite, createQrInvite,
updateEventQrInvite,
} from '../api'; } from '../api';
import { isAuthError } from '../auth/tokens'; import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage } from '../lib/apiError'; import { getApiErrorMessage } from '../lib/apiError';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { ADMIN_BASE_PATH } from '../constants';
export function resolveLayoutForFormat(format: 'a4-poster' | 'a5-foldable', layouts: EventQrInviteLayout[]): string | null {
const formatKey = format === 'a4-poster' ? 'poster-a4' : 'foldable-a5';
const byHint = layouts.find((layout) => (layout as any)?.format_hint === formatKey);
if (byHint?.id) {
return byHint.id;
}
const match = layouts.find((layout) => {
const paper = (layout.paper || '').toLowerCase();
const orientation = (layout.orientation || '').toLowerCase();
const panel = (layout.panel_mode || '').toLowerCase();
if (format === 'a4-poster') {
return paper === 'a4' && orientation === 'portrait' && panel !== 'double-mirror';
}
return paper === 'a4' && orientation === 'landscape' && panel === 'double-mirror';
});
if (match?.id) {
return match.id;
}
if (format === 'a5-foldable') {
const fallback = layouts.find((layout) => (layout.id || '').includes('foldable'));
return fallback?.id ?? layouts[0]?.id ?? null;
}
return layouts[0]?.id ?? null;
}
export default function MobileQrPrintPage() { export default function MobileQrPrintPage() {
const { slug: slugParam } = useParams<{ slug?: string }>(); const { slug: slugParam } = useParams<{ slug?: string }>();
@@ -29,24 +61,10 @@ export default function MobileQrPrintPage() {
const [event, setEvent] = React.useState<TenantEvent | null>(null); const [event, setEvent] = React.useState<TenantEvent | null>(null);
const [selectedInvite, setSelectedInvite] = React.useState<EventQrInvite | null>(null); const [selectedInvite, setSelectedInvite] = React.useState<EventQrInvite | null>(null);
const [selectedLayoutId, setSelectedLayoutId] = React.useState<string | null>(null); const [selectedLayoutId, setSelectedLayoutId] = React.useState<string | null>(null);
const [selectedFormat, setSelectedFormat] = React.useState<'a4-poster' | 'a5-foldable' | null>(null);
const [error, setError] = React.useState<string | null>(null); const [error, setError] = React.useState<string | null>(null);
const [loading, setLoading] = React.useState(true); const [loading, setLoading] = React.useState(true);
const [qrUrl, setQrUrl] = React.useState<string>(''); const [qrUrl, setQrUrl] = React.useState<string>('');
const [wizardStep, setWizardStep] = React.useState<'select-layout' | 'background' | 'text' | 'preview'>('select-layout');
const [selectedBackgroundPreset, setSelectedBackgroundPreset] = React.useState<string | null>(null);
const [textFields, setTextFields] = React.useState({
headline: '',
subtitle: '',
description: '',
instructions: [''],
});
const [saving, setSaving] = React.useState(false);
const BACKGROUND_PRESETS = [
{ id: 'bg-blue-floral', src: '/storage/layouts/backgrounds-portrait/bg-blue-floral.png', label: 'Blue Floral' },
{ id: 'bg-goldframe', src: '/storage/layouts/backgrounds-portrait/bg-goldframe.png', label: 'Gold Frame' },
{ id: 'gr-green-floral', src: '/storage/layouts/backgrounds-portrait/gr-green-floral.png', label: 'Green Floral' },
];
React.useEffect(() => { React.useEffect(() => {
if (!slug) return; if (!slug) return;
@@ -58,18 +76,16 @@ export default function MobileQrPrintPage() {
setEvent(data); setEvent(data);
const primaryInvite = invites.find((item) => item.is_active) ?? invites[0] ?? null; const primaryInvite = invites.find((item) => item.is_active) ?? invites[0] ?? null;
setSelectedInvite(primaryInvite); setSelectedInvite(primaryInvite);
setSelectedLayoutId(primaryInvite?.layouts?.[0]?.id ?? null); const initialLayout = primaryInvite?.layouts?.[0];
const backgroundPreset = (primaryInvite?.metadata as any)?.layout_customization?.background_preset ?? null; const initialFormat =
setSelectedBackgroundPreset(typeof backgroundPreset === 'string' ? backgroundPreset : null); initialLayout && ((initialLayout.panel_mode ?? '').toLowerCase() === 'double-mirror' || (initialLayout.orientation ?? '').toLowerCase() === 'landscape')
const customization = (primaryInvite?.metadata as any)?.layout_customization ?? {}; ? 'a5-foldable'
setTextFields({ : 'a4-poster';
headline: customization.headline ?? '', setSelectedFormat(initialFormat);
subtitle: customization.subtitle ?? '', const resolvedLayoutId = primaryInvite?.layouts
description: customization.description ?? '', ? resolveLayoutForFormat(initialFormat, primaryInvite.layouts) ?? initialLayout?.id ?? null
instructions: Array.isArray(customization.instructions) && customization.instructions.length : initialLayout?.id ?? null;
? customization.instructions.map((item: unknown) => String(item ?? '')).filter((item: string) => item.length > 0) setSelectedLayoutId(resolvedLayoutId);
: [''],
});
setQrUrl(primaryInvite?.url ?? data.public_url ?? ''); setQrUrl(primaryInvite?.url ?? data.public_url ?? '');
setError(null); setError(null);
} catch (err) { } catch (err) {
@@ -134,6 +150,7 @@ export default function MobileQrPrintPage() {
<XStack space="$2" width="100%" marginTop="$2"> <XStack space="$2" width="100%" marginTop="$2">
<CTAButton <CTAButton
label={t('events.qr.download', 'Download')} label={t('events.qr.download', 'Download')}
fullWidth={false}
onPress={() => { onPress={() => {
if (qrUrl) { if (qrUrl) {
toast.success(t('events.qr.downloadStarted', 'Download gestartet')); toast.success(t('events.qr.downloadStarted', 'Download gestartet'));
@@ -144,6 +161,7 @@ export default function MobileQrPrintPage() {
/> />
<CTAButton <CTAButton
label={t('events.qr.share', 'Share')} label={t('events.qr.share', 'Share')}
fullWidth={false}
onPress={async () => { onPress={async () => {
try { try {
const shareUrl = String(qrUrl || (event as any)?.public_url || ''); const shareUrl = String(qrUrl || (event as any)?.public_url || '');
@@ -158,165 +176,53 @@ export default function MobileQrPrintPage() {
</MobileCard> </MobileCard>
<MobileCard space="$2"> <MobileCard space="$2">
<Text fontSize="$xs" color="#6b7280">
{t('events.qr.step1', 'Schritt 1: Format wählen')}
</Text>
<Text fontSize="$md" fontWeight="800" color="#111827"> <Text fontSize="$md" fontWeight="800" color="#111827">
{t('events.qr.layouts', 'Print Layouts')} {t('events.qr.layouts', 'Print Layouts')}
</Text> </Text>
{(() => { <FormatSelection
if (wizardStep === 'select-layout') { layouts={selectedInvite?.layouts ?? []}
return ( selectedFormat={selectedFormat}
<LayoutSelection onSelect={(format, layoutId) => {
layouts={selectedInvite?.layouts ?? []} setSelectedFormat(format);
selectedLayoutId={selectedLayoutId} setSelectedLayoutId(layoutId);
onSelect={(layoutId) => { }}
setSelectedLayoutId(layoutId); />
setWizardStep('background'); <CTAButton
}} label={t('events.qr.preview', 'Anpassen & Exportieren')}
/> onPress={() => {
); if (!slug || !selectedInvite) {
} toast.error(t('events.qr.missing', 'Kein QR-Link vorhanden'));
return;
if (wizardStep === 'background') { }
return ( const layouts = selectedInvite.layouts ?? [];
<BackgroundStep const targetLayout =
onBack={() => setWizardStep('select-layout')} selectedLayoutId ??
presets={BACKGROUND_PRESETS} (selectedFormat ? resolveLayoutForFormat(selectedFormat, layouts) : layouts[0]?.id ?? '');
selectedPreset={selectedBackgroundPreset} if (!targetLayout) {
onSelectPreset={setSelectedBackgroundPreset} toast.error(t('events.qr.missing', 'Kein Layout verfügbar'));
selectedLayout={selectedInvite?.layouts.find((l) => l.id === selectedLayoutId) ?? null} return;
onSave={async () => { }
if (!slug || !selectedInvite || !selectedLayoutId) { navigate(`${ADMIN_BASE_PATH}/mobile/events/${slug}/qr/customize/${selectedInvite.id}?layout=${encodeURIComponent(targetLayout)}`);
toast.error(t('events.qr.missing', 'Kein QR-Link vorhanden')); }}
return; />
}
setSaving(true);
try {
const payload = {
metadata: {
layout_customization: {
layout_id: selectedLayoutId,
background_preset: selectedBackgroundPreset,
headline: textFields.headline || undefined,
subtitle: textFields.subtitle || undefined,
description: textFields.description || undefined,
instructions: textFields.instructions.filter((item) => item.trim().length > 0),
},
},
};
const updated = await updateEventQrInvite(slug, selectedInvite.id, payload);
setSelectedInvite(updated);
toast.success(t('common.saved', 'Gespeichert'));
setWizardStep('text');
} catch (err) {
toast.error(getApiErrorMessage(err, t('events.errors.saveFailed', 'Speichern fehlgeschlagen.')));
} finally {
setSaving(false);
}
}}
saving={saving}
/>
);
}
if (wizardStep === 'text') {
return (
<TextStep
onBack={() => setWizardStep('background')}
textFields={textFields}
onChange={(fields) => setTextFields(fields)}
onSave={async () => {
if (!slug || !selectedInvite || !selectedLayoutId) {
toast.error(t('events.qr.missing', 'Kein QR-Link vorhanden'));
return;
}
setSaving(true);
try {
const payload = {
metadata: {
layout_customization: {
layout_id: selectedLayoutId,
background_preset: selectedBackgroundPreset,
headline: textFields.headline || null,
subtitle: textFields.subtitle || null,
description: textFields.description || null,
instructions: textFields.instructions.filter((item) => item.trim().length > 0),
},
},
};
const updated = await updateEventQrInvite(slug, selectedInvite.id, payload);
setSelectedInvite(updated);
toast.success(t('common.saved', 'Gespeichert'));
setWizardStep('preview');
} catch (err) {
toast.error(getApiErrorMessage(err, t('events.errors.saveFailed', 'Speichern fehlgeschlagen.')));
} finally {
setSaving(false);
}
}}
saving={saving}
/>
);
}
return (
<PreviewStep
onBack={() => setWizardStep('text')}
layout={selectedInvite?.layouts.find((l) => l.id === selectedLayoutId) ?? null}
backgroundPreset={selectedBackgroundPreset}
presets={BACKGROUND_PRESETS}
textFields={textFields}
qrUrl={qrUrl}
onExport={(format) => {
const layout = selectedInvite?.layouts.find((l) => l.id === selectedLayoutId);
const url = layout?.download_urls?.[format];
if (!url) {
toast.error(t('events.qr.missing', 'Kein QR-Link vorhanden'));
return;
}
window.open(url, '_blank', 'noopener');
}}
/>
);
})()}
</MobileCard> </MobileCard>
<MobileCard space="$2"> <MobileCard space="$2">
<Text fontSize="$sm" fontWeight="700" color="#111827"> <Text fontSize="$sm" fontWeight="700" color="#111827">
{t('events.qr.templates', 'Templates')} {t('events.qr.createLink', 'Neuen QR-Link erstellen')}
</Text> </Text>
<XStack justifyContent="space-between" alignItems="center" paddingVertical="$2" borderBottomWidth={1} borderColor="#e5e7eb">
<Text fontSize="$sm" color="#111827">
{t('events.qr.branding', 'Branding')}
</Text>
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input type="checkbox" defaultChecked />
<Text fontSize="$sm" color="#111827">
{t('common.enabled', 'Enabled')}
</Text>
</label>
</XStack>
<XStack justifyContent="space-between" alignItems="center" paddingVertical="$2">
<Text fontSize="$sm" color="#111827">
{t('events.qr.paper', 'Paper Size')}
</Text>
<XStack alignItems="center" space="$2">
<Text fontSize="$sm" color="#111827">
{t('events.qr.paperAuto', 'Auto (per layout)')}
</Text>
<ChevronRight size={16} color="#9ca3af" />
</XStack>
</XStack>
<CTAButton
label={t('events.qr.preview', 'Preview & Print')}
onPress={() => toast.success(t('events.qr.previewStarted', 'Preview gestartet (mock)'))}
/>
<CTAButton <CTAButton
label={t('events.qr.createLink', 'Neuen QR-Link erstellen')} label={t('events.qr.createLink', 'Neuen QR-Link erstellen')}
onPress={async () => { onPress={async () => {
if (!slug) return; if (!slug) return;
try { try {
if (!slug) return; const invite = await createQrInvite(slug, { label: 'Mobile Link' });
const invite = await createQrInvite(slug, { label: 'Mobile Link' });
setQrUrl(invite.url); setQrUrl(invite.url);
setSelectedInvite(invite);
setSelectedLayoutId(invite.layouts?.[0]?.id ?? selectedLayoutId);
toast.success(t('events.qr.created', 'Neuer QR-Link erstellt')); toast.success(t('events.qr.created', 'Neuer QR-Link erstellt'));
} catch (err) { } catch (err) {
toast.error(getApiErrorMessage(err, t('events.qr.createFailed', 'Link konnte nicht erstellt werden.'))); toast.error(getApiErrorMessage(err, t('events.qr.createFailed', 'Link konnte nicht erstellt werden.')));
@@ -328,31 +234,46 @@ export default function MobileQrPrintPage() {
); );
} }
function LayoutSelection({ function FormatSelection({
layouts, layouts,
selectedLayoutId, selectedFormat,
onSelect, onSelect,
}: { }: {
layouts: EventQrInviteLayout[]; layouts: EventQrInviteLayout[];
selectedLayoutId: string | null; selectedFormat: 'a4-poster' | 'a5-foldable' | null;
onSelect: (layoutId: string) => void; onSelect: (format: 'a4-poster' | 'a5-foldable', layoutId: string | null) => void;
}) { }) {
const { t } = useTranslation('management'); const { t } = useTranslation('management');
if (!layouts.length) { const selectLayoutId = (format: 'a4-poster' | 'a5-foldable'): string | null => {
return ( return resolveLayoutForFormat(format, layouts);
<Text fontSize="$sm" color="#6b7280"> };
{t('events.qr.noLayouts', 'Keine Layouts verfügbar.')}
</Text> const cards: { key: 'a4-poster' | 'a5-foldable'; title: string; subtitle: string; badges: string[] }[] = [
); {
} key: 'a4-poster',
title: t('events.qr.format.poster', 'A4 Poster'),
subtitle: t('events.qr.format.posterSubtitle', 'Hochformat für Aushänge'),
badges: ['A4', 'Portrait'],
},
{
key: 'a5-foldable',
title: t('events.qr.format.table', 'A5 Tischkarte (faltbar)'),
subtitle: t('events.qr.format.tableSubtitle', 'Quer, doppelt & gespiegelt'),
badges: ['A4', 'Landscape', 'Double-Mirror'],
},
];
return ( return (
<YStack space="$2" marginTop="$2"> <YStack space="$2" marginTop="$2">
{layouts.map((layout) => { {cards.map((card) => {
const isSelected = layout.id === selectedLayoutId; const isSelected = selectedFormat === card.key;
return ( return (
<Pressable key={layout.id} onPress={() => onSelect(layout.id)} style={{ width: '100%' }}> <Pressable
key={card.key}
onPress={() => onSelect(card.key, selectLayoutId(card.key))}
style={{ width: '100%' }}
>
<MobileCard <MobileCard
padding="$3" padding="$3"
borderColor={isSelected ? '#2563EB' : '#e5e7eb'} borderColor={isSelected ? '#2563EB' : '#e5e7eb'}
@@ -362,17 +283,17 @@ function LayoutSelection({
<XStack alignItems="center" justifyContent="space-between" space="$3"> <XStack alignItems="center" justifyContent="space-between" space="$3">
<YStack space="$1" flex={1}> <YStack space="$1" flex={1}>
<Text fontSize="$sm" fontWeight="700" color="#111827"> <Text fontSize="$sm" fontWeight="700" color="#111827">
{layout.name || layout.id} {card.title}
</Text>
<Text fontSize="$xs" color="#6b7280">
{card.subtitle}
</Text> </Text>
{layout.description ? (
<Text fontSize="$xs" color="#6b7280">
{layout.description}
</Text>
) : null}
<XStack space="$2" alignItems="center" flexWrap="wrap"> <XStack space="$2" alignItems="center" flexWrap="wrap">
<PillBadge tone="muted">{(layout.paper || 'A4').toUpperCase()}</PillBadge> {card.badges.map((badge) => (
<PillBadge tone="muted">{(layout.orientation || 'portrait').toUpperCase()}</PillBadge> <PillBadge tone="muted" key={badge}>
{layout.panel_mode ? <PillBadge tone="muted">{layout.panel_mode}</PillBadge> : null} {badge}
</PillBadge>
))}
</XStack> </XStack>
</YStack> </YStack>
<ChevronRight size={16} color="#9ca3af" /> <ChevronRight size={16} color="#9ca3af" />
@@ -391,6 +312,7 @@ function BackgroundStep({
selectedPreset, selectedPreset,
onSelectPreset, onSelectPreset,
selectedLayout, selectedLayout,
layouts,
onSave, onSave,
saving, saving,
}: { }: {
@@ -399,10 +321,25 @@ function BackgroundStep({
selectedPreset: string | null; selectedPreset: string | null;
onSelectPreset: (id: string) => void; onSelectPreset: (id: string) => void;
selectedLayout: EventQrInviteLayout | null; selectedLayout: EventQrInviteLayout | null;
layouts: EventQrInviteLayout[];
onSave: () => void; onSave: () => void;
saving: boolean; saving: boolean;
}) { }) {
const { t } = useTranslation('management'); const { t } = useTranslation('management');
const resolvedLayout =
selectedLayout ??
layouts.find((layout) => {
const panel = (layout.panel_mode ?? '').toLowerCase();
const orientation = (layout.orientation ?? '').toLowerCase();
return panel === 'double-mirror' || orientation === 'landscape';
}) ??
layouts[0] ??
null;
const isFoldable = (resolvedLayout?.panel_mode ?? '').toLowerCase() === 'double-mirror';
const isPortrait = (resolvedLayout?.orientation ?? 'portrait').toLowerCase() === 'portrait';
const disablePresets = isFoldable;
const formatLabel = isFoldable ? 'A4 Landscape (Double/Mirror)' : 'A4 Portrait';
return ( return (
<YStack space="$3" marginTop="$2"> <YStack space="$3" marginTop="$2">
@@ -422,41 +359,53 @@ function BackgroundStep({
<YStack space="$2"> <YStack space="$2">
<Text fontSize="$sm" fontWeight="700" color="#111827"> <Text fontSize="$sm" fontWeight="700" color="#111827">
{t('events.qr.backgroundPicker', 'Hintergrund auswählen (A4 Portrait Presets)')} {t(
</Text> 'events.qr.backgroundPicker',
<XStack flexWrap="wrap" gap="$2"> disablePresets ? 'Hintergrund für A5 (Gradient/Farbe)' : `Hintergrund auswählen (${formatLabel})`
{presets.map((preset) => { )}
const isSelected = selectedPreset === preset.id;
return (
<Pressable key={preset.id} onPress={() => onSelectPreset(preset.id)} style={{ width: '48%' }}>
<Stack
height={120}
borderRadius={14}
overflow="hidden"
borderWidth={isSelected ? 2 : 1}
borderColor={isSelected ? '#2563EB' : '#e5e7eb'}
backgroundColor="#f8fafc"
>
<Stack
flex={1}
backgroundImage={`url(${preset.src})`}
backgroundSize="cover"
backgroundPosition="center"
/>
<XStack padding="$2" justifyContent="space-between" alignItems="center" backgroundColor="rgba(255,255,255,0.92)">
<Text fontSize="$xs" color="#111827">
{preset.label}
</Text>
{isSelected ? <PillBadge tone="muted">{t('common.selected', 'Ausgewählt')}</PillBadge> : null}
</XStack>
</Stack>
</Pressable>
);
})}
</XStack>
<Text fontSize="$xs" color="#6b7280">
{t('events.qr.backgroundNote', 'Diese Presets sind für A4 Hochformat. Spiegelung erfolgt automatisch bei Tischkarten.')}
</Text> </Text>
{!disablePresets ? (
<>
<XStack flexWrap="wrap" gap="$2">
{presets.map((preset) => {
const isSelected = selectedPreset === preset.id;
return (
<Pressable key={preset.id} onPress={() => onSelectPreset(preset.id)} style={{ width: '48%' }}>
<YStack
aspectRatio={210 / 297}
maxHeight={220}
borderRadius={14}
overflow="hidden"
borderWidth={isSelected ? 2 : 1}
borderColor={isSelected ? '#2563EB' : '#e5e7eb'}
backgroundColor="#f8fafc"
>
<YStack
flex={1}
backgroundImage={`url(${preset.src})`}
backgroundSize="cover"
backgroundPosition="center"
/>
<XStack padding="$2" justifyContent="space-between" alignItems="center" backgroundColor="rgba(255,255,255,0.92)">
<Text fontSize="$xs" color="#111827">
{preset.label}
</Text>
{isSelected ? <PillBadge tone="muted">{t('common.selected', 'Ausgewählt')}</PillBadge> : null}
</XStack>
</YStack>
</Pressable>
);
})}
</XStack>
<Text fontSize="$xs" color="#6b7280">
{t('events.qr.backgroundNote', 'Diese Presets sind für A4 Hochformat. Spiegelung erfolgt automatisch bei Tischkarten.')}
</Text>
</>
) : (
<Text fontSize="$xs" color="#6b7280">
{t('events.qr.foldableBackgroundNote', 'Für A5 Tischkarten bitte Gradient oder Vollfarbe wählen.')}
</Text>
)}
</YStack> </YStack>
<CTAButton <CTAButton
@@ -611,7 +560,7 @@ function PreviewStep({
<Text fontSize="$sm" fontWeight="700" color="#111827"> <Text fontSize="$sm" fontWeight="700" color="#111827">
{t('events.qr.preview', 'Vorschau')} {t('events.qr.preview', 'Vorschau')}
</Text> </Text>
<Stack <YStack
borderRadius={16} borderRadius={16}
borderWidth={1} borderWidth={1}
borderColor="#e5e7eb" borderColor="#e5e7eb"
@@ -637,11 +586,11 @@ function PreviewStep({
{textFields.subtitle} {textFields.subtitle}
</Text> </Text>
) : null} ) : null}
{textFields.description ? ( {textFields.description ? (
<Text fontSize="$sm" color={layout?.preview?.text ?? '#1f2937'}> <Text fontSize="$sm" color={layout?.preview?.text ?? '#1f2937'}>
{textFields.description} {textFields.description}
</Text> </Text>
) : null} ) : null}
<YStack space="$1"> <YStack space="$1">
{textFields.instructions.filter((i) => i.trim().length > 0).map((item, idx) => ( {textFields.instructions.filter((i) => i.trim().length > 0).map((item, idx) => (
<Text key={idx} fontSize="$xs" color={layout?.preview?.text ?? '#1f2937'}> <Text key={idx} fontSize="$xs" color={layout?.preview?.text ?? '#1f2937'}>
@@ -662,7 +611,7 @@ function PreviewStep({
</Text> </Text>
)} )}
</YStack> </YStack>
</Stack> </YStack>
</YStack> </YStack>
<XStack space="$2"> <XStack space="$2">

View File

@@ -0,0 +1,55 @@
import { describe, it, expect } from 'vitest';
import type { EventQrInviteLayout } from '../../api';
import { buildInitialTextFields } from '../QrLayoutCustomizePage';
import { resolveLayoutForFormat } from '../QrPrintPage';
describe('buildInitialTextFields', () => {
it('prefers event name for headline and default copy when customization is missing', () => {
const layoutDefaults = {
id: 'evergreen-vows',
name: 'Evergreen Vows',
description: 'Old description',
} as EventQrInviteLayout;
const fields = buildInitialTextFields({
customization: null,
layoutDefaults,
eventName: 'Sommerfest 2025',
});
expect(fields.headline).toBe('Sommerfest 2025');
expect(fields.description).toBe('Helft uns, diesen besonderen Tag mit euren schönen Momenten festzuhalten.');
expect(fields.instructions).toHaveLength(3);
});
it('uses customization when provided', () => {
const fields = buildInitialTextFields({
customization: {
headline: 'Custom',
description: 'Desc',
instructions: ['One', 'Two'],
},
layoutDefaults: null,
eventName: 'Ignored',
});
expect(fields.headline).toBe('Custom');
expect(fields.description).toBe('Desc');
expect(fields.instructions).toEqual(['One', 'Two']);
});
});
describe('resolveLayoutForFormat', () => {
const layouts: EventQrInviteLayout[] = [
{ id: 'portrait-a4', paper: 'a4', orientation: 'portrait', panel_mode: 'single', format_hint: 'poster-a4' } as EventQrInviteLayout,
{ id: 'foldable', paper: 'a4', orientation: 'landscape', panel_mode: 'double-mirror', format_hint: 'foldable-a5' } as EventQrInviteLayout,
];
it('returns portrait layout for A4 poster', () => {
expect(resolveLayoutForFormat('a4-poster', layouts)).toBe('portrait-a4');
});
it('returns foldable layout for A5 foldable', () => {
expect(resolveLayoutForFormat('a5-foldable', layouts)).toBe('foldable');
});
});

View File

@@ -72,15 +72,17 @@ export function CTAButton({
label, label,
onPress, onPress,
tone = 'primary', tone = 'primary',
fullWidth = true,
}: { }: {
label: string; label: string;
onPress: () => void; onPress: () => void;
tone?: 'primary' | 'ghost'; tone?: 'primary' | 'ghost';
fullWidth?: boolean;
}) { }) {
const theme = useTheme(); const theme = useTheme();
const isPrimary = tone === 'primary'; const isPrimary = tone === 'primary';
return ( return (
<Pressable onPress={onPress} style={{ width: '100%' }}> <Pressable onPress={onPress} style={{ width: fullWidth ? '100%' : undefined, flex: fullWidth ? undefined : 1 }}>
<XStack <XStack
height={56} height={56}
borderRadius={14} borderRadius={14}

View File

@@ -28,6 +28,7 @@ const MobileEventDetailPage = React.lazy(() => import('./mobile/EventDetailPage'
const MobileBrandingPage = React.lazy(() => import('./mobile/BrandingPage')); const MobileBrandingPage = React.lazy(() => import('./mobile/BrandingPage'));
const MobileEventFormPage = React.lazy(() => import('./mobile/EventFormPage')); const MobileEventFormPage = React.lazy(() => import('./mobile/EventFormPage'));
const MobileQrPrintPage = React.lazy(() => import('./mobile/QrPrintPage')); const MobileQrPrintPage = React.lazy(() => import('./mobile/QrPrintPage'));
const MobileQrLayoutCustomizePage = React.lazy(() => import('./mobile/QrLayoutCustomizePage'));
const MobileEventPhotosPage = React.lazy(() => import('./mobile/EventPhotosPage')); const MobileEventPhotosPage = React.lazy(() => import('./mobile/EventPhotosPage'));
const MobileEventMembersPage = React.lazy(() => import('./mobile/EventMembersPage')); const MobileEventMembersPage = React.lazy(() => import('./mobile/EventMembersPage'));
const MobileEventTasksPage = React.lazy(() => import('./mobile/EventTasksPage')); const MobileEventTasksPage = React.lazy(() => import('./mobile/EventTasksPage'));
@@ -135,6 +136,7 @@ export const router = createBrowserRouter([
{ path: 'mobile/events/new', element: <RequireAdminAccess><MobileEventFormPage /></RequireAdminAccess> }, { path: 'mobile/events/new', element: <RequireAdminAccess><MobileEventFormPage /></RequireAdminAccess> },
{ path: 'mobile/events/:slug/edit', element: <RequireAdminAccess><MobileEventFormPage /></RequireAdminAccess> }, { path: 'mobile/events/:slug/edit', element: <RequireAdminAccess><MobileEventFormPage /></RequireAdminAccess> },
{ path: 'mobile/events/:slug/qr', element: <RequireAdminAccess><MobileQrPrintPage /></RequireAdminAccess> }, { path: 'mobile/events/:slug/qr', element: <RequireAdminAccess><MobileQrPrintPage /></RequireAdminAccess> },
{ path: 'mobile/events/:slug/qr/customize/:tokenId?', element: <RequireAdminAccess><MobileQrLayoutCustomizePage /></RequireAdminAccess> },
{ path: 'mobile/events/:slug/photos', element: <MobileEventPhotosPage /> }, { path: 'mobile/events/:slug/photos', element: <MobileEventPhotosPage /> },
{ path: 'mobile/events/:slug/members', element: <RequireAdminAccess><MobileEventMembersPage /></RequireAdminAccess> }, { path: 'mobile/events/:slug/members', element: <RequireAdminAccess><MobileEventMembersPage /></RequireAdminAccess> },
{ path: 'mobile/events/:slug/tasks', element: <MobileEventTasksPage /> }, { path: 'mobile/events/:slug/tasks', element: <MobileEventTasksPage /> },

74
snapshot.json Normal file
View File

@@ -0,0 +1,74 @@
uid=16_0 RootWebArea "TenantAdmin" url="http://fotospiel-app.test/event-admin/mobile/events/demo-hochzeit-2025/qr/customize/1?layout=confetti-bash"
uid=16_1 generic
uid=16_2 generic
uid=16_3 StaticText "Layout anpassen"
uid=16_4 StaticText "Wähle ein Event, um fortzufahren"
uid=16_5 generic
uid=16_6 generic
uid=16_7 generic
uid=16_8 StaticText "Hintergrund"
uid=16_9 StaticText "Schritt"
uid=16_10 StaticText " "
uid=16_11 StaticText "1"
uid=16_12 generic
uid=16_13 StaticText "Text"
uid=16_14 StaticText "Schritt"
uid=16_15 StaticText " "
uid=16_16 StaticText "2"
uid=16_17 generic
uid=16_18 StaticText "Vorschau"
uid=16_19 StaticText "Schritt"
uid=16_20 StaticText " "
uid=16_21 StaticText "3"
uid=16_22 generic
uid=16_23 StaticText "Zurück"
uid=16_24 StaticText "A4 Portrait"
uid=16_25 StaticText "Hintergrund auswählen (A4 Portrait)"
uid=16_26 generic
uid=16_27 StaticText "Blue Floral"
uid=16_28 generic
uid=16_29 StaticText "Gold Frame"
uid=16_30 StaticText "Ausgewählt"
uid=16_31 generic
uid=16_32 StaticText "Green Floral"
uid=16_33 StaticText "Diese Presets sind für A4 Hochformat. Spiegelung erfolgt automatisch bei Tischkarten."
uid=16_34 StaticText "Gradienten"
uid=16_35 generic
uid=16_36 generic
uid=16_37 generic
uid=16_38 StaticText "Vollfarbe"
uid=16_39 generic
uid=16_40 generic
uid=16_41 generic
uid=16_42 generic
uid=16_43 generic
uid=16_44 generic
uid=16_45 StaticText "Weiter"
uid=16_46 StaticText "Demo tenants"
uid=16_47 StaticText "DEV MODE"
uid=16_48 button "Switcher minimieren"
uid=16_49 StaticText "Select a seeded tenant to mint Sanctum PATs and jump straight into their admin space. Available only in development builds."
uid=16_50 button "Endkunde Standard (kein Event)"
uid=16_51 button "Endkunde Starter (Hochzeit)"
uid=16_52 button "Reseller S 3 aktive Events"
uid=16_53 button "Reseller S voll belegt (5/5)"
uid=16_54 StaticText "Console: "
uid=16_55 StaticText "fotospielDemoAuth.loginAs('lumen')"
uid=16_56 generic
uid=16_57 StaticText "Start"
uid=16_58 generic
uid=16_59 StaticText "Aufgaben"
uid=16_60 generic
uid=16_61 StaticText "Uploads"
uid=16_62 generic
uid=16_63 StaticText "Profil"
uid=16_64 StaticText "Demo tenants"
uid=16_65 StaticText "DEV MODE"
uid=16_66 button "Switcher minimieren"
uid=16_67 StaticText "Select a seeded tenant to mint Sanctum PATs and jump straight into their admin space. Available only in development builds."
uid=16_68 button "Endkunde Standard (kein Event)"
uid=16_69 button "Endkunde Starter (Hochzeit)"
uid=16_70 button "Reseller S 3 aktive Events"
uid=16_71 button "Reseller S voll belegt (5/5)"
uid=16_72 StaticText "Console: "
uid=16_73 StaticText "fotospielDemoAuth.loginAs('lumen')"