fixed event join token handling in the event admin. created new seeders with new tenants and package purchases. added new playwright test scenarios.
This commit is contained in:
@@ -75,6 +75,7 @@ GOOGLE_CLIENT_SECRET=
|
||||
GOOGLE_REDIRECT_URI=${APP_URL}/checkout/auth/google/callback
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
VITE_ENABLE_TENANT_SWITCHER=false
|
||||
REVENUECAT_WEBHOOK_SECRET=
|
||||
REVENUECAT_PRODUCT_MAPPINGS=
|
||||
REVENUECAT_APP_USER_PREFIX=tenant
|
||||
|
||||
568
AGENTS.md
568
AGENTS.md
@@ -82,3 +82,571 @@ This repository hosts a multi-tenant event photo platform (Laravel 12, PHP 8.3,
|
||||
- docs/prp/06-tenant-admin-pwa.md: Detailed PWA specifications.
|
||||
- docs/prp/07-guest-pwa.md: Guest PWA requirements and features.
|
||||
- docs/prp/08-billing.md: Payment system architecture.
|
||||
|
||||
===
|
||||
|
||||
<laravel-boost-guidelines>
|
||||
=== foundation rules ===
|
||||
|
||||
# Laravel Boost Guidelines
|
||||
|
||||
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications.
|
||||
|
||||
## 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.
|
||||
|
||||
- php - 8.3.6
|
||||
- filament/filament (FILAMENT) - v4
|
||||
- inertiajs/inertia-laravel (INERTIA) - v2
|
||||
- laravel/framework (LARAVEL) - v12
|
||||
- laravel/horizon (HORIZON) - v5
|
||||
- laravel/prompts (PROMPTS) - v0
|
||||
- laravel/sanctum (SANCTUM) - v4
|
||||
- laravel/socialite (SOCIALITE) - v5
|
||||
- laravel/wayfinder (WAYFINDER) - v0
|
||||
- livewire/livewire (LIVEWIRE) - v3
|
||||
- laravel/mcp (MCP) - v0
|
||||
- laravel/pint (PINT) - v1
|
||||
- laravel/sail (SAIL) - v1
|
||||
- phpunit/phpunit (PHPUNIT) - v11
|
||||
- @inertiajs/react (INERTIA) - v2
|
||||
- react (REACT) - v19
|
||||
- tailwindcss (TAILWINDCSS) - v4
|
||||
- @laravel/vite-plugin-wayfinder (WAYFINDER) - v0
|
||||
- eslint (ESLINT) - v9
|
||||
- prettier (PRETTIER) - v3
|
||||
|
||||
|
||||
## Conventions
|
||||
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming.
|
||||
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
|
||||
- Check for existing components to reuse before writing a new one.
|
||||
|
||||
## Verification Scripts
|
||||
- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important.
|
||||
|
||||
## Application Structure & Architecture
|
||||
- Stick to existing directory structure - don't create new base folders without approval.
|
||||
- Do not change the application's dependencies without approval.
|
||||
|
||||
## Frontend Bundling
|
||||
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
|
||||
|
||||
## Replies
|
||||
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
|
||||
|
||||
## Documentation Files
|
||||
- You must only create documentation files if explicitly requested by the user.
|
||||
|
||||
|
||||
=== boost rules ===
|
||||
|
||||
## Laravel Boost
|
||||
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
|
||||
|
||||
## Artisan
|
||||
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters.
|
||||
|
||||
## URLs
|
||||
- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port.
|
||||
|
||||
## Tinker / Debugging
|
||||
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
|
||||
- Use the `database-query` tool when you only need to read from the database.
|
||||
|
||||
## Reading Browser Logs With the `browser-logs` Tool
|
||||
- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
|
||||
- Only recent browser logs will be useful - ignore old logs.
|
||||
|
||||
## Searching Documentation (Critically Important)
|
||||
- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
|
||||
- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
|
||||
- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches.
|
||||
- Search the documentation before making code changes to ensure we are taking the correct approach.
|
||||
- Use multiple, broad, simple, topic based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
|
||||
- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
|
||||
|
||||
### Available Search Syntax
|
||||
- You can and should pass multiple queries at once. The most relevant results will be returned first.
|
||||
|
||||
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'
|
||||
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit"
|
||||
3. Quoted Phrases (Exact Position) - query="infinite scroll" - Words must be adjacent and in that order
|
||||
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit"
|
||||
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms
|
||||
|
||||
|
||||
=== php rules ===
|
||||
|
||||
## PHP
|
||||
|
||||
- Always use curly braces for control structures, even if it has one line.
|
||||
|
||||
### Constructors
|
||||
- Use PHP 8 constructor property promotion in `__construct()`.
|
||||
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
|
||||
- Do not allow empty `__construct()` methods with zero parameters.
|
||||
|
||||
### Type Declarations
|
||||
- Always use explicit return type declarations for methods and functions.
|
||||
- Use appropriate PHP type hints for method parameters.
|
||||
|
||||
<code-snippet name="Explicit Return Types and Method Params" lang="php">
|
||||
protected function isAccessible(User $user, ?string $path = null): bool
|
||||
{
|
||||
...
|
||||
}
|
||||
</code-snippet>
|
||||
|
||||
## Comments
|
||||
- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on.
|
||||
|
||||
## PHPDoc Blocks
|
||||
- Add useful array shape type definitions for arrays when appropriate.
|
||||
|
||||
## Enums
|
||||
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
|
||||
|
||||
|
||||
=== filament/core rules ===
|
||||
|
||||
## Filament
|
||||
- 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
|
||||
- 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.
|
||||
- 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
|
||||
- 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
|
||||
|
||||
<code-snippet name="Filament Table Test" lang="php">
|
||||
livewire(ListUsers::class)
|
||||
->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 Core
|
||||
|
||||
- Inertia.js components should be placed in the `resources/js/Pages` directory unless specified differently in the JS bundler (vite.config.js).
|
||||
- Use `Inertia::render()` for server-side routing instead of traditional Blade views.
|
||||
- Use `search-docs` for accurate guidance on all things Inertia.
|
||||
|
||||
<code-snippet lang="php" name="Inertia::render Example">
|
||||
// routes/web.php example
|
||||
Route::get('/users', function () {
|
||||
return Inertia::render('Users/Index', [
|
||||
'users' => User::all()
|
||||
]);
|
||||
});
|
||||
</code-snippet>
|
||||
|
||||
|
||||
=== inertia-laravel/v2 rules ===
|
||||
|
||||
## Inertia v2
|
||||
|
||||
- Make use of all Inertia features from v1 & v2. Check the documentation before making any changes to ensure we are taking the correct approach.
|
||||
|
||||
### Inertia v2 New Features
|
||||
- Polling
|
||||
- Prefetching
|
||||
- Deferred props
|
||||
- Infinite scrolling using merging props and `WhenVisible`
|
||||
- Lazy loading data on scroll
|
||||
|
||||
### Deferred Props & Empty States
|
||||
- When using deferred props on the frontend, you should add a nice empty state with pulsing / animated skeleton.
|
||||
|
||||
### Inertia Form General Guidance
|
||||
- The recommended way to build forms when using Inertia is with the `<Form>` component - a useful example is below. Use `search-docs` with a query of `form component` for guidance.
|
||||
- Forms can also be built using the `useForm` helper for more programmatic control, or to follow existing conventions. Use `search-docs` with a query of `useForm helper` for guidance.
|
||||
- `resetOnError`, `resetOnSuccess`, and `setDefaultsOnSuccess` are available on the `<Form>` component. Use `search-docs` with a query of 'form component resetting' for guidance.
|
||||
|
||||
|
||||
=== laravel/core rules ===
|
||||
|
||||
## 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.
|
||||
- If you're creating a generic PHP class, use `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.
|
||||
|
||||
### Database
|
||||
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
|
||||
- Use Eloquent models and relationships before suggesting raw database queries
|
||||
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
|
||||
- Generate code that prevents N+1 query problems by using eager loading.
|
||||
- Use Laravel's query builder for very complex database operations.
|
||||
|
||||
### Model Creation
|
||||
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`.
|
||||
|
||||
### APIs & Eloquent Resources
|
||||
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
|
||||
|
||||
### Controllers & Validation
|
||||
- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
|
||||
- Check sibling Form Requests to see if the application uses array or string based validation rules.
|
||||
|
||||
### Queues
|
||||
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
|
||||
|
||||
### Authentication & Authorization
|
||||
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
|
||||
|
||||
### URL Generation
|
||||
- When generating links to other pages, prefer named routes and the `route()` function.
|
||||
|
||||
### Configuration
|
||||
- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
|
||||
|
||||
### 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.
|
||||
- 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.
|
||||
|
||||
### 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`.
|
||||
|
||||
|
||||
=== laravel/v12 rules ===
|
||||
|
||||
## Laravel 12
|
||||
|
||||
- Use the `search-docs` tool to get version specific documentation.
|
||||
- This project upgraded from Laravel 10 without migrating to the new streamlined Laravel file structure.
|
||||
- This is **perfectly fine** and recommended by Laravel. Follow the existing structure from Laravel 10. We do not to need migrate to the new Laravel structure unless the user explicitly requests that.
|
||||
|
||||
### Laravel 10 Structure
|
||||
- Middleware typically lives in `app/Http/Middleware/` and service providers in `app/Providers/`.
|
||||
- There is no `bootstrap/app.php` application configuration in a Laravel 10 structure:
|
||||
- Middleware registration happens in `app/Http/Kernel.php`
|
||||
- Exception handling is in `app/Exceptions/Handler.php`
|
||||
- Console commands and schedule register in `app/Console/Kernel.php`
|
||||
- Rate limits likely exist in `RouteServiceProvider` or `app/Http/Kernel.php`
|
||||
|
||||
### Database
|
||||
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
|
||||
- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
|
||||
|
||||
### 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.
|
||||
|
||||
|
||||
=== livewire/core rules ===
|
||||
|
||||
## Livewire Core
|
||||
- Use the `search-docs` tool to find exact version specific documentation for how to write Livewire & Livewire tests.
|
||||
- Use the `php artisan make:livewire [Posts\\CreatePost]` artisan command to create new components
|
||||
- State should live on the server, with the UI reflecting it.
|
||||
- All Livewire requests hit the Laravel backend, they're like regular HTTP requests. Always validate form data, and run authorization checks in Livewire actions.
|
||||
|
||||
## Livewire Best Practices
|
||||
- Livewire components require a single root element.
|
||||
- Use `wire:loading` and `wire:dirty` for delightful loading states.
|
||||
- Add `wire:key` in loops:
|
||||
|
||||
```blade
|
||||
@foreach ($items as $item)
|
||||
<div wire:key="item-{{ $item->id }}">
|
||||
{{ $item->name }}
|
||||
</div>
|
||||
@endforeach
|
||||
```
|
||||
|
||||
- Prefer lifecycle hooks like `mount()`, `updatedFoo()` for initialization and reactive side effects:
|
||||
|
||||
<code-snippet name="Lifecycle hook examples" lang="php">
|
||||
public function mount(User $user) { $this->user = $user; }
|
||||
public function updatedSearch() { $this->resetPage(); }
|
||||
</code-snippet>
|
||||
|
||||
|
||||
## Testing Livewire
|
||||
|
||||
<code-snippet name="Example Livewire component test" lang="php">
|
||||
Livewire::test(Counter::class)
|
||||
->assertSet('count', 0)
|
||||
->call('increment')
|
||||
->assertSet('count', 1)
|
||||
->assertSee(1)
|
||||
->assertStatus(200);
|
||||
</code-snippet>
|
||||
|
||||
|
||||
<code-snippet name="Testing a Livewire component exists within a page" lang="php">
|
||||
$this->get('/posts/create')
|
||||
->assertSeeLivewire(CreatePost::class);
|
||||
</code-snippet>
|
||||
|
||||
|
||||
=== livewire/v3 rules ===
|
||||
|
||||
## Livewire 3
|
||||
|
||||
### Key Changes From Livewire 2
|
||||
- These things changed in Livewire 2, but may not have been updated in this application. Verify this application's setup to ensure you conform with application conventions.
|
||||
- Use `wire:model.live` for real-time updates, `wire:model` is now deferred by default.
|
||||
- Components now use the `App\Livewire` namespace (not `App\Http\Livewire`).
|
||||
- Use `$this->dispatch()` to dispatch events (not `emit` or `dispatchBrowserEvent`).
|
||||
- Use the `components.layouts.app` view as the typical layout path (not `layouts.app`).
|
||||
|
||||
### New Directives
|
||||
- `wire:show`, `wire:transition`, `wire:cloak`, `wire:offline`, `wire:target` are available for use. Use the documentation to find usage examples.
|
||||
|
||||
### Alpine
|
||||
- Alpine is now included with Livewire, don't manually include Alpine.js.
|
||||
- Plugins included with Alpine: persist, intersect, collapse, and focus.
|
||||
|
||||
### Lifecycle Hooks
|
||||
- You can listen for `livewire:init` to hook into Livewire initialization, and `fail.status === 419` for the page expiring:
|
||||
|
||||
<code-snippet name="livewire:load example" lang="js">
|
||||
document.addEventListener('livewire:init', function () {
|
||||
Livewire.hook('request', ({ fail }) => {
|
||||
if (fail && fail.status === 419) {
|
||||
alert('Your session expired');
|
||||
}
|
||||
});
|
||||
|
||||
Livewire.hook('message.failed', (message, component) => {
|
||||
console.error(message);
|
||||
});
|
||||
});
|
||||
</code-snippet>
|
||||
|
||||
|
||||
=== pint/core rules ===
|
||||
|
||||
## Laravel Pint Code Formatter
|
||||
|
||||
- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
|
||||
- Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues.
|
||||
|
||||
|
||||
=== phpunit/core rules ===
|
||||
|
||||
## 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.
|
||||
- If you see a test using "Pest", convert it to PHPUnit.
|
||||
- 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.
|
||||
- Tests should test all of the happy paths, failure paths, and weird paths.
|
||||
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files, these are core to the application.
|
||||
|
||||
### Running Tests
|
||||
- Run the minimal number of tests, using an appropriate filter, before finalizing.
|
||||
- To run all tests: `php artisan test`.
|
||||
- To run all tests in a file: `php artisan test tests/Feature/ExampleTest.php`.
|
||||
- To filter on a particular test name: `php artisan test --filter=testName` (recommended after making a change to a related file).
|
||||
|
||||
|
||||
=== inertia-react/core rules ===
|
||||
|
||||
## Inertia + React
|
||||
|
||||
- Use `router.visit()` or `<Link>` for navigation instead of traditional links.
|
||||
|
||||
<code-snippet name="Inertia Client Navigation" lang="react">
|
||||
|
||||
import { Link } from '@inertiajs/react'
|
||||
<Link href="/">Home</Link>
|
||||
|
||||
</code-snippet>
|
||||
|
||||
|
||||
=== inertia-react/v2/forms rules ===
|
||||
|
||||
## Inertia + React Forms
|
||||
|
||||
<code-snippet name="`<Form>` Component Example" lang="react">
|
||||
|
||||
import { Form } from '@inertiajs/react'
|
||||
|
||||
export default () => (
|
||||
<Form action="/users" method="post">
|
||||
{({
|
||||
errors,
|
||||
hasErrors,
|
||||
processing,
|
||||
wasSuccessful,
|
||||
recentlySuccessful,
|
||||
clearErrors,
|
||||
resetAndClearErrors,
|
||||
defaults
|
||||
}) => (
|
||||
<>
|
||||
<input type="text" name="name" />
|
||||
|
||||
{errors.name && <div>{errors.name}</div>}
|
||||
|
||||
<button type="submit" disabled={processing}>
|
||||
{processing ? 'Creating...' : 'Create User'}
|
||||
</button>
|
||||
|
||||
{wasSuccessful && <div>User created successfully!</div>}
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
)
|
||||
|
||||
</code-snippet>
|
||||
|
||||
|
||||
=== tailwindcss/core rules ===
|
||||
|
||||
## Tailwind Core
|
||||
|
||||
- Use Tailwind CSS classes to style HTML, check and use existing tailwind conventions within the project before writing your own.
|
||||
- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc..)
|
||||
- Think through class placement, order, priority, and defaults - remove redundant classes, add classes to parent or child carefully to limit repetition, group elements logically
|
||||
- You can use the `search-docs` tool to get exact examples from the official documentation when needed.
|
||||
|
||||
### Spacing
|
||||
- When listing items, use gap utilities for spacing, don't use margins.
|
||||
|
||||
<code-snippet name="Valid Flex Gap Spacing Example" lang="html">
|
||||
<div class="flex gap-8">
|
||||
<div>Superior</div>
|
||||
<div>Michigan</div>
|
||||
<div>Erie</div>
|
||||
</div>
|
||||
</code-snippet>
|
||||
|
||||
|
||||
### Dark Mode
|
||||
- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`.
|
||||
|
||||
|
||||
=== tailwindcss/v4 rules ===
|
||||
|
||||
## Tailwind 4
|
||||
|
||||
- Always use Tailwind CSS v4 - do not use the deprecated utilities.
|
||||
- `corePlugins` is not supported in Tailwind v4.
|
||||
- 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">
|
||||
- @tailwind base;
|
||||
- @tailwind components;
|
||||
- @tailwind utilities;
|
||||
+ @import "tailwindcss";
|
||||
</code-snippet>
|
||||
|
||||
|
||||
### Replaced Utilities
|
||||
- Tailwind v4 removed deprecated utilities. Do not use the deprecated option - use the replacement.
|
||||
- Opacity values are still numeric.
|
||||
|
||||
| Deprecated | Replacement |
|
||||
|------------+--------------|
|
||||
| bg-opacity-* | bg-black/* |
|
||||
| text-opacity-* | text-black/* |
|
||||
| border-opacity-* | border-black/* |
|
||||
| divide-opacity-* | divide-black/* |
|
||||
| ring-opacity-* | ring-black/* |
|
||||
| placeholder-opacity-* | placeholder-black/* |
|
||||
| flex-shrink-* | shrink-* |
|
||||
| flex-grow-* | grow-* |
|
||||
| overflow-ellipsis | text-ellipsis |
|
||||
| decoration-slice | box-decoration-slice |
|
||||
| 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>
|
||||
|
||||
68
app/Console/Commands/BackfillEventInvitations.php
Normal file
68
app/Console/Commands/BackfillEventInvitations.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Services\EventJoinTokenService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class BackfillEventInvitations extends Command
|
||||
{
|
||||
protected $signature = 'tenant:backfill-invitations {--tenant=} {--limit=100} {--dry-run}';
|
||||
|
||||
protected $description = 'Ensure every event has at least one invitation link (join token).';
|
||||
|
||||
public function handle(EventJoinTokenService $joinTokenService): int
|
||||
{
|
||||
$tenantId = $this->option('tenant');
|
||||
$limit = (int) $this->option('limit');
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
|
||||
$query = Event::query()
|
||||
->doesntHave('joinTokens')
|
||||
->orderBy('id');
|
||||
|
||||
if ($tenantId) {
|
||||
$query->where('tenant_id', $tenantId);
|
||||
}
|
||||
|
||||
$processed = 0;
|
||||
|
||||
$query->chunkById(50, function ($events) use ($joinTokenService, $dryRun, &$processed, $limit) {
|
||||
foreach ($events as $event) {
|
||||
if ($processed >= $limit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$processed++;
|
||||
|
||||
if ($dryRun) {
|
||||
$this->line("[dry-run] Would create invitation for event #{$event->id} ({$event->slug})");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$joinTokenService->createToken($event, [
|
||||
'label' => 'Standard-Link',
|
||||
'metadata' => [
|
||||
'auto_generated' => true,
|
||||
'backfilled_at' => now()->toIso8601String(),
|
||||
],
|
||||
]);
|
||||
|
||||
$this->line("Created invitation for event #{$event->id} ({$event->slug})");
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
if ($processed === 0) {
|
||||
$this->info('No events required backfilling.');
|
||||
} else {
|
||||
$suffix = $dryRun ? ' (dry-run)' : '';
|
||||
$this->info("Processed {$processed} event(s){$suffix}.");
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -7,18 +7,16 @@ use App\Models\Package;
|
||||
use App\Models\PackagePurchase;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantPackage;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Stripe\Stripe;
|
||||
use Stripe\PaymentIntent;
|
||||
use PayPal\PayPalClient;
|
||||
use PayPal\Environment\SandboxEnvironment;
|
||||
use PayPal\Environment\LiveEnvironment;
|
||||
use PayPal\Checkout\Orders\OrdersCreateRequest;
|
||||
use PayPal\Checkout\Orders\OrdersCaptureRequest;
|
||||
use PayPal\Checkout\Orders\OrdersCreateRequest;
|
||||
use PayPal\Environment\LiveEnvironment;
|
||||
use PayPal\Environment\SandboxEnvironment;
|
||||
use PayPal\PayPalClient;
|
||||
|
||||
class PackageController extends Controller
|
||||
{
|
||||
@@ -30,7 +28,16 @@ class PackageController extends Controller
|
||||
->get();
|
||||
|
||||
$packages->each(function ($package) {
|
||||
$package->features = json_decode($package->features ?? '[]', true);
|
||||
if (is_string($package->features)) {
|
||||
$decoded = json_decode($package->features, true);
|
||||
$package->features = is_array($decoded) ? $decoded : [];
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! is_array($package->features)) {
|
||||
$package->features = [];
|
||||
}
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
@@ -154,7 +161,7 @@ class PackageController extends Controller
|
||||
throw ValidationException::withMessages(['package' => 'Not a free package.']);
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($request, $package, $tenant) {
|
||||
DB::transaction(function () use ($package, $tenant) {
|
||||
PackagePurchase::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'package_id' => $package->id,
|
||||
@@ -202,25 +209,25 @@ class PackageController extends Controller
|
||||
|
||||
$client = PayPalClient::client($environment);
|
||||
|
||||
$request = new OrdersCreateRequest();
|
||||
$request = new OrdersCreateRequest;
|
||||
$request->prefer('return=representation');
|
||||
$request->body = [
|
||||
"intent" => "CAPTURE",
|
||||
"purchase_units" => [[
|
||||
"amount" => [
|
||||
"currency_code" => "EUR",
|
||||
"value" => number_format($package->price, 2, '.', ''),
|
||||
'intent' => 'CAPTURE',
|
||||
'purchase_units' => [[
|
||||
'amount' => [
|
||||
'currency_code' => 'EUR',
|
||||
'value' => number_format($package->price, 2, '.', ''),
|
||||
],
|
||||
"description" => 'Fotospiel Package: ' . $package->name,
|
||||
"custom_id" => json_encode([
|
||||
'description' => 'Fotospiel Package: '.$package->name,
|
||||
'custom_id' => json_encode([
|
||||
'tenant_id' => $tenant->id,
|
||||
'package_id' => $package->id,
|
||||
'user_id' => $tenant->user_id ?? null,
|
||||
]),
|
||||
]],
|
||||
"application_context" => [
|
||||
"shipping_preference" => "NO_SHIPPING",
|
||||
"user_action" => "PAY_NOW",
|
||||
'application_context' => [
|
||||
'shipping_preference' => 'NO_SHIPPING',
|
||||
'user_action' => 'PAY_NOW',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -326,6 +333,7 @@ class PackageController extends Controller
|
||||
return response()->json(['success' => true, 'message' => 'Payment successful']);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('PayPal capture error: '.$e->getMessage(), ['order_id' => $orderId]);
|
||||
|
||||
return response()->json(['success' => false, 'message' => 'Capture failed: '.$e->getMessage()], 422);
|
||||
}
|
||||
}
|
||||
@@ -380,10 +388,12 @@ class PackageController extends Controller
|
||||
$type = $request->type;
|
||||
|
||||
if ($type === 'reseller_subscription') {
|
||||
$response = (new StripeController())->createSubscription($request);
|
||||
$response = (new StripeController)->createSubscription($request);
|
||||
|
||||
return $response;
|
||||
} else {
|
||||
$response = (new StripeController())->createPaymentIntent($request);
|
||||
$response = (new StripeController)->createPaymentIntent($request);
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
|
||||
83
app/Http/Controllers/Api/Tenant/DashboardController.php
Normal file
83
app/Http/Controllers/Api/Tenant/DashboardController.php
Normal file
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Tenant;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Event;
|
||||
use App\Models\Photo;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
$tenant = $request->attributes->get('tenant');
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
$decoded = $request->attributes->get('decoded_token', []);
|
||||
$tenantId = Arr::get($decoded, 'tenant_id');
|
||||
|
||||
if ($tenantId) {
|
||||
$tenant = Tenant::query()->find($tenantId);
|
||||
}
|
||||
}
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return response()->json([
|
||||
'message' => 'Tenant context missing.',
|
||||
], 401);
|
||||
}
|
||||
|
||||
$eventsQuery = Event::query()
|
||||
->where('tenant_id', $tenant->getKey());
|
||||
|
||||
$activeEvents = (clone $eventsQuery)
|
||||
->where(fn ($query) => $query
|
||||
->where('is_active', true)
|
||||
->orWhere('status', 'published'))
|
||||
->count();
|
||||
|
||||
$upcomingEvents = (clone $eventsQuery)
|
||||
->whereDate('date', '>=', Carbon::now()->startOfDay())
|
||||
->count();
|
||||
|
||||
$eventsWithTasks = (clone $eventsQuery)
|
||||
->whereHas('tasks')
|
||||
->count();
|
||||
|
||||
$totalEvents = (clone $eventsQuery)->count();
|
||||
|
||||
$taskProgress = $totalEvents > 0
|
||||
? (int) round(($eventsWithTasks / $totalEvents) * 100)
|
||||
: 0;
|
||||
|
||||
$newPhotos = Photo::query()
|
||||
->whereHas('event', fn ($query) => $query->where('tenant_id', $tenant->getKey()))
|
||||
->where('created_at', '>=', Carbon::now()->subDays(7))
|
||||
->count();
|
||||
|
||||
$activePackage = $tenant->tenantPackages()
|
||||
->with('package')
|
||||
->where('active', true)
|
||||
->orderByDesc('expires_at')
|
||||
->orderByDesc('purchased_at')
|
||||
->first();
|
||||
|
||||
return response()->json([
|
||||
'active_events' => $activeEvents,
|
||||
'new_photos' => $newPhotos,
|
||||
'task_progress' => $taskProgress,
|
||||
'credit_balance' => $tenant->event_credits_balance ?? null,
|
||||
'upcoming_events' => $upcomingEvents,
|
||||
'active_package' => $activePackage ? [
|
||||
'name' => $activePackage->package?->getNameForLocale(app()->getLocale()) ?? $activePackage->package?->name ?? '',
|
||||
'expires_at' => optional($activePackage->expires_at)->toIso8601String(),
|
||||
'remaining_events' => $activePackage->remaining_events ?? null,
|
||||
] : null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,12 @@ namespace App\Http\Controllers\Api\Tenant;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Tenant\EventStoreRequest;
|
||||
use App\Http\Resources\Tenant\EventJoinTokenResource;
|
||||
use App\Http\Resources\Tenant\EventResource;
|
||||
use App\Models\Event;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\EventPackage;
|
||||
use App\Models\Package;
|
||||
use App\Models\Photo;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\EventJoinTokenService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -21,9 +22,7 @@ use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
class EventController extends Controller
|
||||
{
|
||||
public function __construct(private readonly EventJoinTokenService $joinTokenService)
|
||||
{
|
||||
}
|
||||
public function __construct(private readonly EventJoinTokenService $joinTokenService) {}
|
||||
|
||||
public function index(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
@@ -36,7 +35,12 @@ class EventController extends Controller
|
||||
}
|
||||
|
||||
$query = Event::where('tenant_id', $tenantId)
|
||||
->with(['eventType', 'photos'])
|
||||
->with([
|
||||
'eventType',
|
||||
'photos',
|
||||
'eventPackages.package',
|
||||
'eventPackage.package',
|
||||
])
|
||||
->orderBy('created_at', 'desc');
|
||||
|
||||
if ($request->has('status')) {
|
||||
@@ -65,9 +69,31 @@ class EventController extends Controller
|
||||
$validated = $request->validated();
|
||||
$tenantId = $tenant->id;
|
||||
|
||||
$packageId = $validated['package_id'] ?? 1; // Default to Free package ID 1
|
||||
$requestedPackageId = $validated['package_id'] ?? null;
|
||||
unset($validated['package_id']);
|
||||
|
||||
$tenantPackage = $tenant->tenantPackages()
|
||||
->with('package')
|
||||
->where('active', true)
|
||||
->orderByDesc('purchased_at')
|
||||
->first();
|
||||
|
||||
$package = null;
|
||||
|
||||
if ($requestedPackageId) {
|
||||
$package = Package::query()->find($requestedPackageId);
|
||||
}
|
||||
|
||||
if (! $package && $tenantPackage) {
|
||||
$package = $tenantPackage->package ?? Package::query()->find($tenantPackage->package_id);
|
||||
}
|
||||
|
||||
if (! $package) {
|
||||
throw ValidationException::withMessages([
|
||||
'package_id' => __('Aktuell ist kein aktives Paket verfügbar. Bitte buche zunächst ein Paket.'),
|
||||
]);
|
||||
}
|
||||
|
||||
$eventData = array_merge($validated, [
|
||||
'tenant_id' => $tenantId,
|
||||
'status' => $validated['status'] ?? 'draft',
|
||||
@@ -122,37 +148,30 @@ class EventController extends Controller
|
||||
|
||||
$eventData = Arr::only($eventData, $allowed);
|
||||
|
||||
$event = DB::transaction(function () use ($tenant, $eventData, $packageId) {
|
||||
$event = DB::transaction(function () use ($tenant, $eventData, $package) {
|
||||
$event = Event::create($eventData);
|
||||
|
||||
$package = \App\Models\Package::findOrFail($packageId);
|
||||
\App\Models\EventPackage::create([
|
||||
EventPackage::create([
|
||||
'event_id' => $event->id,
|
||||
'package_id' => $packageId,
|
||||
'price' => $package->price,
|
||||
'package_id' => $package->id,
|
||||
'purchased_price' => $package->price,
|
||||
'purchased_at' => now(),
|
||||
'gallery_expires_at' => $package->gallery_days ? now()->addDays($package->gallery_days) : null,
|
||||
]);
|
||||
|
||||
\App\Models\PackagePurchase::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'event_id' => $event->id,
|
||||
'package_id' => $packageId,
|
||||
'provider_id' => 'free',
|
||||
'price' => $package->price,
|
||||
'type' => 'endcustomer_event',
|
||||
'metadata' => json_encode(['note' => 'Free package assigned on event creation']),
|
||||
]);
|
||||
|
||||
if ($package->isReseller()) {
|
||||
$note = sprintf('Event #%d created (%s)', $event->id, $event->name);
|
||||
|
||||
if (! $tenant->consumeEventAllowance(1, 'event.create', $note)) {
|
||||
throw new HttpException(402, 'Insufficient credits or package allowance.');
|
||||
}
|
||||
}
|
||||
|
||||
return $event;
|
||||
});
|
||||
|
||||
$tenant->refresh();
|
||||
$event->load(['eventType', 'tenant', 'eventPackage.package']);
|
||||
$event->load(['eventType', 'tenant', 'eventPackages.package']);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Event created successfully',
|
||||
@@ -175,6 +194,10 @@ class EventController extends Controller
|
||||
'photos' => fn ($query) => $query->with('likes')->latest(),
|
||||
'tasks',
|
||||
'tenant' => fn ($query) => $query->select('id', 'name', 'event_credits_balance'),
|
||||
'eventPackages' => fn ($query) => $query
|
||||
->with('package')
|
||||
->orderByDesc('purchased_at')
|
||||
->orderByDesc('created_at'),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
@@ -229,7 +252,6 @@ class EventController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
public function stats(Request $request, Event $event): JsonResponse
|
||||
{
|
||||
$tenantId = $request->attributes->get('tenant_id');
|
||||
@@ -312,6 +334,7 @@ class EventController extends Controller
|
||||
'join_token' => new EventJoinTokenResource($joinToken),
|
||||
]);
|
||||
}
|
||||
|
||||
public function bulkUpdateStatus(Request $request): JsonResponse
|
||||
{
|
||||
$tenantId = $request->attributes->get('tenant_id');
|
||||
|
||||
@@ -7,17 +7,15 @@ use App\Http\Resources\Tenant\EventJoinTokenResource;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventJoinToken;
|
||||
use App\Services\EventJoinTokenService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class EventJoinTokenController extends Controller
|
||||
{
|
||||
public function __construct(private readonly EventJoinTokenService $joinTokenService)
|
||||
{
|
||||
}
|
||||
public function __construct(private readonly EventJoinTokenService $joinTokenService) {}
|
||||
|
||||
public function index(Request $request, Event $event): JsonResponse
|
||||
public function index(Request $request, Event $event): AnonymousResourceCollection
|
||||
{
|
||||
$this->authorizeEvent($request, $event);
|
||||
|
||||
@@ -48,7 +46,7 @@ class EventJoinTokenController extends Controller
|
||||
->setStatusCode(201);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, Event $event, EventJoinToken $joinToken): JsonResponse
|
||||
public function destroy(Request $request, Event $event, EventJoinToken $joinToken): EventJoinTokenResource
|
||||
{
|
||||
$this->authorizeEvent($request, $event);
|
||||
|
||||
|
||||
20
app/Http/Controllers/Api/Tenant/EventTypeController.php
Normal file
20
app/Http/Controllers/Api/Tenant/EventTypeController.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Tenant;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\Tenant\EventTypeResource;
|
||||
use App\Models\EventType;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
|
||||
class EventTypeController extends Controller
|
||||
{
|
||||
public function __invoke(): AnonymousResourceCollection
|
||||
{
|
||||
$eventTypes = EventType::query()
|
||||
->orderBy('slug')
|
||||
->get();
|
||||
|
||||
return EventTypeResource::collection($eventTypes);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use App\Models\Event;
|
||||
use App\Support\JoinTokenLayoutRegistry;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
class EventJoinTokenResource extends JsonResource
|
||||
{
|
||||
@@ -18,23 +19,25 @@ class EventJoinTokenResource extends JsonResource
|
||||
$eventFromRoute = $request->route('event');
|
||||
$eventContext = $eventFromRoute instanceof Event ? $eventFromRoute : ($this->resource->event ?? null);
|
||||
|
||||
$layouts = $eventContext
|
||||
? JoinTokenLayoutRegistry::toResponse(function (string $layoutId, string $format) use ($eventContext) {
|
||||
$layouts = [];
|
||||
if ($eventContext && Route::has('tenant.events.join-tokens.layouts.download')) {
|
||||
$layouts = JoinTokenLayoutRegistry::toResponse(function (string $layoutId, string $format) use ($eventContext) {
|
||||
return route('tenant.events.join-tokens.layouts.download', [
|
||||
'event' => $eventContext,
|
||||
'joinToken' => $this->resource,
|
||||
'layout' => $layoutId,
|
||||
'format' => $format,
|
||||
]);
|
||||
})
|
||||
: [];
|
||||
});
|
||||
}
|
||||
|
||||
$layoutsUrl = $eventContext
|
||||
? route('tenant.events.join-tokens.layouts.index', [
|
||||
$layoutsUrl = null;
|
||||
if ($eventContext && Route::has('tenant.events.join-tokens.layouts.index')) {
|
||||
$layoutsUrl = route('tenant.events.join-tokens.layouts.index', [
|
||||
'event' => $eventContext,
|
||||
'joinToken' => $this->resource,
|
||||
])
|
||||
: null;
|
||||
]);
|
||||
}
|
||||
|
||||
$plainToken = $this->resource->plain_token ?? $this->token;
|
||||
|
||||
@@ -50,7 +53,7 @@ class EventJoinTokenResource extends JsonResource
|
||||
'revoked_at' => optional($this->revoked_at)->toIso8601String(),
|
||||
'is_active' => $this->isActive(),
|
||||
'created_at' => optional($this->created_at)->toIso8601String(),
|
||||
'metadata' => $this->metadata ?? new \stdClass(),
|
||||
'metadata' => $this->metadata ?? new \stdClass,
|
||||
'layouts_url' => $layoutsUrl,
|
||||
'layouts' => $layouts,
|
||||
];
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Http\Resources\Tenant;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use Illuminate\Http\Resources\MissingValue;
|
||||
|
||||
class EventResource extends JsonResource
|
||||
{
|
||||
@@ -12,6 +13,21 @@ class EventResource extends JsonResource
|
||||
$tenantId = $request->attributes->get('tenant_id');
|
||||
$showSensitive = $this->tenant_id === $tenantId;
|
||||
$settings = is_array($this->settings) ? $this->settings : [];
|
||||
$eventPackage = null;
|
||||
|
||||
if ($this->relationLoaded('eventPackages')) {
|
||||
$related = $this->getRelation('eventPackages');
|
||||
|
||||
if (! $related instanceof MissingValue) {
|
||||
$eventPackage = $related->first();
|
||||
}
|
||||
} elseif ($this->relationLoaded('eventPackage')) {
|
||||
$related = $this->getRelation('eventPackage');
|
||||
|
||||
if (! $related instanceof MissingValue) {
|
||||
$eventPackage = $related;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $this->id,
|
||||
@@ -36,6 +52,13 @@ class EventResource extends JsonResource
|
||||
'is_public' => $this->status === 'published',
|
||||
'public_share_url' => null,
|
||||
'qr_code_url' => null,
|
||||
'package' => $eventPackage ? [
|
||||
'id' => $eventPackage->package_id,
|
||||
'name' => $eventPackage->package?->getNameForLocale(app()->getLocale()) ?? $eventPackage->package?->name,
|
||||
'price' => $eventPackage->purchased_price,
|
||||
'purchased_at' => $eventPackage->purchased_at?->toIso8601String(),
|
||||
'expires_at' => $eventPackage->gallery_expires_at?->toIso8601String(),
|
||||
] : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,15 +14,19 @@ class EventTypeResource extends JsonResource
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
$nameTranslations = is_array($this->name) ? $this->name : [];
|
||||
$fallbackName = is_string($this->name) ? $this->name : '';
|
||||
$localizedName = $nameTranslations[app()->getLocale()] ?? $nameTranslations['en'] ?? reset($nameTranslations) ?? $fallbackName;
|
||||
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'description' => $this->description,
|
||||
'slug' => $this->slug,
|
||||
'name' => $localizedName,
|
||||
'name_translations' => $nameTranslations,
|
||||
'icon' => $this->icon,
|
||||
'color' => $this->color,
|
||||
'is_active' => $this->is_active,
|
||||
'created_at' => $this->created_at->toISOString(),
|
||||
'updated_at' => $this->updated_at->toISOString(),
|
||||
'settings' => $this->settings ?? [],
|
||||
'created_at' => $this->created_at?->toISOString(),
|
||||
'updated_at' => $this->updated_at?->toISOString(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -2,21 +2,21 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Services\EventJoinTokenService;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use App\Models\EventStorageAssignment;
|
||||
use App\Models\EventMediaAsset;
|
||||
use App\Models\MediaStorageTarget;
|
||||
|
||||
class Event extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'events';
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'date' => 'datetime',
|
||||
'is_active' => 'boolean',
|
||||
@@ -24,6 +24,22 @@ class Event extends Model
|
||||
'description' => 'array',
|
||||
];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::created(function (self $event): void {
|
||||
if ($event->joinTokens()->exists()) {
|
||||
return;
|
||||
}
|
||||
|
||||
app(EventJoinTokenService::class)->createToken($event, [
|
||||
'label' => 'Standard-Link',
|
||||
'metadata' => [
|
||||
'auto_generated' => true,
|
||||
],
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
public function storageAssignments(): HasMany
|
||||
{
|
||||
return $this->hasMany(EventStorageAssignment::class);
|
||||
|
||||
@@ -6,7 +6,6 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use App\Models\EventJoinTokenEvent;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
|
||||
class EventJoinToken extends Model
|
||||
@@ -15,6 +14,7 @@ class EventJoinToken extends Model
|
||||
|
||||
protected $fillable = [
|
||||
'event_id',
|
||||
'token',
|
||||
'token_hash',
|
||||
'token_encrypted',
|
||||
'token_preview',
|
||||
@@ -36,6 +36,7 @@ class EventJoinToken extends Model
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'token',
|
||||
'token_encrypted',
|
||||
'token_hash',
|
||||
];
|
||||
|
||||
36
app/Providers/HorizonServiceProvider.php
Normal file
36
app/Providers/HorizonServiceProvider.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Laravel\Horizon\Horizon;
|
||||
use Laravel\Horizon\HorizonApplicationServiceProvider;
|
||||
|
||||
class HorizonServiceProvider extends HorizonApplicationServiceProvider
|
||||
{
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
// Horizon::routeSmsNotificationsTo('15556667777');
|
||||
// Horizon::routeMailNotificationsTo('example@example.com');
|
||||
// Horizon::routeSlackNotificationsTo('slack-webhook-url', '#channel');
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the Horizon gate.
|
||||
*
|
||||
* This gate determines who can access Horizon in non-local environments.
|
||||
*/
|
||||
protected function gate(): void
|
||||
{
|
||||
Gate::define('viewHorizon', function ($user = null) {
|
||||
return in_array(optional($user)->email, [
|
||||
//
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ use App\Models\EventJoinToken;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class EventJoinTokenService
|
||||
@@ -16,13 +15,10 @@ class EventJoinTokenService
|
||||
{
|
||||
return DB::transaction(function () use ($event, $attributes) {
|
||||
$tokenValue = $this->generateUniqueToken();
|
||||
$tokenHash = $this->hashToken($tokenValue);
|
||||
|
||||
$payload = [
|
||||
'event_id' => $event->id,
|
||||
'token_hash' => $tokenHash,
|
||||
'token_encrypted' => Crypt::encryptString($tokenValue),
|
||||
'token_preview' => $this->previewToken($tokenValue),
|
||||
'token' => $tokenValue,
|
||||
'label' => Arr::get($attributes, 'label'),
|
||||
'usage_limit' => Arr::get($attributes, 'usage_limit'),
|
||||
'metadata' => Arr::get($attributes, 'metadata', []),
|
||||
@@ -109,15 +105,4 @@ class EventJoinTokenService
|
||||
{
|
||||
return hash('sha256', $token);
|
||||
}
|
||||
|
||||
protected function previewToken(string $token): string
|
||||
{
|
||||
$length = strlen($token);
|
||||
|
||||
if ($length <= 10) {
|
||||
return $token;
|
||||
}
|
||||
|
||||
return substr($token, 0, 6).'…'.substr($token, -4);
|
||||
}
|
||||
}
|
||||
|
||||
9
boost.json
Normal file
9
boost.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"agents": [
|
||||
"codex"
|
||||
],
|
||||
"editors": [
|
||||
"vscode"
|
||||
],
|
||||
"guidelines": []
|
||||
}
|
||||
@@ -3,7 +3,8 @@
|
||||
return [
|
||||
App\Providers\AppServiceProvider::class,
|
||||
App\Providers\AuthServiceProvider::class,
|
||||
Stephenjude\FilamentBlog\FilamentBlogServiceProvider::class,
|
||||
App\Providers\Filament\SuperAdminPanelProvider::class,
|
||||
App\Providers\Filament\AdminPanelProvider::class,
|
||||
App\Providers\Filament\SuperAdminPanelProvider::class,
|
||||
App\Providers\HorizonServiceProvider::class,
|
||||
Stephenjude\FilamentBlog\FilamentBlogServiceProvider::class,
|
||||
];
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"firebase/php-jwt": "^6.11",
|
||||
"inertiajs/inertia-laravel": "^2.0",
|
||||
"laravel/framework": "^12.0",
|
||||
"laravel/horizon": "^5.37",
|
||||
"laravel/sanctum": "^4.2",
|
||||
"laravel/socialite": "^5.23",
|
||||
"laravel/tinker": "^2.10.1",
|
||||
@@ -25,6 +26,7 @@
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.23",
|
||||
"laravel/boost": "^1.5",
|
||||
"laravel/pail": "^1.2.2",
|
||||
"laravel/pint": "^1.18",
|
||||
"laravel/sail": "^1.41",
|
||||
|
||||
282
composer.lock
generated
282
composer.lock
generated
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "79b6c96efab0391868c6ce26689c0ce3",
|
||||
"content-hash": "c4ce377acba80c944149cab30605d24c",
|
||||
"packages": [
|
||||
{
|
||||
"name": "anourvalar/eloquent-serialize",
|
||||
@@ -2778,6 +2778,86 @@
|
||||
},
|
||||
"time": "2025-10-07T14:30:39+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/horizon",
|
||||
"version": "v5.37.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/laravel/horizon.git",
|
||||
"reference": "3a970f934e95e396faa0aab53b3a996c6bf47e95"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/laravel/horizon/zipball/3a970f934e95e396faa0aab53b3a996c6bf47e95",
|
||||
"reference": "3a970f934e95e396faa0aab53b3a996c6bf47e95",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-json": "*",
|
||||
"ext-pcntl": "*",
|
||||
"ext-posix": "*",
|
||||
"illuminate/contracts": "^9.21|^10.0|^11.0|^12.0",
|
||||
"illuminate/queue": "^9.21|^10.0|^11.0|^12.0",
|
||||
"illuminate/support": "^9.21|^10.0|^11.0|^12.0",
|
||||
"nesbot/carbon": "^2.17|^3.0",
|
||||
"php": "^8.0",
|
||||
"ramsey/uuid": "^4.0",
|
||||
"symfony/console": "^6.0|^7.0",
|
||||
"symfony/error-handler": "^6.0|^7.0",
|
||||
"symfony/polyfill-php83": "^1.28",
|
||||
"symfony/process": "^6.0|^7.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"mockery/mockery": "^1.0",
|
||||
"orchestra/testbench": "^7.0|^8.0|^9.0|^10.0",
|
||||
"phpstan/phpstan": "^1.10|^2.0",
|
||||
"phpunit/phpunit": "^9.0|^10.4|^11.5|^12.0",
|
||||
"predis/predis": "^1.1|^2.0|^3.0"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-redis": "Required to use the Redis PHP driver.",
|
||||
"predis/predis": "Required when not using the Redis PHP driver (^1.1|^2.0|^3.0)."
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"aliases": {
|
||||
"Horizon": "Laravel\\Horizon\\Horizon"
|
||||
},
|
||||
"providers": [
|
||||
"Laravel\\Horizon\\HorizonServiceProvider"
|
||||
]
|
||||
},
|
||||
"branch-alias": {
|
||||
"dev-master": "6.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Laravel\\Horizon\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Taylor Otwell",
|
||||
"email": "taylor@laravel.com"
|
||||
}
|
||||
],
|
||||
"description": "Dashboard and code-driven configuration for Laravel queues.",
|
||||
"keywords": [
|
||||
"laravel",
|
||||
"queue"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/laravel/horizon/issues",
|
||||
"source": "https://github.com/laravel/horizon/tree/v5.37.0"
|
||||
},
|
||||
"time": "2025-10-21T14:32:49+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/prompts",
|
||||
"version": "v0.3.7",
|
||||
@@ -9884,6 +9964,145 @@
|
||||
},
|
||||
"time": "2025-04-30T06:54:44+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/boost",
|
||||
"version": "v1.5.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/laravel/boost.git",
|
||||
"reference": "f100f43c1191a0b229a81cfb3d0cbcdf3053c381"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/laravel/boost/zipball/f100f43c1191a0b229a81cfb3d0cbcdf3053c381",
|
||||
"reference": "f100f43c1191a0b229a81cfb3d0cbcdf3053c381",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"guzzlehttp/guzzle": "^7.10",
|
||||
"illuminate/console": "^10.49.0|^11.45.3|^12.28.1",
|
||||
"illuminate/contracts": "^10.49.0|^11.45.3|^12.28.1",
|
||||
"illuminate/routing": "^10.49.0|^11.45.3|^12.28.1",
|
||||
"illuminate/support": "^10.49.0|^11.45.3|^12.28.1",
|
||||
"laravel/mcp": "^0.2.0|^0.3.0",
|
||||
"laravel/prompts": "0.1.25|^0.3.6",
|
||||
"laravel/roster": "^0.2.9",
|
||||
"php": "^8.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"laravel/pint": "1.20",
|
||||
"mockery/mockery": "^1.6.12",
|
||||
"orchestra/testbench": "^8.36.0|^9.15.0|^10.6",
|
||||
"pestphp/pest": "^2.36.0|^3.8.4",
|
||||
"phpstan/phpstan": "^2.1.27",
|
||||
"rector/rector": "^2.1"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"providers": [
|
||||
"Laravel\\Boost\\BoostServiceProvider"
|
||||
]
|
||||
},
|
||||
"branch-alias": {
|
||||
"dev-master": "1.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Laravel\\Boost\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"description": "Laravel Boost accelerates AI-assisted development by providing the essential context and structure that AI needs to generate high-quality, Laravel-specific code.",
|
||||
"homepage": "https://github.com/laravel/boost",
|
||||
"keywords": [
|
||||
"ai",
|
||||
"dev",
|
||||
"laravel"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/laravel/boost/issues",
|
||||
"source": "https://github.com/laravel/boost"
|
||||
},
|
||||
"time": "2025-10-25T02:38:57+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/mcp",
|
||||
"version": "v0.3.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/laravel/mcp.git",
|
||||
"reference": "4e1389eedb4741a624e26cc3660b31bae04c4342"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/laravel/mcp/zipball/4e1389eedb4741a624e26cc3660b31bae04c4342",
|
||||
"reference": "4e1389eedb4741a624e26cc3660b31bae04c4342",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-json": "*",
|
||||
"ext-mbstring": "*",
|
||||
"illuminate/console": "^10.49.0|^11.45.3|^12.28.1",
|
||||
"illuminate/container": "^10.49.0|^11.45.3|^12.28.1",
|
||||
"illuminate/contracts": "^10.49.0|^11.45.3|^12.28.1",
|
||||
"illuminate/http": "^10.49.0|^11.45.3|^12.28.1",
|
||||
"illuminate/json-schema": "^12.28.1",
|
||||
"illuminate/routing": "^10.49.0|^11.45.3|^12.28.1",
|
||||
"illuminate/support": "^10.49.0|^11.45.3|^12.28.1",
|
||||
"illuminate/validation": "^10.49.0|^11.45.3|^12.28.1",
|
||||
"php": "^8.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"laravel/pint": "1.20.0",
|
||||
"orchestra/testbench": "^8.36.0|^9.15.0|^10.6.0",
|
||||
"pestphp/pest": "^2.36.0|^3.8.4|^4.1.0",
|
||||
"phpstan/phpstan": "^2.1.27",
|
||||
"rector/rector": "^2.1.7"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"aliases": {
|
||||
"Mcp": "Laravel\\Mcp\\Server\\Facades\\Mcp"
|
||||
},
|
||||
"providers": [
|
||||
"Laravel\\Mcp\\Server\\McpServiceProvider"
|
||||
]
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Laravel\\Mcp\\": "src/",
|
||||
"Laravel\\Mcp\\Server\\": "src/Server/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Taylor Otwell",
|
||||
"email": "taylor@laravel.com"
|
||||
}
|
||||
],
|
||||
"description": "Rapidly build MCP servers for your Laravel applications.",
|
||||
"homepage": "https://github.com/laravel/mcp",
|
||||
"keywords": [
|
||||
"laravel",
|
||||
"mcp"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/laravel/mcp/issues",
|
||||
"source": "https://github.com/laravel/mcp"
|
||||
},
|
||||
"time": "2025-10-07T14:28:56+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/pail",
|
||||
"version": "v1.2.3",
|
||||
@@ -10029,6 +10248,67 @@
|
||||
},
|
||||
"time": "2025-09-19T02:57:12+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/roster",
|
||||
"version": "v0.2.9",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/laravel/roster.git",
|
||||
"reference": "82bbd0e2de614906811aebdf16b4305956816fa6"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/laravel/roster/zipball/82bbd0e2de614906811aebdf16b4305956816fa6",
|
||||
"reference": "82bbd0e2de614906811aebdf16b4305956816fa6",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"illuminate/console": "^10.0|^11.0|^12.0",
|
||||
"illuminate/contracts": "^10.0|^11.0|^12.0",
|
||||
"illuminate/routing": "^10.0|^11.0|^12.0",
|
||||
"illuminate/support": "^10.0|^11.0|^12.0",
|
||||
"php": "^8.1|^8.2",
|
||||
"symfony/yaml": "^6.4|^7.2"
|
||||
},
|
||||
"require-dev": {
|
||||
"laravel/pint": "^1.14",
|
||||
"mockery/mockery": "^1.6",
|
||||
"orchestra/testbench": "^8.22.0|^9.0|^10.0",
|
||||
"pestphp/pest": "^2.0|^3.0",
|
||||
"phpstan/phpstan": "^2.0"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"providers": [
|
||||
"Laravel\\Roster\\RosterServiceProvider"
|
||||
]
|
||||
},
|
||||
"branch-alias": {
|
||||
"dev-master": "1.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Laravel\\Roster\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"description": "Detect packages & approaches in use within a Laravel project",
|
||||
"homepage": "https://github.com/laravel/roster",
|
||||
"keywords": [
|
||||
"dev",
|
||||
"laravel"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/laravel/roster/issues",
|
||||
"source": "https://github.com/laravel/roster"
|
||||
},
|
||||
"time": "2025-10-20T09:56:46+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/sail",
|
||||
"version": "v1.46.0",
|
||||
|
||||
230
config/horizon.php
Normal file
230
config/horizon.php
Normal file
@@ -0,0 +1,230 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Horizon Name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This name appears in notifications and in the Horizon UI. Unique names
|
||||
| can be useful while running multiple instances of Horizon within an
|
||||
| application, allowing you to identify the Horizon you're viewing.
|
||||
|
|
||||
*/
|
||||
|
||||
'name' => env('HORIZON_NAME'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Horizon Domain
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This is the subdomain where Horizon will be accessible from. If this
|
||||
| setting is null, Horizon will reside under the same domain as the
|
||||
| application. Otherwise, this value will serve as the subdomain.
|
||||
|
|
||||
*/
|
||||
|
||||
'domain' => env('HORIZON_DOMAIN'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Horizon Path
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This is the URI path where Horizon will be accessible from. Feel free
|
||||
| to change this path to anything you like. Note that the URI will not
|
||||
| affect the paths of its internal API that aren't exposed to users.
|
||||
|
|
||||
*/
|
||||
|
||||
'path' => env('HORIZON_PATH', 'horizon'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Horizon Redis Connection
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This is the name of the Redis connection where Horizon will store the
|
||||
| meta information required for it to function. It includes the list
|
||||
| of supervisors, failed jobs, job metrics, and other information.
|
||||
|
|
||||
*/
|
||||
|
||||
'use' => 'default',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Horizon Redis Prefix
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This prefix will be used when storing all Horizon data in Redis. You
|
||||
| may modify the prefix when you are running multiple installations
|
||||
| of Horizon on the same server so that they don't have problems.
|
||||
|
|
||||
*/
|
||||
|
||||
'prefix' => env(
|
||||
'HORIZON_PREFIX',
|
||||
Str::slug(env('APP_NAME', 'laravel'), '_').'_horizon:'
|
||||
),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Horizon Route Middleware
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These middleware will get attached onto each Horizon route, giving you
|
||||
| the chance to add your own middleware to this list or change any of
|
||||
| the existing middleware. Or, you can simply stick with this list.
|
||||
|
|
||||
*/
|
||||
|
||||
'middleware' => ['web'],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Queue Wait Time Thresholds
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option allows you to configure when the LongWaitDetected event
|
||||
| will be fired. Every connection / queue combination may have its
|
||||
| own, unique threshold (in seconds) before this event is fired.
|
||||
|
|
||||
*/
|
||||
|
||||
'waits' => [
|
||||
'redis:default' => 60,
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Job Trimming Times
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you can configure for how long (in minutes) you desire Horizon to
|
||||
| persist the recent and failed jobs. Typically, recent jobs are kept
|
||||
| for one hour while all failed jobs are stored for an entire week.
|
||||
|
|
||||
*/
|
||||
|
||||
'trim' => [
|
||||
'recent' => 60,
|
||||
'pending' => 60,
|
||||
'completed' => 60,
|
||||
'recent_failed' => 10080,
|
||||
'failed' => 10080,
|
||||
'monitored' => 10080,
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Silenced Jobs
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Silencing a job will instruct Horizon to not place the job in the list
|
||||
| of completed jobs within the Horizon dashboard. This setting may be
|
||||
| used to fully remove any noisy jobs from the completed jobs list.
|
||||
|
|
||||
*/
|
||||
|
||||
'silenced' => [
|
||||
// App\Jobs\ExampleJob::class,
|
||||
],
|
||||
|
||||
'silenced_tags' => [
|
||||
// 'notifications',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Metrics
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you can configure how many snapshots should be kept to display in
|
||||
| the metrics graph. This will get used in combination with Horizon's
|
||||
| `horizon:snapshot` schedule to define how long to retain metrics.
|
||||
|
|
||||
*/
|
||||
|
||||
'metrics' => [
|
||||
'trim_snapshots' => [
|
||||
'job' => 24,
|
||||
'queue' => 24,
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Fast Termination
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When this option is enabled, Horizon's "terminate" command will not
|
||||
| wait on all of the workers to terminate unless the --wait option
|
||||
| is provided. Fast termination can shorten deployment delay by
|
||||
| allowing a new instance of Horizon to start while the last
|
||||
| instance will continue to terminate each of its workers.
|
||||
|
|
||||
*/
|
||||
|
||||
'fast_termination' => false,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Memory Limit (MB)
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value describes the maximum amount of memory the Horizon master
|
||||
| supervisor may consume before it is terminated and restarted. For
|
||||
| configuring these limits on your workers, see the next section.
|
||||
|
|
||||
*/
|
||||
|
||||
'memory_limit' => 64,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Queue Worker Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may define the queue worker settings used by your application
|
||||
| in all environments. These supervisors and settings handle all your
|
||||
| queued jobs and will be provisioned by Horizon during deployment.
|
||||
|
|
||||
*/
|
||||
|
||||
'defaults' => [
|
||||
'supervisor-1' => [
|
||||
'connection' => 'redis',
|
||||
'queue' => ['default'],
|
||||
'balance' => 'auto',
|
||||
'autoScalingStrategy' => 'time',
|
||||
'maxProcesses' => 1,
|
||||
'maxTime' => 0,
|
||||
'maxJobs' => 0,
|
||||
'memory' => 128,
|
||||
'tries' => 1,
|
||||
'timeout' => 60,
|
||||
'nice' => 0,
|
||||
],
|
||||
],
|
||||
|
||||
'environments' => [
|
||||
'production' => [
|
||||
'supervisor-1' => [
|
||||
'maxProcesses' => 10,
|
||||
'balanceMaxShift' => 1,
|
||||
'balanceCooldown' => 3,
|
||||
],
|
||||
],
|
||||
|
||||
'local' => [
|
||||
'supervisor-1' => [
|
||||
'maxProcesses' => 3,
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('event_packages', function (Blueprint $table) {
|
||||
if (! Schema::hasColumn('event_packages', 'gallery_expires_at')) {
|
||||
$table->timestamp('gallery_expires_at')->nullable();
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('event_packages', 'used_guests')) {
|
||||
$table->integer('used_guests')->default(0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('event_packages', function (Blueprint $table) {
|
||||
if (Schema::hasColumn('event_packages', 'gallery_expires_at')) {
|
||||
$table->dropColumn('gallery_expires_at');
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('event_packages', 'used_guests')) {
|
||||
$table->dropColumn('used_guests');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -2,18 +2,28 @@
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
use App\Models\{Event, EventType};
|
||||
use App\Models\Event;
|
||||
use App\Models\EventPackage;
|
||||
use App\Models\EventType;
|
||||
use App\Models\Package;
|
||||
use App\Models\PackagePurchase;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\EventJoinTokenService;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class DemoEventSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$type = EventType::where('slug', 'wedding')->first();
|
||||
if(!$type){ return; }
|
||||
$demoTenant = \App\Models\Tenant::where('slug', 'demo')->first();
|
||||
if (!$demoTenant) { return; }
|
||||
if (! $type) {
|
||||
return;
|
||||
}
|
||||
$demoTenant = Tenant::where('slug', 'demo-tenant')->first();
|
||||
if (! $demoTenant) {
|
||||
return;
|
||||
}
|
||||
$event = Event::updateOrCreate(['slug' => 'demo-wedding-2025'], [
|
||||
'tenant_id' => $demoTenant->id,
|
||||
'name' => ['de' => 'Demo Hochzeit 2025', 'en' => 'Demo Wedding 2025'],
|
||||
@@ -33,5 +43,43 @@ class DemoEventSeeder extends Seeder
|
||||
'label' => 'Demo QR',
|
||||
]);
|
||||
}
|
||||
|
||||
$package = Package::where('slug', 'standard')->first();
|
||||
if (! $package) {
|
||||
$package = Package::where('type', 'endcustomer')->orderBy('price')->first();
|
||||
}
|
||||
|
||||
if ($package) {
|
||||
$eventPackageData = [
|
||||
'purchased_price' => $package->price,
|
||||
'purchased_at' => now()->subDays(7),
|
||||
];
|
||||
|
||||
if (Schema::hasColumn('event_packages', 'used_photos')) {
|
||||
$eventPackageData['used_photos'] = 0;
|
||||
}
|
||||
if (Schema::hasColumn('event_packages', 'used_guests')) {
|
||||
$eventPackageData['used_guests'] = 0;
|
||||
}
|
||||
if (Schema::hasColumn('event_packages', 'gallery_expires_at')) {
|
||||
$eventPackageData['gallery_expires_at'] = now()->addDays($package->gallery_days ?? 30);
|
||||
}
|
||||
|
||||
EventPackage::updateOrCreate(
|
||||
[
|
||||
'event_id' => $event->id,
|
||||
'package_id' => $package->id,
|
||||
],
|
||||
$eventPackageData
|
||||
);
|
||||
|
||||
PackagePurchase::query()
|
||||
->where('tenant_id', $demoTenant->id)
|
||||
->where('package_id', $package->id)
|
||||
->where('provider_id', 'demo-seed')
|
||||
->update([
|
||||
'event_id' => $event->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
404
database/seeders/DemoLifecycleSeeder.php
Normal file
404
database/seeders/DemoLifecycleSeeder.php
Normal file
@@ -0,0 +1,404 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Models\EventPackage;
|
||||
use App\Models\EventType;
|
||||
use App\Models\OAuthClient;
|
||||
use App\Models\Package;
|
||||
use App\Models\PackagePurchase;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantPackage;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class DemoLifecycleSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
[$standard, $premium, $reseller] = $this->ensurePackages();
|
||||
[$weddingType, $corporateType] = $this->ensureEventTypes();
|
||||
|
||||
$this->seedOnboardingTenant();
|
||||
$this->seedActiveTenant($standard, $premium, $weddingType, $corporateType);
|
||||
$this->seedResellerTenant($reseller, $standard, $weddingType);
|
||||
$this->seedDormantTenant();
|
||||
}
|
||||
|
||||
private function ensurePackages(): array
|
||||
{
|
||||
$standard = Package::firstOrCreate(
|
||||
['slug' => 'standard'],
|
||||
[
|
||||
'type' => 'endcustomer',
|
||||
'name' => 'Standard',
|
||||
'name_translations' => ['de' => 'Standard', 'en' => 'Standard'],
|
||||
'price' => 79,
|
||||
'max_photos' => 1500,
|
||||
'max_guests' => 400,
|
||||
'gallery_days' => 60,
|
||||
'features' => [
|
||||
'basic_uploads' => true,
|
||||
'unlimited_sharing' => true,
|
||||
'no_watermark' => true,
|
||||
'custom_tasks' => true,
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
$premium = Package::firstOrCreate(
|
||||
['slug' => 'premium'],
|
||||
[
|
||||
'type' => 'endcustomer',
|
||||
'name' => 'Premium',
|
||||
'name_translations' => ['de' => 'Premium', 'en' => 'Premium'],
|
||||
'price' => 149,
|
||||
'max_photos' => 5000,
|
||||
'max_guests' => 1000,
|
||||
'gallery_days' => 180,
|
||||
'features' => [
|
||||
'basic_uploads' => true,
|
||||
'unlimited_sharing' => true,
|
||||
'no_watermark' => true,
|
||||
'custom_branding' => true,
|
||||
'custom_tasks' => true,
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
$reseller = Package::firstOrCreate(
|
||||
['slug' => 'studio-annual'],
|
||||
[
|
||||
'type' => 'reseller',
|
||||
'name' => 'Studio Jahrespaket',
|
||||
'name_translations' => ['de' => 'Studio Jahrespaket', 'en' => 'Studio Annual'],
|
||||
'price' => 1299,
|
||||
'max_events_per_year' => 24,
|
||||
'features' => [
|
||||
'custom_branding' => true,
|
||||
'unlimited_sharing' => true,
|
||||
'basic_uploads' => true,
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
return [$standard, $premium, $reseller];
|
||||
}
|
||||
|
||||
private function ensureEventTypes(): array
|
||||
{
|
||||
$weddingType = EventType::firstOrCreate(
|
||||
['slug' => 'wedding'],
|
||||
[
|
||||
'name' => 'Wedding',
|
||||
'name_translations' => ['de' => 'Hochzeit', 'en' => 'Wedding'],
|
||||
'icon' => 'heart',
|
||||
]
|
||||
);
|
||||
|
||||
$corporateType = EventType::firstOrCreate(
|
||||
['slug' => 'corporate'],
|
||||
[
|
||||
'name' => 'Corporate Event',
|
||||
'name_translations' => ['de' => 'Firmen-Event', 'en' => 'Corporate'],
|
||||
'icon' => 'presentation-chart',
|
||||
]
|
||||
);
|
||||
|
||||
return [$weddingType, $corporateType];
|
||||
}
|
||||
|
||||
private function seedOnboardingTenant(): void
|
||||
{
|
||||
$tenant = $this->createTenant('storycraft-weddings', [
|
||||
'name' => 'Storycraft Weddings',
|
||||
'contact_email' => 'hello@storycraft-weddings.demo',
|
||||
'event_credits_balance' => 0,
|
||||
'subscription_tier' => 'free',
|
||||
'subscription_status' => 'free',
|
||||
'subscription_expires_at' => null,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->createTenantAdmin($tenant, 'storycraft-owner@demo.fotospiel');
|
||||
$this->ensureOAuthClientForTenant($tenant, 'demo-tenant-admin-storycraft');
|
||||
}
|
||||
|
||||
private function seedActiveTenant(Package $standard, Package $premium, EventType $weddingType, EventType $corporateType): void
|
||||
{
|
||||
$tenant = $this->createTenant('lumen-moments', [
|
||||
'name' => 'Lumen Moments',
|
||||
'contact_email' => 'hello@lumen-moments.demo',
|
||||
'event_credits_balance' => 2,
|
||||
'subscription_tier' => 'starter',
|
||||
'subscription_status' => 'active',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
OAuthClient::query()
|
||||
->where('client_id', config('services.oauth.tenant_admin.id', 'tenant-admin-app'))
|
||||
->update(['tenant_id' => $tenant->id]);
|
||||
|
||||
$this->createTenantAdmin($tenant, 'hello@lumen-moments.demo');
|
||||
$this->ensureOAuthClientForTenant($tenant, 'demo-tenant-admin-lumen');
|
||||
|
||||
$purchase = PackagePurchase::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'package_id' => $premium->id,
|
||||
'provider_id' => 'stripe_demo_pi',
|
||||
'price' => $premium->price,
|
||||
'type' => 'endcustomer_event',
|
||||
'purchased_at' => Carbon::now()->subDays(3),
|
||||
'metadata' => ['demo' => true],
|
||||
]);
|
||||
|
||||
$publishedEvent = $this->createEventWithPackage(
|
||||
tenant: $tenant,
|
||||
package: $premium,
|
||||
eventType: $weddingType,
|
||||
attributes: [
|
||||
'name' => ['de' => 'Sommerhochzeit Lea & Tim', 'en' => 'Summer Wedding Lea & Tim'],
|
||||
'slug' => 'summer-wedding-lea-tim',
|
||||
'status' => 'published',
|
||||
'is_active' => true,
|
||||
'date' => Carbon::now()->addWeeks(4),
|
||||
]
|
||||
);
|
||||
|
||||
$draftEvent = $this->createEventWithPackage(
|
||||
tenant: $tenant,
|
||||
package: $standard,
|
||||
eventType: $corporateType,
|
||||
attributes: [
|
||||
'name' => ['de' => 'Startup Social 2025', 'en' => 'Startup Social 2025'],
|
||||
'slug' => 'startup-social-2025',
|
||||
'status' => 'draft',
|
||||
'is_active' => false,
|
||||
'date' => Carbon::now()->addWeeks(12),
|
||||
]
|
||||
);
|
||||
|
||||
$purchase->update(['event_id' => $publishedEvent->id]);
|
||||
|
||||
PackagePurchase::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'event_id' => $draftEvent->id,
|
||||
'package_id' => $standard->id,
|
||||
'provider_id' => 'paypal_demo_capture',
|
||||
'price' => $standard->price,
|
||||
'type' => 'endcustomer_event',
|
||||
'purchased_at' => Carbon::now()->subDays(1),
|
||||
'metadata' => ['demo' => true],
|
||||
]);
|
||||
}
|
||||
|
||||
private function seedResellerTenant(Package $reseller, Package $standard, EventType $weddingType): void
|
||||
{
|
||||
$tenant = $this->createTenant('viewfinder-studios', [
|
||||
'name' => 'Viewfinder Studios',
|
||||
'contact_email' => 'team@viewfinder.demo',
|
||||
'event_credits_balance' => 0,
|
||||
'subscription_tier' => 'reseller',
|
||||
'subscription_status' => 'active',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->createTenantAdmin($tenant, 'team@viewfinder.demo');
|
||||
$this->ensureOAuthClientForTenant($tenant, 'demo-tenant-admin-viewfinder');
|
||||
|
||||
$tenantPackage = TenantPackage::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'package_id' => $reseller->id,
|
||||
'price' => $reseller->price,
|
||||
'purchased_at' => Carbon::now()->subMonths(2),
|
||||
'expires_at' => Carbon::now()->addMonths(10),
|
||||
'used_events' => 6,
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
PackagePurchase::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'package_id' => $reseller->id,
|
||||
'provider_id' => 'stripe_demo_subscription',
|
||||
'price' => $reseller->price,
|
||||
'type' => 'reseller_subscription',
|
||||
'purchased_at' => $tenantPackage->purchased_at,
|
||||
'metadata' => ['demo' => true, 'plan' => 'studio-annual'],
|
||||
]);
|
||||
|
||||
// Create a mix of events representing allowance consumption.
|
||||
$statuses = ['published', 'published', 'draft', 'archived'];
|
||||
|
||||
foreach ($statuses as $index => $status) {
|
||||
$event = $this->createEventWithPackage(
|
||||
tenant: $tenant,
|
||||
package: $standard,
|
||||
eventType: $weddingType,
|
||||
attributes: [
|
||||
'name' => ['de' => 'Studio Event #'.($index + 1), 'en' => 'Studio Event #'.($index + 1)],
|
||||
'slug' => 'studio-event-'.($index + 1),
|
||||
'status' => $status,
|
||||
'is_active' => $status === 'published',
|
||||
'date' => $status === 'archived'
|
||||
? Carbon::now()->subMonths(3)
|
||||
: Carbon::now()->addWeeks($index * 3 + 1),
|
||||
]
|
||||
);
|
||||
|
||||
PackagePurchase::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'event_id' => $event->id,
|
||||
'package_id' => $standard->id,
|
||||
'provider_id' => 'reseller_allowance',
|
||||
'price' => 0,
|
||||
'type' => 'endcustomer_event',
|
||||
'purchased_at' => Carbon::now()->subDays(10 - $index),
|
||||
'metadata' => ['allowance' => true],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function seedDormantTenant(): void
|
||||
{
|
||||
$tenant = $this->createTenant('pixel-and-co', [
|
||||
'name' => 'Pixel & Co',
|
||||
'contact_email' => 'support@pixelco.demo',
|
||||
'subscription_status' => 'expired',
|
||||
'subscription_tier' => 'free',
|
||||
'subscription_expires_at' => Carbon::now()->subMonths(2),
|
||||
'is_active' => false,
|
||||
'is_suspended' => false,
|
||||
'event_credits_balance' => 0,
|
||||
]);
|
||||
|
||||
$this->createTenantAdmin($tenant, 'support@pixelco.demo', role: 'member');
|
||||
$this->ensureOAuthClientForTenant($tenant, 'demo-tenant-admin-pixel');
|
||||
}
|
||||
|
||||
private function createTenantAdmin(Tenant $tenant, string $email, string $role = 'tenant_admin'): User
|
||||
{
|
||||
return User::updateOrCreate(
|
||||
['email' => $email],
|
||||
[
|
||||
'tenant_id' => $tenant->id,
|
||||
'role' => $role,
|
||||
'password' => Hash::make('Demo1234!'),
|
||||
'first_name' => Str::headline(Str::before($tenant->slug, '-')),
|
||||
'last_name' => 'Team',
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
private function createEventWithPackage(
|
||||
Tenant $tenant,
|
||||
Package $package,
|
||||
EventType $eventType,
|
||||
array $attributes
|
||||
): Event {
|
||||
$payload = array_merge([
|
||||
'tenant_id' => $tenant->id,
|
||||
'event_type_id' => $eventType->id,
|
||||
'settings' => [
|
||||
'features' => [
|
||||
'photo_likes_enabled' => true,
|
||||
'event_checklist' => true,
|
||||
],
|
||||
],
|
||||
], $attributes);
|
||||
|
||||
$event = Event::updateOrCreate(['slug' => $attributes['slug']], $payload);
|
||||
|
||||
EventPackage::updateOrCreate(
|
||||
[
|
||||
'event_id' => $event->id,
|
||||
'package_id' => $package->id,
|
||||
],
|
||||
[
|
||||
'purchased_price' => $package->price,
|
||||
'purchased_at' => Carbon::now()->subDays(2),
|
||||
'used_photos' => 0,
|
||||
'used_guests' => 0,
|
||||
'gallery_expires_at' => Carbon::now()->addDays($package->gallery_days ?? 30),
|
||||
]
|
||||
);
|
||||
|
||||
return $event;
|
||||
}
|
||||
|
||||
private function createTenant(string $slug, array $overrides = []): Tenant
|
||||
{
|
||||
$email = $overrides['contact_email'] ?? $slug.'@demo.fotospiel';
|
||||
|
||||
$defaults = [
|
||||
'name' => Str::headline(str_replace('-', ' ', $slug)),
|
||||
'slug' => $slug,
|
||||
'contact_email' => $email,
|
||||
'event_credits_balance' => 0,
|
||||
'subscription_tier' => 'free',
|
||||
'subscription_status' => 'free',
|
||||
'subscription_expires_at' => Carbon::now()->addMonths(6),
|
||||
'is_active' => true,
|
||||
'is_suspended' => false,
|
||||
'settings_updated_at' => Carbon::now(),
|
||||
'settings' => $this->defaultSettings($email),
|
||||
];
|
||||
|
||||
$attributes = array_merge($defaults, $overrides);
|
||||
|
||||
$tenant = Tenant::updateOrCreate(['slug' => $slug], $attributes);
|
||||
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
private function defaultSettings(string $contactEmail): array
|
||||
{
|
||||
return [
|
||||
'branding' => [
|
||||
'logo_url' => null,
|
||||
'primary_color' => '#3B82F6',
|
||||
'secondary_color' => '#1F2937',
|
||||
'font_family' => 'Inter, sans-serif',
|
||||
],
|
||||
'features' => [
|
||||
'photo_likes_enabled' => true,
|
||||
'event_checklist' => true,
|
||||
'custom_domain' => false,
|
||||
'advanced_analytics' => false,
|
||||
],
|
||||
'custom_domain' => null,
|
||||
'contact_email' => $contactEmail,
|
||||
'event_default_type' => 'general',
|
||||
];
|
||||
}
|
||||
|
||||
private function ensureOAuthClientForTenant(Tenant $tenant, string $clientId): void
|
||||
{
|
||||
$redirectUris = config('services.oauth.tenant_admin.redirects', []);
|
||||
if (empty($redirectUris)) {
|
||||
$redirectUris = [
|
||||
'http://localhost:5173/event-admin/auth/callback',
|
||||
url('/event-admin/auth/callback'),
|
||||
];
|
||||
}
|
||||
|
||||
$client = OAuthClient::firstOrNew(['client_id' => $clientId]);
|
||||
|
||||
if (! $client->exists) {
|
||||
$client->id = (string) Str::uuid();
|
||||
}
|
||||
|
||||
$client->fill([
|
||||
'client_secret' => null,
|
||||
'tenant_id' => $tenant->id,
|
||||
'redirect_uris' => $redirectUris,
|
||||
'scopes' => ['tenant:read', 'tenant:write'],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$client->save();
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\EventPurchase;
|
||||
use App\Models\Package;
|
||||
use App\Models\PackagePurchase;
|
||||
use App\Models\Tenant;
|
||||
@@ -18,10 +19,19 @@ class DemoTenantSeeder extends Seeder
|
||||
$email = 'tenant-demo@fotospiel.app';
|
||||
$password = config('seeding.demo_tenant_password', 'Demo1234!');
|
||||
$package = Package::query()
|
||||
->where('type', 'reseller')
|
||||
->where('slug', 'standard')
|
||||
->first();
|
||||
|
||||
if (! $package) {
|
||||
$package = Package::query()
|
||||
->where('type', 'endcustomer')
|
||||
->orderBy('price')
|
||||
->first()
|
||||
?? Package::query()->orderBy('price')->first();
|
||||
->first();
|
||||
}
|
||||
|
||||
if (! $package) {
|
||||
$package = Package::query()->orderBy('price')->first();
|
||||
}
|
||||
|
||||
if (! $package) {
|
||||
$this->command?->warn('Skipped DemoTenantSeeder: no packages available.');
|
||||
@@ -82,6 +92,8 @@ class DemoTenantSeeder extends Seeder
|
||||
$user->forceFill(['tenant_id' => $tenant->id])->save();
|
||||
}
|
||||
|
||||
$purchasedAt = now()->subDays(7);
|
||||
|
||||
TenantPackage::query()->updateOrCreate(
|
||||
[
|
||||
'tenant_id' => $tenant->id,
|
||||
@@ -90,8 +102,8 @@ class DemoTenantSeeder extends Seeder
|
||||
[
|
||||
'price' => $package->price,
|
||||
'active' => true,
|
||||
'purchased_at' => now()->subDays(7),
|
||||
'expires_at' => now()->addYear(),
|
||||
'purchased_at' => $purchasedAt,
|
||||
'expires_at' => now()->addMonths(6),
|
||||
'used_events' => 0,
|
||||
]
|
||||
);
|
||||
@@ -103,13 +115,30 @@ class DemoTenantSeeder extends Seeder
|
||||
'provider_id' => 'demo-seed',
|
||||
],
|
||||
[
|
||||
'event_id' => null,
|
||||
'price' => $package->price,
|
||||
'type' => $package->type === 'reseller' ? 'reseller_subscription' : 'endcustomer_event',
|
||||
'purchased_at' => now()->subDays(7),
|
||||
'purchased_at' => $purchasedAt,
|
||||
'metadata' => [
|
||||
'seeded' => true,
|
||||
'note' => 'Demo tenant seed purchase',
|
||||
],
|
||||
'ip_address' => null,
|
||||
'user_agent' => null,
|
||||
]
|
||||
);
|
||||
|
||||
EventPurchase::query()->updateOrCreate(
|
||||
[
|
||||
'tenant_id' => $tenant->id,
|
||||
'provider' => 'demo-seed',
|
||||
],
|
||||
[
|
||||
'events_purchased' => 1,
|
||||
'amount' => $package->price,
|
||||
'currency' => 'EUR',
|
||||
'status' => 'completed',
|
||||
'purchased_at' => $purchasedAt,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
13
docs/deployment/lokale podman adressen.md
Normal file
13
docs/deployment/lokale podman adressen.md
Normal file
@@ -0,0 +1,13 @@
|
||||
Services & URLs
|
||||
|
||||
| Service | URL / Port | Notes |
|
||||
|----------------|--------------------------------|-------|
|
||||
| Laravel app | http://localhost:8000 | Default web UI; Horizon dashboard at /horizon if laravel/horizon is installed. |
|
||||
| Vite dev server| http://localhost:5173 | Hot module reload for the marketing/guest frontend.
|
||||
|
|
||||
| Mailpit UI | http://localhost:8025 | No auth; SMTP listening on port 1025. |
|
||||
| Grafana | http://localhost:3000 | Anonymous admin already enabled; dashboards will show Loki logs once you add Loki as a data source (URL http://loki:3100). |
|
||||
| Loki API | http://localhost:3100 | Used by Grafana/Promtail; direct browsing usually not needed. |
|
||||
| Portainer | https://localhost:9443 | First visit prompts you to set an admin password; point it to /var/run/docker.sock (already mounted from ${PODMAN_SOCKET}). |
|
||||
| Redis | Bound to localhost:6379 | Matches QUEUE_CONNECTION=redis. |
|
||||
| Promtail | Internal only (port 9080) | Tails storage/logs and pushes to Loki. |
|
||||
BIN
google-chrome-stable_current_amd64.deb
Normal file
BIN
google-chrome-stable_current_amd64.deb
Normal file
Binary file not shown.
13
package-lock.json
generated
13
package-lock.json
generated
@@ -36,6 +36,7 @@
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"@upstash/context7-mcp": "^1.0.21",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"chrome-devtools-mcp": "^0.9.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"concurrently": "^9.0.1",
|
||||
@@ -5507,6 +5508,18 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/chrome-devtools-mcp": {
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/chrome-devtools-mcp/-/chrome-devtools-mcp-0.9.0.tgz",
|
||||
"integrity": "sha512-7MzI/fdnwbKHzgnGWUmCyEYdKnSpfSIelDV9XNTz8wrjycoMB6cENryKLyZkLHXkZLlDdOLfYa9YtF+3lQoM2g==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"chrome-devtools-mcp": "build/src/index.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=23"
|
||||
}
|
||||
},
|
||||
"node_modules/chromium-bidi": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.11.0.tgz",
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"@upstash/context7-mcp": "^1.0.21",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"chrome-devtools-mcp": "^0.9.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"concurrently": "^9.0.1",
|
||||
|
||||
85
playwright-report/index.html
Normal file
85
playwright-report/index.html
Normal file
File diff suppressed because one or more lines are too long
@@ -10,6 +10,7 @@ function getDisplayValue(value: string | undefined) {
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
timeout: 90_000,
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
|
||||
@@ -18,16 +18,37 @@ export type EventJoinTokenLayout = {
|
||||
download_urls: Record<string, string>;
|
||||
};
|
||||
|
||||
export type TenantEventType = {
|
||||
id: number;
|
||||
slug: string;
|
||||
name: string;
|
||||
name_translations: Record<string, string>;
|
||||
icon: string | null;
|
||||
settings: Record<string, unknown>;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
};
|
||||
|
||||
export type TenantEvent = {
|
||||
id: number;
|
||||
name: string | Record<string, string>;
|
||||
slug: string;
|
||||
event_date: string | null;
|
||||
event_type_id: number | null;
|
||||
event_type: TenantEventType | null;
|
||||
status: 'draft' | 'published' | 'archived';
|
||||
is_active?: boolean;
|
||||
description?: string | null;
|
||||
photo_count?: number;
|
||||
like_count?: number;
|
||||
package?: {
|
||||
id: number | string | null;
|
||||
name: string | null;
|
||||
price: number | null;
|
||||
purchased_at: string | null;
|
||||
expires_at: string | null;
|
||||
} | null;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type TenantPhoto = {
|
||||
@@ -208,8 +229,8 @@ export type EventMember = {
|
||||
avatar_url?: string | null;
|
||||
};
|
||||
|
||||
type EventListResponse = { data?: TenantEvent[] };
|
||||
type EventResponse = { data: TenantEvent };
|
||||
type EventListResponse = { data?: JsonValue[] };
|
||||
type EventResponse = { data: JsonValue };
|
||||
|
||||
export type EventJoinToken = {
|
||||
id: number;
|
||||
@@ -226,13 +247,14 @@ export type EventJoinToken = {
|
||||
layouts: EventJoinTokenLayout[];
|
||||
layouts_url: string | null;
|
||||
};
|
||||
type CreatedEventResponse = { message: string; data: TenantEvent; balance: number };
|
||||
type CreatedEventResponse = { message: string; data: JsonValue; balance: number };
|
||||
type PhotoResponse = { message: string; data: TenantPhoto };
|
||||
|
||||
type EventSavePayload = {
|
||||
name: string;
|
||||
slug: string;
|
||||
date?: string;
|
||||
event_type_id: number;
|
||||
event_date?: string;
|
||||
status?: 'draft' | 'published' | 'archived';
|
||||
is_active?: boolean;
|
||||
package_id?: number;
|
||||
@@ -322,13 +344,51 @@ function pickTranslatedText(translations: Record<string, string>, fallback: stri
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function normalizeEvent(event: TenantEvent): TenantEvent {
|
||||
function normalizeEventType(raw: JsonValue | TenantEventType | null): TenantEventType | null {
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const translations = normalizeTranslationMap((raw as JsonValue).name ?? {}, undefined, true);
|
||||
const fallback = typeof (raw as JsonValue).name === 'string' ? (raw as JsonValue).name : 'Event';
|
||||
|
||||
return {
|
||||
...event,
|
||||
is_active: typeof event.is_active === 'boolean' ? event.is_active : undefined,
|
||||
id: Number((raw as JsonValue).id ?? 0),
|
||||
slug: String((raw as JsonValue).slug ?? ''),
|
||||
name: pickTranslatedText(translations, fallback ?? 'Event'),
|
||||
name_translations: translations,
|
||||
icon: ((raw as JsonValue).icon ?? null) as string | null,
|
||||
settings: ((raw as JsonValue).settings ?? {}) as Record<string, unknown>,
|
||||
created_at: (raw as JsonValue).created_at ?? null,
|
||||
updated_at: (raw as JsonValue).updated_at ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeEvent(event: JsonValue): TenantEvent {
|
||||
const normalizedType = normalizeEventType(event.event_type ?? event.eventType ?? null);
|
||||
const normalized: TenantEvent = {
|
||||
...(event as Record<string, unknown>),
|
||||
id: Number(event.id ?? 0),
|
||||
name: event.name ?? '',
|
||||
slug: String(event.slug ?? ''),
|
||||
event_date: typeof event.event_date === 'string'
|
||||
? event.event_date
|
||||
: (typeof event.date === 'string' ? event.date : null),
|
||||
event_type_id: event.event_type_id !== undefined && event.event_type_id !== null
|
||||
? Number(event.event_type_id)
|
||||
: null,
|
||||
event_type: normalizedType,
|
||||
status: (event.status ?? 'draft') as TenantEvent['status'],
|
||||
is_active: typeof event.is_active === 'boolean' ? event.is_active : undefined,
|
||||
description: event.description ?? null,
|
||||
photo_count: event.photo_count !== undefined ? Number(event.photo_count ?? 0) : undefined,
|
||||
like_count: event.like_count !== undefined ? Number(event.like_count ?? 0) : undefined,
|
||||
package: event.package ?? null,
|
||||
};
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizePhoto(photo: TenantPhoto): TenantPhoto {
|
||||
return {
|
||||
id: photo.id,
|
||||
@@ -574,6 +634,15 @@ export async function getEvent(slug: string): Promise<TenantEvent> {
|
||||
return normalizeEvent(data.data);
|
||||
}
|
||||
|
||||
export async function getEventTypes(): Promise<TenantEventType[]> {
|
||||
const response = await authorizedFetch('/api/v1/tenant/event-types');
|
||||
const data = await jsonOrThrow<{ data?: JsonValue[] }>(response, 'Failed to load event types');
|
||||
const rows = Array.isArray(data.data) ? data.data : [];
|
||||
return rows
|
||||
.map((row) => normalizeEventType(row))
|
||||
.filter((row): row is TenantEventType => Boolean(row));
|
||||
}
|
||||
|
||||
export async function getEventPhotos(slug: string): Promise<TenantPhoto[]> {
|
||||
const response = await authorizedFetch(`${eventEndpoint(slug)}/photos`);
|
||||
const data = await jsonOrThrow<{ data?: TenantPhoto[] }>(response, 'Failed to load photos');
|
||||
@@ -602,7 +671,7 @@ export async function deletePhoto(slug: string, id: number): Promise<void> {
|
||||
|
||||
export async function toggleEvent(slug: string): Promise<TenantEvent> {
|
||||
const response = await authorizedFetch(`${eventEndpoint(slug)}/toggle`, { method: 'POST' });
|
||||
const data = await jsonOrThrow<{ message: string; data: TenantEvent }>(response, 'Failed to toggle event');
|
||||
const data = await jsonOrThrow<{ message: string; data: JsonValue }>(response, 'Failed to toggle event');
|
||||
return normalizeEvent(data.data);
|
||||
}
|
||||
|
||||
@@ -621,7 +690,7 @@ export async function getEventStats(slug: string): Promise<EventStats> {
|
||||
|
||||
export async function getEventJoinTokens(slug: string): Promise<EventJoinToken[]> {
|
||||
const response = await authorizedFetch(`${eventEndpoint(slug)}/join-tokens`);
|
||||
const payload = await jsonOrThrow<{ data: JsonValue[] }>(response, 'Failed to load join tokens');
|
||||
const payload = await jsonOrThrow<{ data: JsonValue[] }>(response, 'Failed to load invitations');
|
||||
const list = Array.isArray(payload.data) ? payload.data : [];
|
||||
return list.map(normalizeJoinToken);
|
||||
}
|
||||
@@ -636,7 +705,7 @@ export async function createInviteLink(
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
});
|
||||
const data = await jsonOrThrow<{ data: JsonValue }>(response, 'Failed to create invite');
|
||||
const data = await jsonOrThrow<{ data: JsonValue }>(response, 'Failed to create invitation');
|
||||
return normalizeJoinToken(data.data ?? {});
|
||||
}
|
||||
|
||||
@@ -651,7 +720,7 @@ export async function revokeEventJoinToken(
|
||||
options.body = JSON.stringify({ reason });
|
||||
}
|
||||
const response = await authorizedFetch(`${eventEndpoint(slug)}/join-tokens/${tokenId}`, options);
|
||||
const data = await jsonOrThrow<{ data: JsonValue }>(response, 'Failed to revoke join token');
|
||||
const data = await jsonOrThrow<{ data: JsonValue }>(response, 'Failed to revoke invitation');
|
||||
return normalizeJoinToken(data.data ?? {});
|
||||
}
|
||||
|
||||
|
||||
75
resources/js/admin/components/DevTenantSwitcher.tsx
Normal file
75
resources/js/admin/components/DevTenantSwitcher.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import React from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
const DEV_TENANT_KEYS = [
|
||||
{ key: 'lumen', label: 'Lumen Moments' },
|
||||
{ key: 'storycraft', label: 'Storycraft Weddings' },
|
||||
{ key: 'viewfinder', label: 'Viewfinder Studios' },
|
||||
{ key: 'pixel', label: 'Pixel & Co (dormant)' },
|
||||
] as const;
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
fotospielDemoAuth?: {
|
||||
clients: Record<string, string>;
|
||||
loginAs: (tenantKey: string) => Promise<void>;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function DevTenantSwitcher() {
|
||||
const helper = window.fotospielDemoAuth;
|
||||
const [loggingIn, setLoggingIn] = React.useState<string | null>(null);
|
||||
|
||||
if (!helper) {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function handleLogin(key: string) {
|
||||
if (!helper) return;
|
||||
setLoggingIn(key);
|
||||
try {
|
||||
await helper.loginAs(key);
|
||||
} catch (error) {
|
||||
console.error('[DevAuth] Switch failed', error);
|
||||
setLoggingIn(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pointer-events-auto fixed bottom-4 right-4 z-[1000] flex max-w-xs flex-col gap-2 rounded-xl border border-amber-200 bg-white/95 p-3 text-sm shadow-xl shadow-amber-200/60">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<strong className="text-amber-800">Demo tenants</strong>
|
||||
<span className="text-xs uppercase tracking-wide text-amber-600">Dev mode</span>
|
||||
</div>
|
||||
<p className="text-xs text-amber-700">
|
||||
Select a seeded tenant to mint OAuth tokens and jump straight into their admin space. Available only in development builds.
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{DEV_TENANT_KEYS.map(({ key, label }) => (
|
||||
<Button
|
||||
key={key}
|
||||
variant="outline"
|
||||
className="w-full border-amber-200 text-amber-800 hover:bg-amber-50"
|
||||
disabled={Boolean(loggingIn)}
|
||||
onClick={() => void handleLogin(key)}
|
||||
>
|
||||
{loggingIn === key ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Verbinde...
|
||||
</>
|
||||
) : (
|
||||
label
|
||||
)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[10px] text-amber-600">
|
||||
Console: <code>fotospielDemoAuth.loginAs('lumen')</code>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
145
resources/js/admin/dev-tools.ts
Normal file
145
resources/js/admin/dev-tools.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
if (import.meta.env.DEV || import.meta.env.VITE_ENABLE_TENANT_SWITCHER === 'true') {
|
||||
type StoredTokens = {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresAt: number;
|
||||
scope?: string;
|
||||
};
|
||||
|
||||
const CLIENTS: Record<string, string> = {
|
||||
lumen: import.meta.env.VITE_OAUTH_CLIENT_ID || 'tenant-admin-app',
|
||||
storycraft: 'demo-tenant-admin-storycraft',
|
||||
viewfinder: 'demo-tenant-admin-viewfinder',
|
||||
pixel: 'demo-tenant-admin-pixel',
|
||||
};
|
||||
|
||||
const scopes = (import.meta.env.VITE_OAUTH_SCOPES as string | undefined) ?? 'tenant:read tenant:write';
|
||||
const baseUrl = window.location.origin;
|
||||
const redirectUri = `${baseUrl}/event-admin/auth/callback`;
|
||||
|
||||
async function loginAs(label: string): Promise<void> {
|
||||
const clientId = CLIENTS[label];
|
||||
if (!clientId) {
|
||||
console.warn('[DevAuth] Unknown tenant key', label);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const tokens = await fetchTokens(clientId);
|
||||
localStorage.setItem('tenant_oauth_tokens.v1', JSON.stringify(tokens));
|
||||
window.location.assign('/event-admin/dashboard');
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.error('[DevAuth] Failed to login', error.message);
|
||||
} else {
|
||||
console.error('[DevAuth] Failed to login', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTokens(clientId: string): Promise<StoredTokens> {
|
||||
const verifier = randomString(32);
|
||||
const challenge = await sha256(verifier);
|
||||
const state = randomString(12);
|
||||
|
||||
const authorizeParams = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
scope: scopes,
|
||||
state,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: 'S256',
|
||||
});
|
||||
|
||||
const callbackUrl = await requestAuthorization(`/api/v1/oauth/authorize?${authorizeParams}`);
|
||||
verifyState(callbackUrl.searchParams.get('state'), state);
|
||||
|
||||
const code = callbackUrl.searchParams.get('code');
|
||||
if (!code) {
|
||||
throw new Error('Authorize response missing code');
|
||||
}
|
||||
|
||||
const tokenResponse = await fetch('/api/v1/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
code_verifier: verifier,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
throw new Error(`Token exchange failed with ${tokenResponse.status}`);
|
||||
}
|
||||
|
||||
const body = await tokenResponse.json();
|
||||
const expiresIn = typeof body.expires_in === 'number' ? body.expires_in : 3600;
|
||||
|
||||
return {
|
||||
accessToken: body.access_token,
|
||||
refreshToken: body.refresh_token,
|
||||
expiresAt: Date.now() + Math.max(expiresIn - 30, 0) * 1000,
|
||||
scope: body.scope,
|
||||
};
|
||||
}
|
||||
|
||||
function randomString(bytes: number): string {
|
||||
const buffer = new Uint8Array(bytes);
|
||||
crypto.getRandomValues(buffer);
|
||||
return base64Url(buffer);
|
||||
}
|
||||
|
||||
async function sha256(input: string): Promise<string> {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(input);
|
||||
const digest = await crypto.subtle.digest('SHA-256', data);
|
||||
return base64Url(new Uint8Array(digest));
|
||||
}
|
||||
|
||||
function base64Url(data: Uint8Array): string {
|
||||
const binary = Array.from(data, (byte) => String.fromCharCode(byte)).join('');
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
const api = { loginAs, clients: CLIENTS };
|
||||
|
||||
console.info('[DevAuth] Demo tenant helpers ready', Object.keys(CLIENTS));
|
||||
|
||||
// @ts-expect-error Dev helper for debugging only.
|
||||
window.fotospielDemoAuth = api;
|
||||
// @ts-expect-error Dev helper for debugging only.
|
||||
globalThis.fotospielDemoAuth = api;
|
||||
}
|
||||
|
||||
function requestAuthorization(url: string): Promise<URL> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', url, true);
|
||||
xhr.withCredentials = true;
|
||||
xhr.onreadystatechange = () => {
|
||||
if (xhr.readyState !== XMLHttpRequest.DONE) {
|
||||
return;
|
||||
}
|
||||
|
||||
const responseUrl = xhr.responseURL || xhr.getResponseHeader('Location');
|
||||
if (xhr.status >= 200 && xhr.status < 400 && responseUrl) {
|
||||
resolve(new URL(responseUrl, window.location.origin));
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new Error(`Authorize failed with ${xhr.status}`));
|
||||
};
|
||||
xhr.onerror = () => reject(new Error('Authorize request failed'));
|
||||
xhr.send();
|
||||
});
|
||||
}
|
||||
|
||||
function verifyState(returnedState: string | null, expectedState: string): void {
|
||||
if (returnedState && returnedState !== expectedState) {
|
||||
throw new Error('Authorize state mismatch');
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
"welcome": {
|
||||
"fallbackName": "Tenant-Admin",
|
||||
"greeting": "Hallo {{name}}!",
|
||||
"subtitle": "Behalte deine Events, Credits und Aufgaben im Blick."
|
||||
"subtitle": "Behalte deine Events, Pakete und Aufgaben im Blick."
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "Dashboard konnte nicht geladen werden."
|
||||
@@ -27,6 +27,7 @@
|
||||
"description": "Wichtigste Kennzahlen deines Tenants auf einen Blick.",
|
||||
"noPackage": "Kein aktives Paket",
|
||||
"stats": {
|
||||
"activePackage": "Aktives Paket",
|
||||
"activeEvents": "Aktive Events",
|
||||
"publishedHint": "{{count}} veröffentlicht",
|
||||
"newPhotos": "Neue Fotos (7 Tage)",
|
||||
@@ -50,9 +51,81 @@
|
||||
"label": "Tasks organisieren",
|
||||
"description": "Sorge für klare Verantwortungen."
|
||||
},
|
||||
"manageCredits": {
|
||||
"label": "Credits verwalten",
|
||||
"description": "Sieh dir Balance & Ledger an."
|
||||
"managePackages": {
|
||||
"label": "Pakete verwalten",
|
||||
"description": "Aktives Paket und Historie einsehen."
|
||||
}
|
||||
},
|
||||
"upcoming": {
|
||||
"title": "Kommende Events",
|
||||
"description": "Die nächsten Termine inklusive Status & Zugriff.",
|
||||
"settings": "Einstellungen öffnen",
|
||||
"empty": {
|
||||
"message": "Noch keine Termine geplant. Lege dein erstes Event an!",
|
||||
"cta": "Event planen"
|
||||
},
|
||||
"status": {
|
||||
"live": "Live",
|
||||
"planning": "In Planung",
|
||||
"noDate": "Kein Datum"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"actions": {
|
||||
"newEvent": "Neues Event",
|
||||
"allEvents": "Alle Events",
|
||||
"guidedSetup": "Guided Setup"
|
||||
},
|
||||
"welcome": {
|
||||
"fallbackName": "Tenant-Admin",
|
||||
"greeting": "Hallo {{name}}!",
|
||||
"subtitle": "Behalte deine Events, Pakete und Aufgaben im Blick."
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "Dashboard konnte nicht geladen werden."
|
||||
},
|
||||
"alerts": {
|
||||
"errorTitle": "Fehler"
|
||||
},
|
||||
"welcomeCard": {
|
||||
"title": "Starte mit der Welcome Journey",
|
||||
"summary": "Lerne die Storytelling-Elemente kennen, wähle dein Paket und erstelle dein erstes Event mit geführten Schritten.",
|
||||
"body1": "Wir begleiten dich durch Pakete, Aufgaben und Galerie-Konfiguration, damit dein Event glänzt.",
|
||||
"body2": "Du kannst jederzeit zur Welcome Journey zurückkehren, auch wenn bereits Events laufen.",
|
||||
"cta": "Jetzt starten"
|
||||
},
|
||||
"overview": {
|
||||
"title": "Kurzer Überblick",
|
||||
"description": "Wichtigste Kennzahlen deines Tenants auf einen Blick.",
|
||||
"noPackage": "Kein aktives Paket",
|
||||
"stats": {
|
||||
"activePackage": "Aktives Paket",
|
||||
"activeEvents": "Aktive Events",
|
||||
"publishedHint": "{{count}} veröffentlicht",
|
||||
"newPhotos": "Neue Fotos (7 Tage)",
|
||||
"taskProgress": "Task-Fortschritt",
|
||||
"credits": "Credits",
|
||||
"lowCredits": "Auffüllen empfohlen"
|
||||
}
|
||||
},
|
||||
"quickActions": {
|
||||
"title": "Schnellaktionen",
|
||||
"description": "Starte durch mit den wichtigsten Aktionen.",
|
||||
"createEvent": {
|
||||
"label": "Event erstellen",
|
||||
"description": "Plane dein nächstes Highlight."
|
||||
},
|
||||
"moderatePhotos": {
|
||||
"label": "Fotos moderieren",
|
||||
"description": "Prüfe neue Uploads."
|
||||
},
|
||||
"organiseTasks": {
|
||||
"label": "Tasks organisieren",
|
||||
"description": "Sorge für klare Verantwortungen."
|
||||
},
|
||||
"managePackages": {
|
||||
"label": "Pakete verwalten",
|
||||
"description": "Aktives Paket und Historie einsehen."
|
||||
}
|
||||
},
|
||||
"upcoming": {
|
||||
@@ -70,3 +143,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
{
|
||||
"billing": {
|
||||
"title": "Billing und Credits",
|
||||
"subtitle": "Verwalte Guthaben, Pakete und Abrechnungen.",
|
||||
"title": "Pakete & Abrechnung",
|
||||
"subtitle": "Verwalte deine gebuchten Pakete und behalte Laufzeiten im Blick.",
|
||||
"actions": {
|
||||
"refresh": "Aktualisieren",
|
||||
"exportCsv": "Export als CSV"
|
||||
},
|
||||
"errors": {
|
||||
"load": "Billing-Daten konnten nicht geladen werden.",
|
||||
"more": "Weitere Ledger-Einträge konnten nicht geladen werden."
|
||||
"load": "Paketdaten konnten nicht geladen werden.",
|
||||
"more": "Weitere Einträge konnten nicht geladen werden."
|
||||
},
|
||||
"sections": {
|
||||
"overview": {
|
||||
"title": "Credits und Status",
|
||||
"description": "Dein aktuelles Guthaben und das aktive Reseller-Paket.",
|
||||
"title": "Paketübersicht",
|
||||
"description": "Dein aktives Paket und die wichtigsten Kennzahlen.",
|
||||
"empty": "Noch kein Paket aktiv.",
|
||||
"emptyBadge": "Kein aktives Paket",
|
||||
"cards": {
|
||||
"balance": {
|
||||
"label": "Verfügbare Credits"
|
||||
"package": {
|
||||
"label": "Aktives Paket",
|
||||
"helper": "Aktuell zugewiesen"
|
||||
},
|
||||
"used": {
|
||||
"label": "Genutzte Events",
|
||||
@@ -26,33 +29,34 @@
|
||||
"label": "Preis (netto)"
|
||||
},
|
||||
"expires": {
|
||||
"label": "Ablauf",
|
||||
"helper": "Automatisch verlängern, falls aktiv"
|
||||
"label": "Läuft ab",
|
||||
"helper": "Automatische Verlängerung, falls aktiv"
|
||||
}
|
||||
}
|
||||
},
|
||||
"packages": {
|
||||
"title": "Paket-Historie",
|
||||
"description": "Übersicht über aktive und vergangene Reseller-Pakete.",
|
||||
"description": "Übersicht über aktive und vergangene Pakete.",
|
||||
"empty": "Noch keine Pakete gebucht.",
|
||||
"card": {
|
||||
"statusActive": "Aktiv",
|
||||
"statusInactive": "Inaktiv",
|
||||
"used": "Genutzte Events",
|
||||
"available": "Verfügbar",
|
||||
"expires": "Ablauf"
|
||||
"expires": "Läuft ab"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ledger": {
|
||||
"title": "Credit Ledger",
|
||||
"description": "Alle Zu- und Abbuchungen deines Credits-Kontos.",
|
||||
"empty": "Noch keine Ledger-Einträge vorhanden.",
|
||||
"loadMore": "Mehr laden",
|
||||
"reasons": {
|
||||
"purchase": "Credit-Kauf",
|
||||
"usage": "Verbrauch",
|
||||
"manual": "Manuelle Anpassung"
|
||||
}
|
||||
"packages": {
|
||||
"title": "Paket-Historie",
|
||||
"description": "Übersicht über aktive und vergangene Pakete.",
|
||||
"empty": "Noch keine Pakete gebucht.",
|
||||
"card": {
|
||||
"statusActive": "Aktiv",
|
||||
"statusInactive": "Inaktiv",
|
||||
"used": "Genutzte Events",
|
||||
"available": "Verfügbar",
|
||||
"expires": "Läuft ab"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -241,5 +245,57 @@
|
||||
"submit": "Emotion speichern"
|
||||
}
|
||||
}
|
||||
,
|
||||
"management": {
|
||||
"billing": {
|
||||
"title": "Pakete & Abrechnung",
|
||||
"subtitle": "Verwalte deine gebuchten Pakete und behalte Laufzeiten im Blick.",
|
||||
"actions": {
|
||||
"refresh": "Aktualisieren",
|
||||
"exportCsv": "Export als CSV"
|
||||
},
|
||||
"errors": {
|
||||
"load": "Paketdaten konnten nicht geladen werden.",
|
||||
"more": "Weitere Einträge konnten nicht geladen werden."
|
||||
},
|
||||
"sections": {
|
||||
"overview": {
|
||||
"title": "Paketübersicht",
|
||||
"description": "Dein aktives Paket und die wichtigsten Kennzahlen.",
|
||||
"empty": "Noch kein Paket aktiv.",
|
||||
"emptyBadge": "Kein aktives Paket",
|
||||
"cards": {
|
||||
"package": {
|
||||
"label": "Aktives Paket",
|
||||
"helper": "Aktuell zugewiesen"
|
||||
},
|
||||
"used": {
|
||||
"label": "Genutzte Events",
|
||||
"helper": "Verfügbar: {{count}}"
|
||||
},
|
||||
"price": {
|
||||
"label": "Preis (netto)"
|
||||
},
|
||||
"expires": {
|
||||
"label": "Läuft ab",
|
||||
"helper": "Automatische Verlängerung, falls aktiv"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"packages": {
|
||||
"title": "Paket-Historie",
|
||||
"description": "Übersicht über aktuelle und vergangene Pakete.",
|
||||
"empty": "Noch keine Pakete gebucht.",
|
||||
"card": {
|
||||
"statusActive": "Aktiv",
|
||||
"statusInactive": "Inaktiv",
|
||||
"used": "Genutzte Events",
|
||||
"available": "Verfügbar",
|
||||
"expires": "Läuft ab"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"welcome": {
|
||||
"fallbackName": "Tenant Admin",
|
||||
"greeting": "Welcome, {{name}}!",
|
||||
"subtitle": "Keep your events, credits, and tasks on track."
|
||||
"subtitle": "Keep your events, packages, and tasks on track."
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "Dashboard could not be loaded."
|
||||
@@ -27,6 +27,7 @@
|
||||
"description": "Key tenant metrics at a glance.",
|
||||
"noPackage": "No active package",
|
||||
"stats": {
|
||||
"activePackage": "Active package",
|
||||
"activeEvents": "Active events",
|
||||
"publishedHint": "{{count}} published",
|
||||
"newPhotos": "New photos (7 days)",
|
||||
@@ -50,9 +51,81 @@
|
||||
"label": "Organise tasks",
|
||||
"description": "Assign clear responsibilities."
|
||||
},
|
||||
"manageCredits": {
|
||||
"label": "Manage credits",
|
||||
"description": "Review balance and ledger."
|
||||
"managePackages": {
|
||||
"label": "Manage packages",
|
||||
"description": "View your active package and history."
|
||||
}
|
||||
},
|
||||
"upcoming": {
|
||||
"title": "Upcoming events",
|
||||
"description": "The next dates including status and quick access.",
|
||||
"settings": "Open settings",
|
||||
"empty": {
|
||||
"message": "No events scheduled yet. Create your first one!",
|
||||
"cta": "Plan event"
|
||||
},
|
||||
"status": {
|
||||
"live": "Live",
|
||||
"planning": "In planning",
|
||||
"noDate": "No date"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"actions": {
|
||||
"newEvent": "New Event",
|
||||
"allEvents": "All events",
|
||||
"guidedSetup": "Guided setup"
|
||||
},
|
||||
"welcome": {
|
||||
"fallbackName": "Tenant Admin",
|
||||
"greeting": "Welcome, {{name}}!",
|
||||
"subtitle": "Keep your events, packages, and tasks on track."
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "Dashboard could not be loaded."
|
||||
},
|
||||
"alerts": {
|
||||
"errorTitle": "Error"
|
||||
},
|
||||
"welcomeCard": {
|
||||
"title": "Start with the welcome journey",
|
||||
"summary": "Discover the storytelling elements, choose your package, and create your first event with guided steps.",
|
||||
"body1": "We guide you through packages, tasks, and gallery setup so your event shines.",
|
||||
"body2": "You can return to the welcome journey at any time, even once events are live.",
|
||||
"cta": "Start now"
|
||||
},
|
||||
"overview": {
|
||||
"title": "At a glance",
|
||||
"description": "Key tenant metrics at a glance.",
|
||||
"noPackage": "No active package",
|
||||
"stats": {
|
||||
"activePackage": "Active package",
|
||||
"activeEvents": "Active events",
|
||||
"publishedHint": "{{count}} published",
|
||||
"newPhotos": "New photos (7 days)",
|
||||
"taskProgress": "Task progress",
|
||||
"credits": "Credits",
|
||||
"lowCredits": "Top up recommended"
|
||||
}
|
||||
},
|
||||
"quickActions": {
|
||||
"title": "Quick actions",
|
||||
"description": "Jump straight to the most important actions.",
|
||||
"createEvent": {
|
||||
"label": "Create event",
|
||||
"description": "Plan your next highlight."
|
||||
},
|
||||
"moderatePhotos": {
|
||||
"label": "Moderate photos",
|
||||
"description": "Review new uploads."
|
||||
},
|
||||
"organiseTasks": {
|
||||
"label": "Organise tasks",
|
||||
"description": "Assign clear responsibilities."
|
||||
},
|
||||
"managePackages": {
|
||||
"label": "Manage packages",
|
||||
"description": "View your active package and history."
|
||||
}
|
||||
},
|
||||
"upcoming": {
|
||||
@@ -70,3 +143,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
{
|
||||
"billing": {
|
||||
"title": "Billing & credits",
|
||||
"subtitle": "Manage balances, packages, and invoicing.",
|
||||
"title": "Packages & billing",
|
||||
"subtitle": "Manage your purchased packages and track their durations.",
|
||||
"actions": {
|
||||
"refresh": "Refresh",
|
||||
"exportCsv": "Export CSV"
|
||||
},
|
||||
"errors": {
|
||||
"load": "Unable to load billing data.",
|
||||
"more": "Unable to load more ledger entries."
|
||||
"load": "Unable to load package data.",
|
||||
"more": "Unable to load more entries."
|
||||
},
|
||||
"sections": {
|
||||
"overview": {
|
||||
"title": "Credits & status",
|
||||
"description": "Your current balance and active reseller package.",
|
||||
"title": "Package overview",
|
||||
"description": "Your active package and the most important metrics.",
|
||||
"empty": "No active package yet.",
|
||||
"emptyBadge": "No active package",
|
||||
"cards": {
|
||||
"balance": {
|
||||
"label": "Available credits"
|
||||
"package": {
|
||||
"label": "Active package",
|
||||
"helper": "Currently assigned"
|
||||
},
|
||||
"used": {
|
||||
"label": "Events used",
|
||||
@@ -27,13 +30,13 @@
|
||||
},
|
||||
"expires": {
|
||||
"label": "Expires",
|
||||
"helper": "Auto-renews when active"
|
||||
"helper": "Auto-renews if enabled"
|
||||
}
|
||||
}
|
||||
},
|
||||
"packages": {
|
||||
"title": "Package history",
|
||||
"description": "Overview of active and past reseller packages.",
|
||||
"description": "Overview of active and past packages.",
|
||||
"empty": "No packages purchased yet.",
|
||||
"card": {
|
||||
"statusActive": "Active",
|
||||
@@ -42,17 +45,18 @@
|
||||
"available": "Remaining",
|
||||
"expires": "Expires"
|
||||
}
|
||||
},
|
||||
"ledger": {
|
||||
"title": "Credit ledger",
|
||||
"description": "All credit additions and deductions.",
|
||||
"empty": "No ledger entries recorded yet.",
|
||||
"loadMore": "Load more",
|
||||
"reasons": {
|
||||
"purchase": "Credit purchase",
|
||||
"usage": "Usage",
|
||||
"manual": "Manual adjustment"
|
||||
}
|
||||
},
|
||||
"packages": {
|
||||
"title": "Package history",
|
||||
"description": "Overview of current and past packages.",
|
||||
"empty": "No packages purchased yet.",
|
||||
"card": {
|
||||
"statusActive": "Active",
|
||||
"statusInactive": "Inactive",
|
||||
"used": "Used events",
|
||||
"available": "Available",
|
||||
"expires": "Expires"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -241,4 +245,56 @@
|
||||
"submit": "Save emotion"
|
||||
}
|
||||
}
|
||||
,
|
||||
"management": {
|
||||
"billing": {
|
||||
"title": "Packages & billing",
|
||||
"subtitle": "Manage your purchased packages and track their durations.",
|
||||
"actions": {
|
||||
"refresh": "Refresh",
|
||||
"exportCsv": "Export CSV"
|
||||
},
|
||||
"errors": {
|
||||
"load": "Unable to load package data.",
|
||||
"more": "Unable to load more entries."
|
||||
},
|
||||
"sections": {
|
||||
"overview": {
|
||||
"title": "Package overview",
|
||||
"description": "Your active package and the most important metrics.",
|
||||
"empty": "No active package yet.",
|
||||
"emptyBadge": "No active package",
|
||||
"cards": {
|
||||
"package": {
|
||||
"label": "Active package",
|
||||
"helper": "Currently assigned"
|
||||
},
|
||||
"used": {
|
||||
"label": "Events used",
|
||||
"helper": "Remaining: {{count}}"
|
||||
},
|
||||
"price": {
|
||||
"label": "Price (net)"
|
||||
},
|
||||
"expires": {
|
||||
"label": "Expires",
|
||||
"helper": "Auto-renews if enabled"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"packages": {
|
||||
"title": "Package history",
|
||||
"description": "Overview of current and past packages.",
|
||||
"empty": "No packages purchased yet.",
|
||||
"card": {
|
||||
"statusActive": "Active",
|
||||
"statusInactive": "Inactive",
|
||||
"used": "Used events",
|
||||
"available": "Available",
|
||||
"expires": "Expires"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,12 @@ import { AuthProvider } from './auth/context';
|
||||
import { router } from './router';
|
||||
import '../../css/app.css';
|
||||
import './i18n';
|
||||
import './dev-tools';
|
||||
import { initializeTheme } from '@/hooks/use-appearance';
|
||||
import { OnboardingProgressProvider } from './onboarding';
|
||||
import { DevTenantSwitcher } from './components/DevTenantSwitcher';
|
||||
|
||||
const enableDevSwitcher = import.meta.env.DEV || import.meta.env.VITE_ENABLE_TENANT_SWITCHER === 'true';
|
||||
|
||||
initializeTheme();
|
||||
const rootEl = document.getElementById('root')!;
|
||||
@@ -27,6 +31,7 @@ createRoot(rootEl).render(
|
||||
<RouterProvider router={router} />
|
||||
</OnboardingProgressProvider>
|
||||
</AuthProvider>
|
||||
{enableDevSwitcher ? <DevTenantSwitcher /> : null}
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { Elements, PaymentElement, useElements, useStripe } from "@stripe/react-stripe-js";
|
||||
import { loadStripe } from "@stripe/stripe-js";
|
||||
import { PayPalScriptProvider, PayPalButtons } from "@paypal/react-paypal-js";
|
||||
|
||||
import {
|
||||
@@ -32,6 +31,7 @@ import {
|
||||
createTenantPayPalOrder,
|
||||
captureTenantPayPalOrder,
|
||||
} from "../../api";
|
||||
import { getStripe } from '@/utils/stripe';
|
||||
|
||||
const stripePublishableKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY ?? "";
|
||||
const paypalClientId = import.meta.env.VITE_PAYPAL_CLIENT_ID ?? "";
|
||||
@@ -267,10 +267,7 @@ export default function WelcomeOrderSummaryPage() {
|
||||
const { t, i18n } = useTranslation("onboarding");
|
||||
const locale = i18n.language?.startsWith("en") ? "en-GB" : "de-DE";
|
||||
const { currencyFormatter, dateFormatter } = useLocaleFormats(locale);
|
||||
const stripePromise = React.useMemo(
|
||||
() => (stripePublishableKey ? loadStripe(stripePublishableKey) : null),
|
||||
[stripePublishableKey]
|
||||
);
|
||||
const stripePromise = React.useMemo(() => getStripe(stripePublishableKey), [stripePublishableKey]);
|
||||
|
||||
const packageIdFromState = typeof location.state === "object" ? (location.state as any)?.packageId : undefined;
|
||||
const selectedPackageId = progress.selectedPackage?.id ?? packageIdFromState ?? null;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { CreditCard, Download, Loader2, RefreshCw, Sparkles } from 'lucide-react';
|
||||
import { Loader2, RefreshCw, Sparkles } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
@@ -9,21 +9,9 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
|
||||
import { AdminLayout } from '../components/AdminLayout';
|
||||
import {
|
||||
CreditLedgerEntry,
|
||||
getCreditBalance,
|
||||
getCreditLedger,
|
||||
getTenantPackagesOverview,
|
||||
PaginationMeta,
|
||||
TenantPackageSummary,
|
||||
} from '../api';
|
||||
import { getTenantPackagesOverview, TenantPackageSummary } from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
|
||||
type LedgerState = {
|
||||
entries: CreditLedgerEntry[];
|
||||
meta: PaginationMeta | null;
|
||||
};
|
||||
|
||||
export default function BillingPage() {
|
||||
const { t, i18n } = useTranslation(['management', 'dashboard']);
|
||||
const locale = React.useMemo(
|
||||
@@ -31,13 +19,10 @@ export default function BillingPage() {
|
||||
[i18n.language]
|
||||
);
|
||||
|
||||
const [balance, setBalance] = React.useState<number>(0);
|
||||
const [packages, setPackages] = React.useState<TenantPackageSummary[]>([]);
|
||||
const [activePackage, setActivePackage] = React.useState<TenantPackageSummary | null>(null);
|
||||
const [ledger, setLedger] = React.useState<LedgerState>({ entries: [], meta: null });
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [loadingMore, setLoadingMore] = React.useState(false);
|
||||
|
||||
const formatDate = React.useCallback(
|
||||
(value: string | null | undefined) => {
|
||||
@@ -57,111 +42,53 @@ export default function BillingPage() {
|
||||
[locale]
|
||||
);
|
||||
|
||||
const resolveReason = React.useCallback(
|
||||
(reason: string) => {
|
||||
switch (reason) {
|
||||
case 'purchase':
|
||||
return t('management.billing.ledger.reasons.purchase', 'Credit Kauf');
|
||||
case 'usage':
|
||||
return t('management.billing.ledger.reasons.usage', 'Verbrauch');
|
||||
case 'manual':
|
||||
return t('management.billing.ledger.reasons.manual', 'Manuelle Anpassung');
|
||||
default:
|
||||
return reason;
|
||||
}
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
const packageLabels = React.useMemo(
|
||||
() => ({
|
||||
statusActive: t('management.billing.packages.card.statusActive', 'Aktiv'),
|
||||
statusInactive: t('management.billing.packages.card.statusInactive', 'Inaktiv'),
|
||||
used: t('management.billing.packages.card.used', 'Genutzte Events'),
|
||||
available: t('management.billing.packages.card.available', 'Verfügbar'),
|
||||
expires: t('management.billing.packages.card.expires', 'Ablauf'),
|
||||
statusActive: t('billing.sections.packages.card.statusActive'),
|
||||
statusInactive: t('billing.sections.packages.card.statusInactive'),
|
||||
used: t('billing.sections.packages.card.used'),
|
||||
available: t('billing.sections.packages.card.available'),
|
||||
expires: t('billing.sections.packages.card.expires'),
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
void loadAll();
|
||||
}, []);
|
||||
|
||||
async function loadAll() {
|
||||
const loadAll = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [balanceResult, packagesResult, ledgerResult] = await Promise.all([
|
||||
safeCall(() => getCreditBalance()),
|
||||
safeCall(() => getTenantPackagesOverview()),
|
||||
safeCall(() => getCreditLedger(1)),
|
||||
]);
|
||||
|
||||
if (balanceResult?.balance !== undefined) {
|
||||
setBalance(balanceResult.balance);
|
||||
}
|
||||
|
||||
if (packagesResult) {
|
||||
const packagesResult = await getTenantPackagesOverview();
|
||||
setPackages(packagesResult.packages);
|
||||
setActivePackage(packagesResult.activePackage);
|
||||
}
|
||||
|
||||
if (ledgerResult) {
|
||||
setLedger({ entries: ledgerResult.data, meta: ledgerResult.meta });
|
||||
} else {
|
||||
setLedger({ entries: [], meta: null });
|
||||
}
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError(t('management.billing.errors.load', 'Billing Daten konnten nicht geladen werden.'));
|
||||
setError(t('billing.errors.load'));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
async function loadMore() {
|
||||
if (!ledger.meta || loadingMore) {
|
||||
return;
|
||||
}
|
||||
const { current_page, last_page } = ledger.meta;
|
||||
if (current_page >= last_page) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingMore(true);
|
||||
try {
|
||||
const next = await getCreditLedger(current_page + 1);
|
||||
setLedger({
|
||||
entries: [...ledger.entries, ...next.data],
|
||||
meta: next.meta,
|
||||
});
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError(t('management.billing.errors.more', 'Weitere Ledger Eintraege konnten nicht geladen werden.'));
|
||||
}
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}
|
||||
React.useEffect(() => {
|
||||
void loadAll();
|
||||
}, [loadAll]);
|
||||
|
||||
const actions = (
|
||||
<Button variant="outline" onClick={() => void loadAll()} disabled={loading}>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
||||
{t('management.billing.actions.refresh', 'Aktualisieren')}
|
||||
{t('billing.actions.refresh')}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<AdminLayout
|
||||
title={t('management.billing.title', 'Billing und Credits')}
|
||||
subtitle={t('management.billing.subtitle', 'Verwalte Guthaben, Pakete und Abrechnungen.')}
|
||||
title={t('billing.title')}
|
||||
subtitle={t('billing.subtitle')}
|
||||
actions={actions}
|
||||
>
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t('dashboard.alerts.errorTitle', 'Fehler')}</AlertTitle>
|
||||
<AlertTitle>{t('dashboard:alerts.errorTitle')}</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
@@ -174,43 +101,50 @@ export default function BillingPage() {
|
||||
<CardHeader className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
||||
<CreditCard className="h-5 w-5 text-pink-500" />
|
||||
{t('management.billing.sections.overview.title', 'Credits und Status')}
|
||||
<Sparkles className="h-5 w-5 text-pink-500" />
|
||||
{t('billing.sections.overview.title')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
{t('management.billing.sections.overview.description', 'Dein aktuelles Guthaben und das aktive Reseller Paket.')}
|
||||
{t('billing.sections.overview.description')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge className={activePackage ? 'bg-pink-500/10 text-pink-700' : 'bg-slate-200 text-slate-700'}>
|
||||
{activePackage ? activePackage.package_name : 'Kein aktives Paket'}
|
||||
{activePackage ? activePackage.package_name : t('billing.sections.overview.emptyBadge')}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<CardContent>
|
||||
{activePackage ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<InfoCard
|
||||
label={t('management.billing.sections.overview.cards.balance.label', 'Verfügbare Credits')}
|
||||
value={balance}
|
||||
label={t('billing.sections.overview.cards.package.label')}
|
||||
value={activePackage.package_name}
|
||||
tone="pink"
|
||||
helper={t('billing.sections.overview.cards.package.helper')}
|
||||
/>
|
||||
<InfoCard
|
||||
label={t('management.billing.sections.overview.cards.used.label', 'Genutzte Events')}
|
||||
value={activePackage?.used_events ?? 0}
|
||||
label={t('billing.sections.overview.cards.used.label')}
|
||||
value={activePackage.used_events ?? 0}
|
||||
tone="amber"
|
||||
helper={t('management.billing.sections.overview.cards.used.helper', {
|
||||
count: activePackage?.remaining_events ?? 0,
|
||||
helper={t('billing.sections.overview.cards.used.helper', {
|
||||
count: activePackage.remaining_events ?? 0,
|
||||
})}
|
||||
/>
|
||||
<InfoCard
|
||||
label={t('management.billing.sections.overview.cards.price.label', 'Preis (netto)')}
|
||||
value={formatCurrency(activePackage?.price ?? null, activePackage?.currency ?? 'EUR')}
|
||||
label={t('billing.sections.overview.cards.price.label')}
|
||||
value={formatCurrency(activePackage.price ?? null, activePackage.currency ?? 'EUR')}
|
||||
tone="sky"
|
||||
helper={activePackage?.currency ?? 'EUR'}
|
||||
helper={activePackage.currency ?? 'EUR'}
|
||||
/>
|
||||
<InfoCard
|
||||
label={t('management.billing.sections.overview.cards.expires.label', 'Ablauf')}
|
||||
value={formatDate(activePackage?.expires_at)}
|
||||
label={t('billing.sections.overview.cards.expires.label')}
|
||||
value={formatDate(activePackage.expires_at)}
|
||||
tone="emerald"
|
||||
helper={t('management.billing.sections.overview.cards.expires.helper', 'Automatisch verlängern, falls aktiv')}
|
||||
helper={t('billing.sections.overview.cards.expires.helper')}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState message={t('billing.sections.overview.empty')} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -218,15 +152,15 @@ export default function BillingPage() {
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
||||
<Sparkles className="h-5 w-5 text-amber-500" />
|
||||
{t('management.billing.packages.title', 'Paket Historie')}
|
||||
{t('billing.sections.packages.title')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
{t('management.billing.packages.description', 'Übersicht über aktive und vergangene Reseller Pakete.')}
|
||||
{t('billing.sections.packages.description')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{packages.length === 0 ? (
|
||||
<EmptyState message={t('management.billing.packages.empty', 'Noch keine Pakete gebucht.')} />
|
||||
<EmptyState message={t('billing.sections.packages.empty')} />
|
||||
) : (
|
||||
packages.map((pkg) => (
|
||||
<PackageCard
|
||||
@@ -242,61 +176,11 @@ export default function BillingPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-0 bg-white/85 shadow-xl shadow-sky-100/60">
|
||||
<CardHeader className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
||||
<Sparkles className="h-5 w-5 text-sky-500" />
|
||||
{t('management.billing.ledger.title', 'Credit Ledger')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
{t('management.billing.ledger.description', 'Alle Zu- und Abbuchungen deines Credits-Kontos.')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button variant="outline" size="sm">
|
||||
<Download className="h-4 w-4" />
|
||||
{t('management.billing.actions.exportCsv', 'Export als CSV')}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{ledger.entries.length === 0 ? (
|
||||
<EmptyState message={t('management.billing.ledger.empty', 'Noch keine Ledger-Einträge vorhanden.')} />
|
||||
) : (
|
||||
<>
|
||||
{ledger.entries.map((entry) => (
|
||||
<LedgerRow
|
||||
key={`${entry.id}-${entry.created_at}`}
|
||||
entry={entry}
|
||||
resolveReason={resolveReason}
|
||||
formatDate={formatDate}
|
||||
/>
|
||||
))}
|
||||
{ledger.meta && ledger.meta.current_page < ledger.meta.last_page && (
|
||||
<Button variant="outline" className="w-full" onClick={() => void loadMore()} disabled={loadingMore}>
|
||||
{loadingMore ? <Loader2 className="h-4 w-4 animate-spin" /> : t('management.billing.ledger.loadMore', 'Mehr laden')}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
async function safeCall<T>(callback: () => Promise<T>): Promise<T | null> {
|
||||
try {
|
||||
return await callback();
|
||||
} catch (error) {
|
||||
if (!isAuthError(error)) {
|
||||
console.warn('[Tenant Billing] optional endpoint fehlgeschlagen', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function InfoCard({
|
||||
label,
|
||||
value,
|
||||
@@ -372,33 +256,6 @@ function PackageCard({
|
||||
);
|
||||
}
|
||||
|
||||
function LedgerRow({
|
||||
entry,
|
||||
resolveReason,
|
||||
formatDate,
|
||||
}: {
|
||||
entry: CreditLedgerEntry;
|
||||
resolveReason: (reason: string) => string;
|
||||
formatDate: (value: string | null | undefined) => string;
|
||||
}) {
|
||||
const positive = entry.delta >= 0;
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded-2xl border border-slate-100 bg-white/90 p-4 shadow-sm sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-900">{resolveReason(entry.reason)}</p>
|
||||
{entry.note && <p className="text-xs text-slate-500">{entry.note}</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className={`text-sm font-semibold ${positive ? 'text-emerald-600' : 'text-rose-600'}`}>
|
||||
{positive ? '+' : ''}
|
||||
{entry.delta}
|
||||
</span>
|
||||
<span className="text-xs text-slate-500">{formatDate(entry.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-slate-200 bg-white/70 p-8 text-center">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CalendarDays, Camera, CreditCard, Sparkles, Users, Plus, Settings } from 'lucide-react';
|
||||
import { CalendarDays, Camera, Sparkles, Users, Plus, Settings } from 'lucide-react';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -11,7 +11,6 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
||||
import { AdminLayout } from '../components/AdminLayout';
|
||||
import {
|
||||
DashboardSummary,
|
||||
getCreditBalance,
|
||||
getDashboardSummary,
|
||||
getEvents,
|
||||
getTenantPackagesOverview,
|
||||
@@ -35,7 +34,6 @@ import { useOnboardingProgress } from '../onboarding';
|
||||
interface DashboardState {
|
||||
summary: DashboardSummary | null;
|
||||
events: TenantEvent[];
|
||||
credits: number;
|
||||
activePackage: TenantPackageSummary | null;
|
||||
loading: boolean;
|
||||
errorKey: string | null;
|
||||
@@ -46,11 +44,23 @@ export default function DashboardPage() {
|
||||
const location = useLocation();
|
||||
const { user } = useAuth();
|
||||
const { progress, markStep } = useOnboardingProgress();
|
||||
const { t, i18n } = useTranslation(['dashboard', 'common']);
|
||||
const { t, i18n } = useTranslation('dashboard', { keyPrefix: 'dashboard' });
|
||||
const { t: tc } = useTranslation('common');
|
||||
|
||||
const translate = React.useCallback(
|
||||
(key: string, options?: Record<string, unknown>) => {
|
||||
const value = t(key, options);
|
||||
if (value === `dashboard.${key}`) {
|
||||
const fallback = i18n.t(`dashboard:${key}`, options);
|
||||
return fallback === `dashboard:${key}` ? value : fallback;
|
||||
}
|
||||
return value;
|
||||
},
|
||||
[t, i18n],
|
||||
);
|
||||
const [state, setState] = React.useState<DashboardState>({
|
||||
summary: null,
|
||||
events: [],
|
||||
credits: 0,
|
||||
activePackage: null,
|
||||
loading: true,
|
||||
errorKey: null,
|
||||
@@ -60,10 +70,9 @@ export default function DashboardPage() {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const [summary, events, credits, packages] = await Promise.all([
|
||||
const [summary, events, packages] = await Promise.all([
|
||||
getDashboardSummary().catch(() => null),
|
||||
getEvents().catch(() => [] as TenantEvent[]),
|
||||
getCreditBalance().catch(() => ({ balance: 0 })),
|
||||
getTenantPackagesOverview().catch(() => ({ packages: [], activePackage: null })),
|
||||
]);
|
||||
|
||||
@@ -71,12 +80,11 @@ export default function DashboardPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
const fallbackSummary = buildSummaryFallback(events, credits.balance ?? 0, packages.activePackage);
|
||||
const fallbackSummary = buildSummaryFallback(events, packages.activePackage);
|
||||
|
||||
setState({
|
||||
summary: summary ?? fallbackSummary,
|
||||
events,
|
||||
credits: credits.balance ?? 0,
|
||||
activePackage: packages.activePackage,
|
||||
loading: false,
|
||||
errorKey: null,
|
||||
@@ -97,7 +105,7 @@ export default function DashboardPage() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const { summary, events, credits, activePackage, loading, errorKey } = state;
|
||||
const { summary, events, activePackage, loading, errorKey } = state;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (loading) {
|
||||
@@ -112,10 +120,10 @@ export default function DashboardPage() {
|
||||
}
|
||||
}, [loading, events.length, progress.eventCreated, navigate, location.pathname, markStep]);
|
||||
|
||||
const greetingName = user?.name ?? t('dashboard.welcome.fallbackName');
|
||||
const greetingTitle = t('dashboard.welcome.greeting', { name: greetingName });
|
||||
const subtitle = t('dashboard.welcome.subtitle');
|
||||
const errorMessage = errorKey ? t(`dashboard.errors.${errorKey}`) : null;
|
||||
const greetingName = user?.name ?? translate('welcome.fallbackName');
|
||||
const greetingTitle = translate('welcome.greeting', { name: greetingName });
|
||||
const subtitle = translate('welcome.subtitle');
|
||||
const errorMessage = errorKey ? translate(`errors.${errorKey}`) : null;
|
||||
const dateLocale = i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE';
|
||||
|
||||
const upcomingEvents = getUpcomingEvents(events);
|
||||
@@ -127,10 +135,10 @@ export default function DashboardPage() {
|
||||
className="bg-brand-rose text-white shadow-lg shadow-rose-400/40 hover:bg-[var(--brand-rose-strong)]"
|
||||
onClick={() => navigate(ADMIN_EVENT_CREATE_PATH)}
|
||||
>
|
||||
<Plus className="h-4 w-4" /> {t('dashboard.actions.newEvent')}
|
||||
<Plus className="h-4 w-4" /> {translate('actions.newEvent')}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => navigate(ADMIN_EVENTS_PATH)} className="border-brand-rose-soft text-brand-rose">
|
||||
<CalendarDays className="h-4 w-4" /> {t('dashboard.actions.allEvents')}
|
||||
<CalendarDays className="h-4 w-4" /> {translate('actions.allEvents')}
|
||||
</Button>
|
||||
{events.length === 0 && (
|
||||
<Button
|
||||
@@ -138,7 +146,7 @@ export default function DashboardPage() {
|
||||
onClick={() => navigate(ADMIN_WELCOME_BASE_PATH)}
|
||||
className="border-brand-rose-soft text-brand-rose hover:bg-brand-rose-soft/40"
|
||||
>
|
||||
<Sparkles className="h-4 w-4" /> {t('dashboard.actions.guidedSetup')}
|
||||
<Sparkles className="h-4 w-4" /> {translate('actions.guidedSetup')}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
@@ -162,24 +170,23 @@ export default function DashboardPage() {
|
||||
<CardHeader className="space-y-3">
|
||||
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
||||
<Sparkles className="h-5 w-5 text-brand-rose" />
|
||||
{t('dashboard.welcomeCard.title')}
|
||||
{translate('welcomeCard.title')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
Lerne die Storytelling-Elemente kennen, wähle dein Paket und erstelle dein erstes Event mit
|
||||
geführten Schritten.
|
||||
{translate('welcomeCard.summary')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="space-y-2 text-sm text-slate-600">
|
||||
<p>{t('dashboard.welcomeCard.body1')}</p>
|
||||
<p>{t('dashboard.welcomeCard.body2')}</p>
|
||||
<p>{translate('welcomeCard.body1')}</p>
|
||||
<p>{translate('welcomeCard.body2')}</p>
|
||||
</div>
|
||||
<Button
|
||||
size="lg"
|
||||
className="rounded-full bg-rose-500 text-white shadow-lg shadow-rose-400/40 hover:bg-rose-500/90"
|
||||
onClick={() => navigate(ADMIN_WELCOME_BASE_PATH)}
|
||||
>
|
||||
{t('dashboard.welcomeCard.cta')}
|
||||
{translate('welcomeCard.cta')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -190,74 +197,75 @@ export default function DashboardPage() {
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
||||
<Sparkles className="h-5 w-5 text-brand-rose" />
|
||||
{t('dashboard.overview.title')}
|
||||
{translate('overview.title')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
{t('dashboard.overview.description')}
|
||||
{translate('overview.description')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge className="bg-brand-rose-soft text-brand-rose">
|
||||
{activePackage?.package_name ?? t('dashboard.overview.noPackage')}
|
||||
{activePackage?.package_name ?? translate('overview.noPackage')}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<CardContent className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<StatCard
|
||||
label={t('dashboard.overview.stats.activeEvents')}
|
||||
label={translate('overview.stats.activeEvents')}
|
||||
value={summary?.active_events ?? publishedEvents.length}
|
||||
hint={t('dashboard.overview.stats.publishedHint', { count: publishedEvents.length })}
|
||||
hint={translate('overview.stats.publishedHint', { count: publishedEvents.length })}
|
||||
icon={<CalendarDays className="h-5 w-5 text-brand-rose" />}
|
||||
/>
|
||||
<StatCard
|
||||
label={t('dashboard.overview.stats.newPhotos')}
|
||||
label={translate('overview.stats.newPhotos')}
|
||||
value={summary?.new_photos ?? 0}
|
||||
icon={<Camera className="h-5 w-5 text-fuchsia-500" />}
|
||||
/>
|
||||
<StatCard
|
||||
label={t('dashboard.overview.stats.taskProgress')}
|
||||
label={translate('overview.stats.taskProgress')}
|
||||
value={`${Math.round(summary?.task_progress ?? 0)}%`}
|
||||
icon={<Users className="h-5 w-5 text-amber-500" />}
|
||||
/>
|
||||
{activePackage ? (
|
||||
<StatCard
|
||||
label={t('dashboard.overview.stats.credits')}
|
||||
value={credits}
|
||||
hint={credits <= 1 ? t('dashboard.overview.stats.lowCredits') : undefined}
|
||||
icon={<CreditCard className="h-5 w-5 text-sky-500" />}
|
||||
label={translate('overview.stats.activePackage')}
|
||||
value={activePackage.package_name}
|
||||
icon={<Sparkles className="h-5 w-5 text-sky-500" />}
|
||||
/>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-0 bg-brand-card shadow-brand-primary">
|
||||
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-xl text-slate-900">{t('dashboard.quickActions.title')}</CardTitle>
|
||||
<CardTitle className="text-xl text-slate-900">{translate('quickActions.title')}</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
{t('dashboard.quickActions.description')}
|
||||
{translate('quickActions.description')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<QuickAction
|
||||
icon={<Plus className="h-5 w-5" />}
|
||||
label={t('dashboard.quickActions.createEvent.label')}
|
||||
description={t('dashboard.quickActions.createEvent.description')}
|
||||
label={translate('quickActions.createEvent.label')}
|
||||
description={translate('quickActions.createEvent.description')}
|
||||
onClick={() => navigate(ADMIN_EVENT_CREATE_PATH)}
|
||||
/>
|
||||
<QuickAction
|
||||
icon={<Camera className="h-5 w-5" />}
|
||||
label={t('dashboard.quickActions.moderatePhotos.label')}
|
||||
description={t('dashboard.quickActions.moderatePhotos.description')}
|
||||
label={translate('quickActions.moderatePhotos.label')}
|
||||
description={translate('quickActions.moderatePhotos.description')}
|
||||
onClick={() => navigate(ADMIN_EVENTS_PATH)}
|
||||
/>
|
||||
<QuickAction
|
||||
icon={<Users className="h-5 w-5" />}
|
||||
label={t('dashboard.quickActions.organiseTasks.label')}
|
||||
description={t('dashboard.quickActions.organiseTasks.description')}
|
||||
label={translate('quickActions.organiseTasks.label')}
|
||||
description={translate('quickActions.organiseTasks.description')}
|
||||
onClick={() => navigate(ADMIN_TASKS_PATH)}
|
||||
/>
|
||||
<QuickAction
|
||||
icon={<CreditCard className="h-5 w-5" />}
|
||||
label={t('dashboard.quickActions.manageCredits.label')}
|
||||
description={t('dashboard.quickActions.manageCredits.description')}
|
||||
icon={<Sparkles className="h-5 w-5" />}
|
||||
label={translate('quickActions.managePackages.label')}
|
||||
description={translate('quickActions.managePackages.description')}
|
||||
onClick={() => navigate(ADMIN_BILLING_PATH)}
|
||||
/>
|
||||
</CardContent>
|
||||
@@ -266,21 +274,21 @@ export default function DashboardPage() {
|
||||
<Card className="border-0 bg-brand-card shadow-brand-primary">
|
||||
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-xl text-slate-900">{t('dashboard.upcoming.title')}</CardTitle>
|
||||
<CardTitle className="text-xl text-slate-900">{translate('upcoming.title')}</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
{t('dashboard.upcoming.description')}
|
||||
{translate('upcoming.description')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => navigate(ADMIN_SETTINGS_PATH)}>
|
||||
<Settings className="h-4 w-4" />
|
||||
{t('dashboard.upcoming.settings')}
|
||||
{translate('upcoming.settings')}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{upcomingEvents.length === 0 ? (
|
||||
<EmptyState
|
||||
message={t('dashboard.upcoming.empty.message')}
|
||||
ctaLabel={t('dashboard.upcoming.empty.cta')}
|
||||
message={translate('upcoming.empty.message')}
|
||||
ctaLabel={translate('upcoming.empty.cta')}
|
||||
onCta={() => navigate(adminPath('/events/new'))}
|
||||
/>
|
||||
) : (
|
||||
@@ -291,10 +299,10 @@ export default function DashboardPage() {
|
||||
onView={() => navigate(ADMIN_EVENT_VIEW_PATH(event.slug))}
|
||||
locale={dateLocale}
|
||||
labels={{
|
||||
live: t('dashboard.upcoming.status.live'),
|
||||
planning: t('dashboard.upcoming.status.planning'),
|
||||
open: t('common:actions.open'),
|
||||
noDate: t('dashboard.upcoming.status.noDate'),
|
||||
live: translate('upcoming.status.live'),
|
||||
planning: translate('upcoming.status.planning'),
|
||||
open: tc('actions.open'),
|
||||
noDate: translate('upcoming.status.noDate'),
|
||||
}}
|
||||
/>
|
||||
))
|
||||
@@ -309,7 +317,6 @@ export default function DashboardPage() {
|
||||
|
||||
function buildSummaryFallback(
|
||||
events: TenantEvent[],
|
||||
balance: number,
|
||||
activePackage: TenantPackageSummary | null
|
||||
): DashboardSummary {
|
||||
const activeEvents = events.filter((event) => Boolean(event.is_active || event.status === 'published'));
|
||||
@@ -319,7 +326,6 @@ function buildSummaryFallback(
|
||||
active_events: activeEvents.length,
|
||||
new_photos: totalPhotos,
|
||||
task_progress: 0,
|
||||
credit_balance: balance,
|
||||
upcoming_events: activeEvents.length,
|
||||
active_package: activePackage
|
||||
? {
|
||||
@@ -471,10 +477,3 @@ function DashboardSkeleton() {
|
||||
);
|
||||
}
|
||||
|
||||
function renderName(name: TenantEvent['name']): string {
|
||||
if (typeof name === 'string') {
|
||||
return name;
|
||||
}
|
||||
return name?.de ?? name?.en ?? Object.values(name ?? {})[0] ?? 'Unbenanntes Event';
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { ArrowLeft, Camera, Download, Heart, Loader2, RefreshCw, Share2, Sparkles } from 'lucide-react';
|
||||
import { ArrowLeft, Camera, Copy, Download, Heart, Loader2, RefreshCw, Share2, Sparkles } from 'lucide-react';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -10,6 +10,7 @@ import { AdminLayout } from '../components/AdminLayout';
|
||||
import {
|
||||
createInviteLink,
|
||||
EventJoinToken,
|
||||
EventJoinTokenLayout,
|
||||
EventStats as TenantEventStats,
|
||||
getEvent,
|
||||
getEventJoinTokens,
|
||||
@@ -151,7 +152,7 @@ export default function EventDetailPage() {
|
||||
}));
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setState((prev) => ({ ...prev, error: 'Token konnte nicht deaktiviert werden.' }));
|
||||
setState((prev) => ({ ...prev, error: 'Einladung konnte nicht deaktiviert werden.' }));
|
||||
}
|
||||
} finally {
|
||||
setRevokingId(null);
|
||||
@@ -263,22 +264,22 @@ export default function EventDetailPage() {
|
||||
<Card id="join-invites" className="border-0 bg-white/90 shadow-xl shadow-amber-100/60">
|
||||
<CardHeader className="space-y-2">
|
||||
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
||||
<Share2 className="h-5 w-5 text-amber-500" /> Einladungen & Drucklayouts
|
||||
<Share2 className="h-5 w-5 text-amber-500" /> Einladungslinks & QR-Layouts
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
Verwalte Join-Tokens fuer dein Event. Jede Einladung enthaelt einen eindeutigen Token, QR-Code und
|
||||
downloadbare PDF/SVG-Layouts.
|
||||
Teile Gaesteeinladungen als Link oder drucke sie als fertige Layouts mit QR-Code - ganz ohne technisches
|
||||
Vokabular.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-sm text-slate-700">
|
||||
<div className="space-y-2 rounded-xl border border-amber-100 bg-amber-50/70 p-3 text-xs text-amber-800">
|
||||
<p>
|
||||
Teile den generierten Link oder drucke die Layouts aus, um Gaeste sicher ins Event zu leiten. Tokens lassen
|
||||
sich jederzeit rotieren oder deaktivieren.
|
||||
Teile den generierten Link oder drucke eine Vorlage aus, um Gaeste sicher ins Event zu leiten. Einladungen
|
||||
kannst du jederzeit erneuern oder deaktivieren.
|
||||
</p>
|
||||
{tokens.length > 0 && (
|
||||
<p className="flex items-center gap-2 text-[11px] uppercase tracking-wide text-amber-600">
|
||||
Aktive Tokens: {tokens.filter((token) => token.is_active && !token.revoked_at).length} · Gesamt:{' '}
|
||||
Aktive Einladungen: {tokens.filter((token) => token.is_active && !token.revoked_at).length} · Gesamt:{' '}
|
||||
{tokens.length}
|
||||
</p>
|
||||
)}
|
||||
@@ -286,7 +287,7 @@ export default function EventDetailPage() {
|
||||
|
||||
<Button onClick={handleInvite} disabled={creatingToken} className="w-full">
|
||||
{creatingToken ? <Loader2 className="h-4 w-4 animate-spin" /> : <Share2 className="h-4 w-4" />}
|
||||
Join-Token erzeugen
|
||||
Einladung erstellen
|
||||
</Button>
|
||||
|
||||
{inviteLink && (
|
||||
@@ -298,7 +299,7 @@ export default function EventDetailPage() {
|
||||
<div className="space-y-3">
|
||||
{tokens.length > 0 ? (
|
||||
tokens.map((token) => (
|
||||
<JoinTokenRow
|
||||
<InvitationCard
|
||||
key={token.id}
|
||||
token={token}
|
||||
onCopy={() => handleCopy(token)}
|
||||
@@ -308,8 +309,8 @@ export default function EventDetailPage() {
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-lg border border-slate-200 bg-white/80 p-4 text-xs text-slate-500">
|
||||
Noch keine Tokens vorhanden. Erzeuge jetzt den ersten Token, um QR-Codes und Drucklayouts
|
||||
herunterzuladen.
|
||||
Es gibt noch keine Einladungslinks. Erstelle jetzt den ersten Link, um QR-Layouts mit QR-Code
|
||||
herunterzuladen und zu teilen.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -371,7 +372,7 @@ function StatChip({ label, value }: { label: string; value: string | number }) {
|
||||
);
|
||||
}
|
||||
|
||||
function JoinTokenRow({
|
||||
function InvitationCard({
|
||||
token,
|
||||
onCopy,
|
||||
onRevoke,
|
||||
@@ -383,97 +384,53 @@ function JoinTokenRow({
|
||||
revoking: boolean;
|
||||
}) {
|
||||
const status = getTokenStatus(token);
|
||||
const availableLayouts = Array.isArray(token.layouts) ? token.layouts : [];
|
||||
const layouts = Array.isArray(token.layouts) ? token.layouts : [];
|
||||
const usageLabel = token.usage_limit ? `${token.usage_count} / ${token.usage_limit}` : `${token.usage_count}`;
|
||||
const metadata = (token.metadata ?? {}) as Record<string, unknown>;
|
||||
const isAutoGenerated = Boolean(metadata.auto_generated);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 rounded-xl border border-amber-100 bg-amber-50/60 p-3">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-slate-800">{token.label || `Einladung #${token.id}`}</span>
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||
const statusClassname =
|
||||
status === 'Aktiv'
|
||||
? 'bg-emerald-100 text-emerald-700'
|
||||
: status === 'Abgelaufen'
|
||||
? 'bg-orange-100 text-orange-700'
|
||||
: 'bg-slate-200 text-slate-700'
|
||||
}`}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="break-all font-mono text-xs text-slate-600">{token.url}</p>
|
||||
<div className="flex flex-wrap gap-3 text-xs text-slate-500">
|
||||
<span>
|
||||
Nutzung: {token.usage_count}
|
||||
{token.usage_limit ? ` / ${token.usage_limit}` : ''}
|
||||
</span>
|
||||
{token.expires_at && <span>Gültig bis {formatDateTime(token.expires_at)}</span>}
|
||||
{token.created_at && <span>Erstellt {formatDateTime(token.created_at)}</span>}
|
||||
</div>
|
||||
{availableLayouts.length > 0 && (
|
||||
<div className="space-y-3 rounded-xl border border-amber-100 bg-white/80 p-3">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-amber-600">Drucklayouts</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{availableLayouts.map((layout) => {
|
||||
const formatEntries = Array.isArray(layout.formats)
|
||||
? layout.formats
|
||||
.map((format) => {
|
||||
const normalized = String(format ?? '').toLowerCase();
|
||||
const href =
|
||||
layout.download_urls?.[normalized] ??
|
||||
layout.download_urls?.[String(format ?? '')] ??
|
||||
null;
|
||||
|
||||
return {
|
||||
format: normalized,
|
||||
label: String(format ?? '').toUpperCase(),
|
||||
href,
|
||||
};
|
||||
})
|
||||
.filter((entry) => Boolean(entry.href))
|
||||
: [];
|
||||
|
||||
if (formatEntries.length === 0) {
|
||||
return null;
|
||||
}
|
||||
: 'bg-slate-200 text-slate-700';
|
||||
|
||||
return (
|
||||
<div key={layout.id} className="flex flex-col gap-2 rounded-lg border border-amber-200 bg-white p-3 shadow-sm">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-slate-800">{layout.name}</div>
|
||||
{layout.subtitle && <div className="text-xs text-slate-500">{layout.subtitle}</div>}
|
||||
<div className="flex flex-col gap-4 rounded-2xl border border-amber-100 bg-white/90 p-4 shadow-md shadow-amber-100/40">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-semibold text-slate-900">{token.label?.trim() || `Einladung #${token.id}`}</span>
|
||||
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${statusClassname}`}>{status}</span>
|
||||
{isAutoGenerated ? (
|
||||
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-medium text-amber-700">
|
||||
Standard
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{formatEntries.map((entry) => (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="break-all rounded-md border border-slate-200 bg-slate-50 px-2 py-1 font-mono text-xs text-slate-700">
|
||||
{token.url}
|
||||
</span>
|
||||
<Button
|
||||
asChild
|
||||
key={`${layout.id}-${entry.format}`}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onCopy}
|
||||
className="border-amber-200 text-amber-700 hover:bg-amber-100"
|
||||
>
|
||||
<a href={entry.href as string} target="_blank" rel="noreferrer">
|
||||
<Download className="mr-1 h-3 w-3" />
|
||||
{entry.label}
|
||||
</a>
|
||||
<Copy className="mr-1 h-3 w-3" />
|
||||
Link kopieren
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-slate-500">
|
||||
<span>Nutzung: {usageLabel}</span>
|
||||
{token.expires_at ? <span>Gültig bis {formatDateTime(token.expires_at)}</span> : null}
|
||||
{token.created_at ? <span>Erstellt am {formatDateTime(token.created_at)}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!availableLayouts.length && token.layouts_url && (
|
||||
<div className="rounded-xl border border-amber-100 bg-white/70 p-3 text-xs text-slate-600">
|
||||
Drucklayouts stehen für diesen Token bereit. Öffne den Layout-Link, um PDF- oder SVG-Versionen zu laden.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 md:items-center md:justify-start">
|
||||
{token.layouts_url && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{token.layouts_url ? (
|
||||
<Button
|
||||
asChild
|
||||
size="sm"
|
||||
@@ -481,14 +438,11 @@ function JoinTokenRow({
|
||||
className="border-amber-200 text-amber-700 hover:bg-amber-100"
|
||||
>
|
||||
<a href={token.layouts_url} target="_blank" rel="noreferrer">
|
||||
<Download className="h-3 w-3" />
|
||||
<span className="ml-1">Layouts</span>
|
||||
<Download className="mr-1 h-3 w-3" />
|
||||
Layout-Übersicht
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={onCopy} className="border-amber-200 text-amber-700 hover:bg-amber-100">
|
||||
Kopieren
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -500,6 +454,82 @@ function JoinTokenRow({
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{layouts.length > 0 ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{layouts.map((layout) => (
|
||||
<LayoutPreviewCard key={layout.id} layout={layout} />
|
||||
))}
|
||||
</div>
|
||||
) : token.layouts_url ? (
|
||||
<div className="rounded-xl border border-amber-100 bg-amber-50/60 p-3 text-xs text-amber-800">
|
||||
Für diese Einladung stehen Layouts bereit. Öffne die Übersicht, um PDF- oder SVG-Versionen zu laden.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LayoutPreviewCard({ layout }: { layout: EventJoinTokenLayout }) {
|
||||
const gradient = layout.preview?.background_gradient;
|
||||
const stops = Array.isArray(gradient?.stops) ? gradient?.stops ?? [] : [];
|
||||
const gradientStyle = stops.length
|
||||
? {
|
||||
backgroundImage: `linear-gradient(${gradient?.angle ?? 135}deg, ${stops.join(', ')})`,
|
||||
}
|
||||
: {
|
||||
backgroundColor: layout.preview?.background ?? '#F8FAFC',
|
||||
};
|
||||
const textColor = layout.preview?.text ?? '#0F172A';
|
||||
|
||||
const formats = Array.isArray(layout.formats) ? layout.formats : [];
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border border-amber-100 bg-white shadow-sm">
|
||||
<div className="relative h-28">
|
||||
<div className="absolute inset-0" style={gradientStyle} />
|
||||
<div className="absolute inset-0 flex flex-col justify-between p-3 text-xs" style={{ color: textColor }}>
|
||||
<span className="w-fit rounded-full bg-white/30 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide">
|
||||
QR-Layout
|
||||
</span>
|
||||
<div>
|
||||
<div className="text-sm font-semibold leading-tight">{layout.name}</div>
|
||||
{layout.subtitle ? (
|
||||
<div className="text-[11px] opacity-80">{layout.subtitle}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3 p-3">
|
||||
{layout.description ? <p className="text-xs text-slate-600">{layout.description}</p> : null}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{formats.map((format) => {
|
||||
const key = String(format ?? '').toLowerCase();
|
||||
const href = layout.download_urls?.[key] ?? layout.download_urls?.[String(format ?? '')] ?? null;
|
||||
if (!href) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const label = String(format ?? '').toUpperCase() || 'PDF';
|
||||
|
||||
return (
|
||||
<Button
|
||||
asChild
|
||||
key={`${layout.id}-${label}`}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-amber-200 text-amber-700 hover:bg-amber-100"
|
||||
>
|
||||
<a href={href} target="_blank" rel="noreferrer">
|
||||
<Download className="mr-1 h-3 w-3" />
|
||||
{label}
|
||||
</a>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -547,4 +577,3 @@ function renderName(name: TenantEvent['name']): string {
|
||||
}
|
||||
return 'Unbenanntes Event';
|
||||
}
|
||||
|
||||
|
||||
@@ -4,27 +4,50 @@ import { ArrowLeft, Loader2, Save, Sparkles } from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
|
||||
import { AdminLayout } from '../components/AdminLayout';
|
||||
import { createEvent, getEvent, updateEvent, getPackages } from '../api';
|
||||
import { createEvent, getEvent, getTenantPackagesOverview, updateEvent, getPackages, getEventTypes } from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import { ADMIN_EVENT_VIEW_PATH, ADMIN_EVENTS_PATH } from '../constants';
|
||||
import { ADMIN_BILLING_PATH, ADMIN_EVENT_VIEW_PATH, ADMIN_EVENTS_PATH } from '../constants';
|
||||
|
||||
interface EventFormState {
|
||||
name: string;
|
||||
slug: string;
|
||||
date: string;
|
||||
eventTypeId: number | null;
|
||||
package_id: number;
|
||||
isPublished: boolean;
|
||||
}
|
||||
|
||||
type PackageHighlight = {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
const FEATURE_LABELS: Record<string, string> = {
|
||||
basic_uploads: 'Uploads inklusive',
|
||||
unlimited_sharing: 'Unbegrenztes Teilen',
|
||||
no_watermark: 'Kein Wasserzeichen',
|
||||
custom_branding: 'Eigenes Branding',
|
||||
custom_tasks: 'Eigene Aufgaben',
|
||||
watermark_allowed: 'Wasserzeichen erlaubt',
|
||||
branding_allowed: 'Branding-Optionen',
|
||||
};
|
||||
|
||||
type EventPackageMeta = {
|
||||
id: number;
|
||||
name: string;
|
||||
purchasedAt: string | null;
|
||||
expiresAt: string | null;
|
||||
};
|
||||
|
||||
export default function EventFormPage() {
|
||||
const params = useParams<{ slug?: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
@@ -36,7 +59,8 @@ export default function EventFormPage() {
|
||||
name: '',
|
||||
slug: '',
|
||||
date: '',
|
||||
package_id: 1, // Default Free package
|
||||
eventTypeId: null,
|
||||
package_id: 0,
|
||||
isPublished: false,
|
||||
});
|
||||
const [autoSlug, setAutoSlug] = React.useState(true);
|
||||
@@ -44,12 +68,65 @@ export default function EventFormPage() {
|
||||
const [loading, setLoading] = React.useState(isEdit);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [readOnlyPackageName, setReadOnlyPackageName] = React.useState<string | null>(null);
|
||||
const [eventPackageMeta, setEventPackageMeta] = React.useState<EventPackageMeta | null>(null);
|
||||
|
||||
const { data: packages, isLoading: packagesLoading } = useQuery({
|
||||
queryKey: ['packages', 'endcustomer'],
|
||||
queryFn: () => getPackages('endcustomer'),
|
||||
});
|
||||
|
||||
const { data: eventTypes, isLoading: eventTypesLoading } = useQuery({
|
||||
queryKey: ['tenant', 'event-types'],
|
||||
queryFn: getEventTypes,
|
||||
});
|
||||
|
||||
const { data: packageOverview, isLoading: overviewLoading } = useQuery({
|
||||
queryKey: ['tenant', 'packages', 'overview'],
|
||||
queryFn: getTenantPackagesOverview,
|
||||
});
|
||||
|
||||
const activePackage = packageOverview?.activePackage ?? null;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isEdit || !activePackage?.package_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
setForm((prev) => {
|
||||
if (prev.package_id === activePackage.package_id) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
package_id: activePackage.package_id,
|
||||
};
|
||||
});
|
||||
|
||||
setReadOnlyPackageName((prev) => prev ?? activePackage.package_name);
|
||||
}, [isEdit, activePackage]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isEdit) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!eventTypes || eventTypes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setForm((prev) => {
|
||||
if (prev.eventTypeId) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
eventTypeId: eventTypes[0]!.id,
|
||||
};
|
||||
});
|
||||
}, [eventTypes, isEdit]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (!isEdit || !slugParam) {
|
||||
@@ -69,9 +146,20 @@ export default function EventFormPage() {
|
||||
name,
|
||||
slug: event.slug,
|
||||
date: event.event_date ? event.event_date.slice(0, 10) : '',
|
||||
eventTypeId: event.event_type_id ?? prev.eventTypeId,
|
||||
isPublished: event.status === 'published',
|
||||
package_id: event.package?.id ? Number(event.package.id) : prev.package_id,
|
||||
}));
|
||||
setOriginalSlug(event.slug);
|
||||
setReadOnlyPackageName(event.package?.name ?? null);
|
||||
setEventPackageMeta(event.package
|
||||
? {
|
||||
id: Number(event.package.id),
|
||||
name: event.package.name ?? (typeof event.package === 'string' ? event.package : ''),
|
||||
purchasedAt: event.package.purchased_at ?? null,
|
||||
expiresAt: event.package.expires_at ?? null,
|
||||
}
|
||||
: null);
|
||||
setAutoSlug(false);
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
@@ -116,17 +204,30 @@ export default function EventFormPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!form.eventTypeId) {
|
||||
setError('Bitte waehle einen Event-Typ aus.');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
const status: 'draft' | 'published' | 'archived' = form.isPublished ? 'published' : 'draft';
|
||||
const packageIdForSubmit = form.package_id || activePackage?.package_id || null;
|
||||
|
||||
const shouldIncludePackage = !isEdit
|
||||
&& packageIdForSubmit
|
||||
&& (!activePackage?.package_id || packageIdForSubmit !== activePackage.package_id);
|
||||
|
||||
const payload = {
|
||||
name: trimmedName,
|
||||
slug: trimmedSlug,
|
||||
package_id: form.package_id,
|
||||
date: form.date || undefined,
|
||||
event_type_id: form.eventTypeId,
|
||||
event_date: form.date || undefined,
|
||||
status,
|
||||
...(shouldIncludePackage && packageIdForSubmit
|
||||
? { package_id: Number(packageIdForSubmit) }
|
||||
: {}),
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -148,6 +249,77 @@ export default function EventFormPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const effectivePackageId = form.package_id || activePackage?.package_id || null;
|
||||
|
||||
const selectedPackage = React.useMemo(() => {
|
||||
if (!packages || !packages.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (effectivePackageId) {
|
||||
return packages.find((pkg) => pkg.id === effectivePackageId) ?? null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [packages, effectivePackageId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!readOnlyPackageName && selectedPackage?.name) {
|
||||
setReadOnlyPackageName(selectedPackage.name);
|
||||
}
|
||||
}, [readOnlyPackageName, selectedPackage]);
|
||||
|
||||
const packageNameDisplay = readOnlyPackageName
|
||||
?? selectedPackage?.name
|
||||
?? ((overviewLoading || packagesLoading) ? 'Paket wird geladen…' : 'Kein aktives Paket gefunden');
|
||||
|
||||
const packagePriceLabel = selectedPackage?.price !== undefined && selectedPackage?.price !== null
|
||||
? formatCurrency(selectedPackage.price)
|
||||
: null;
|
||||
|
||||
const packageHighlights = React.useMemo<PackageHighlight[]>(() => {
|
||||
const highlights: PackageHighlight[] = [];
|
||||
|
||||
if (selectedPackage?.max_photos) {
|
||||
highlights.push({
|
||||
label: 'Fotos',
|
||||
value: `${selectedPackage.max_photos.toLocaleString('de-DE')} Bilder`,
|
||||
});
|
||||
}
|
||||
|
||||
if (selectedPackage?.max_guests) {
|
||||
highlights.push({
|
||||
label: 'Gäste',
|
||||
value: `${selectedPackage.max_guests.toLocaleString('de-DE')} Personen`,
|
||||
});
|
||||
}
|
||||
|
||||
if (selectedPackage?.gallery_days) {
|
||||
highlights.push({
|
||||
label: 'Galerie',
|
||||
value: `${selectedPackage.gallery_days} Tage online`,
|
||||
});
|
||||
}
|
||||
|
||||
return highlights;
|
||||
}, [selectedPackage]);
|
||||
|
||||
const featureTags = React.useMemo(() => {
|
||||
if (!selectedPackage?.features) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.entries(selectedPackage.features)
|
||||
.filter(([, enabled]) => Boolean(enabled))
|
||||
.map(([key]) => FEATURE_LABELS[key] ?? key.replace(/_/g, ' '));
|
||||
}, [selectedPackage]);
|
||||
|
||||
const packageExpiresLabel = formatDate(eventPackageMeta?.expiresAt ?? activePackage?.expires_at ?? null);
|
||||
|
||||
const remainingEventsLabel = typeof activePackage?.remaining_events === 'number'
|
||||
? `Noch ${activePackage.remaining_events} Event${activePackage.remaining_events === 1 ? '' : 's'} in deinem Paket`
|
||||
: null;
|
||||
|
||||
const actions = (
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -205,8 +377,8 @@ export default function EventFormPage() {
|
||||
onChange={(e) => handleSlugChange(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-slate-500">
|
||||
Diese Kennung wird intern verwendet. Gaeste erhalten Zugriff ausschliesslich ueber Join-Tokens und deren
|
||||
QR-/Layout-Downloads.
|
||||
Diese Kennung wird intern verwendet. Gaeste betreten dein Event ausschliesslich ueber ihre
|
||||
Einladungslinks und die dazugehoerigen QR-Layouts.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -219,56 +391,107 @@ export default function EventFormPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="package_id">Package</Label>
|
||||
<Label htmlFor="event-type">Event-Typ</Label>
|
||||
<Select
|
||||
value={form.package_id.toString()}
|
||||
onValueChange={(value) => setForm((prev) => ({ ...prev, package_id: parseInt(value, 10) }))}
|
||||
disabled={packagesLoading || !packages?.length}
|
||||
value={form.eventTypeId ? String(form.eventTypeId) : undefined}
|
||||
onValueChange={(value) => setForm((prev) => ({ ...prev, eventTypeId: Number(value) }))}
|
||||
disabled={eventTypesLoading || !eventTypes?.length}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={packagesLoading ? 'Pakete werden geladen...' : 'Waehlen Sie ein Package'} />
|
||||
<SelectTrigger id="event-type">
|
||||
<SelectValue
|
||||
placeholder={eventTypesLoading ? 'Event-Typ wird geladen…' : 'Event-Typ auswaehlen'}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
{packages?.length ? (
|
||||
<SelectContent>
|
||||
{packages.map((pkg) => (
|
||||
<SelectItem key={pkg.id} value={pkg.id.toString()}>
|
||||
{pkg.name} - {pkg.price} EUR ({pkg.max_photos} Fotos)
|
||||
{eventTypes?.map((eventType) => (
|
||||
<SelectItem key={eventType.id} value={String(eventType.id)}>
|
||||
{eventType.icon ? `${eventType.icon} ${eventType.name}` : eventType.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
) : null}
|
||||
</Select>
|
||||
{packagesLoading ? (
|
||||
<p className="text-xs text-slate-500">Pakete werden geladen...</p>
|
||||
{!eventTypesLoading && (!eventTypes || eventTypes.length === 0) ? (
|
||||
<p className="text-xs text-amber-600">
|
||||
Keine Event-Typen verfuegbar. Bitte lege einen Typ im Adminbereich an.
|
||||
</p>
|
||||
) : null}
|
||||
{!packagesLoading && (!packages || packages.length === 0) ? (
|
||||
<p className="text-xs text-red-500">Keine Pakete verfuegbar. Bitte pruefen Sie Ihre Einstellungen.</p>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<Card className="border-0 bg-gradient-to-br from-pink-500 via-fuchsia-500 to-purple-500 text-white shadow-xl shadow-pink-500/25">
|
||||
<CardHeader className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<Badge className="bg-white/25 text-white backdrop-blur">
|
||||
{isEdit ? 'Gebuchtes Paket' : 'Aktives Paket'}
|
||||
</Badge>
|
||||
{packagePriceLabel ? (
|
||||
<span className="text-sm font-semibold uppercase tracking-widest text-white/90">
|
||||
{packagePriceLabel}
|
||||
</span>
|
||||
) : null}
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm">Package-Details</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Package auswaehlen</DialogTitle>
|
||||
<DialogDescription>Waehlen Sie das Package fuer Ihr Event. Hoehere Packages bieten mehr Limits und Features.</DialogDescription>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{packages?.map((pkg) => (
|
||||
<div key={pkg.id} className="p-4 border rounded">
|
||||
<h3 className="font-semibold">{pkg.name}</h3>
|
||||
<p>{pkg.price} EUR</p>
|
||||
<ul className="text-sm">
|
||||
<li>Max Fotos: {pkg.max_photos}</li>
|
||||
<li>Max Gaeste: {pkg.max_guests}</li>
|
||||
<li>Galerie: {pkg.gallery_days} Tage</li>
|
||||
<li>Features: {Object.keys(pkg.features).filter(k => pkg.features[k]).join(', ')}</li>
|
||||
</ul>
|
||||
<CardTitle className="text-2xl font-semibold tracking-tight text-white">
|
||||
{packageNameDisplay}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-pink-50">
|
||||
Du nutzt dieses Paket für dein Event. Upgrades und Add-ons folgen bald – bis dahin kannst du alle enthaltenen Leistungen voll ausschöpfen.
|
||||
</CardDescription>
|
||||
</div>
|
||||
{packageExpiresLabel ? (
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-white/80">
|
||||
Galerie aktiv bis {packageExpiresLabel}
|
||||
</p>
|
||||
) : null}
|
||||
{remainingEventsLabel ? (
|
||||
<p className="text-xs text-white/80">{remainingEventsLabel}</p>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5 pb-6">
|
||||
{packageHighlights.length ? (
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
{packageHighlights.map((highlight) => (
|
||||
<div key={`${highlight.label}-${highlight.value}`} className="rounded-xl bg-white/15 px-4 py-3 text-sm">
|
||||
<p className="text-white/70">{highlight.label}</p>
|
||||
<p className="font-semibold text-white">{highlight.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
) : null}
|
||||
{featureTags.length ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{featureTags.map((feature) => (
|
||||
<Badge key={feature} variant="secondary" className="bg-white/15 text-white backdrop-blur">
|
||||
{feature}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-white/75">
|
||||
{(packagesLoading || overviewLoading)
|
||||
? 'Paketdetails werden geladen...'
|
||||
: 'Für dieses Paket sind aktuell keine besonderen Features hinterlegt.'}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="bg-white/20 text-white hover:bg-white/30"
|
||||
onClick={() => navigate(ADMIN_BILLING_PATH)}
|
||||
>
|
||||
Abrechnung öffnen
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="text-white/70 hover:bg-white/10"
|
||||
disabled
|
||||
>
|
||||
Upgrade-Optionen demnächst
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -291,7 +514,7 @@ export default function EventFormPage() {
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={saving || !form.name.trim() || !form.slug.trim()}
|
||||
disabled={saving || !form.name.trim() || !form.slug.trim() || !form.eventTypeId}
|
||||
className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white shadow-lg shadow-pink-500/20"
|
||||
>
|
||||
{saving ? (
|
||||
@@ -326,6 +549,43 @@ function FormSkeleton() {
|
||||
);
|
||||
}
|
||||
|
||||
function formatCurrency(value: number | null | undefined): string | null {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
maximumFractionDigits: value % 1 === 0 ? 0 : 2,
|
||||
}).format(value);
|
||||
} catch {
|
||||
return `${value} €`;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(value: string | null | undefined): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return new Intl.DateTimeFormat('de-DE', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
}).format(date);
|
||||
} catch {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
}
|
||||
|
||||
function slugify(value: string): string {
|
||||
return value
|
||||
.normalize('NFKD')
|
||||
|
||||
@@ -350,6 +350,11 @@ function TaskDialog({
|
||||
saving: boolean;
|
||||
isEditing: boolean;
|
||||
}) {
|
||||
const handleFormSubmit = React.useCallback((event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
onSubmit(event);
|
||||
}, [onSubmit]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
@@ -357,7 +362,7 @@ function TaskDialog({
|
||||
<DialogTitle>{isEditing ? 'Task bearbeiten' : 'Neuen Task anlegen'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form className="space-y-4" onSubmit={onSubmit}>
|
||||
<form className="space-y-4" onSubmit={handleFormSubmit}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="task-title">Titel</Label>
|
||||
<Input
|
||||
|
||||
@@ -42,7 +42,6 @@ vi.mock('../../onboarding', () => ({
|
||||
vi.mock('../../api', () => ({
|
||||
getDashboardSummary: vi.fn().mockResolvedValue(null),
|
||||
getEvents: vi.fn().mockResolvedValue([]),
|
||||
getCreditBalance: vi.fn().mockResolvedValue({ balance: 0 }),
|
||||
getTenantPackagesOverview: vi.fn().mockResolvedValue({ packages: [], activePackage: null }),
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo, useRef, useEffect, useCallback } from "react";
|
||||
import React, { useMemo, useRef, useEffect, useCallback, Suspense, lazy } from "react";
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Steps } from "@/components/ui/Steps";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -7,10 +7,11 @@ import { CheckoutWizardProvider, useCheckoutWizard } from "./WizardContext";
|
||||
import type { CheckoutPackage, CheckoutStepId } from "./types";
|
||||
import { PackageStep } from "./steps/PackageStep";
|
||||
import { AuthStep } from "./steps/AuthStep";
|
||||
import { PaymentStep } from "./steps/PaymentStep";
|
||||
import { ConfirmationStep } from "./steps/ConfirmationStep";
|
||||
import { useAnalytics } from '@/hooks/useAnalytics';
|
||||
|
||||
const PaymentStep = lazy(() => import('./steps/PaymentStep').then((module) => ({ default: module.PaymentStep })));
|
||||
|
||||
interface CheckoutWizardProps {
|
||||
initialPackage: CheckoutPackage;
|
||||
packageOptions: CheckoutPackage[];
|
||||
@@ -52,6 +53,14 @@ const baseStepConfig: { id: CheckoutStepId; titleKey: string; descriptionKey: st
|
||||
detailsKey: 'checkout.confirmation_step.description'
|
||||
},
|
||||
];
|
||||
|
||||
const PaymentStepFallback: React.FC = () => (
|
||||
<div className="rounded-lg border bg-card p-6 shadow-sm">
|
||||
<div className="mb-4 h-4 w-52 animate-pulse rounded bg-muted" />
|
||||
<div className="h-10 w-full animate-pulse rounded bg-muted" />
|
||||
</div>
|
||||
);
|
||||
|
||||
const WizardBody: React.FC<{ stripePublishableKey: string; paypalClientId: string; privacyHtml: string }> = ({ stripePublishableKey, paypalClientId, privacyHtml }) => {
|
||||
const { t } = useTranslation('marketing');
|
||||
const { currentStep, nextStep, previousStep } = useCheckoutWizard();
|
||||
@@ -144,7 +153,9 @@ const WizardBody: React.FC<{ stripePublishableKey: string; paypalClientId: strin
|
||||
{currentStep === "package" && <PackageStep />}
|
||||
{currentStep === "auth" && <AuthStep privacyHtml={privacyHtml} />}
|
||||
{currentStep === "payment" && (
|
||||
<Suspense fallback={<PaymentStepFallback />}>
|
||||
<PaymentStep stripePublishableKey={stripePublishableKey} paypalClientId={paypalClientId} />
|
||||
</Suspense>
|
||||
)}
|
||||
{currentStep === "confirmation" && (
|
||||
<ConfirmationStep onViewProfile={handleViewProfile} onGoToAdmin={handleGoToAdmin} />
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useStripe, useElements, PaymentElement, Elements } from '@stripe/react-stripe-js';
|
||||
import { loadStripe } from '@stripe/stripe-js';
|
||||
import { PayPalButtons, PayPalScriptProvider } from '@paypal/react-paypal-js';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { LoaderCircle } from 'lucide-react';
|
||||
import { useCheckoutWizard } from '../WizardContext';
|
||||
import { getStripe } from '@/utils/stripe';
|
||||
|
||||
interface PaymentStepProps {
|
||||
stripePublishableKey: string;
|
||||
@@ -243,10 +243,7 @@ export const PaymentStep: React.FC<PaymentStepProps> = ({ stripePublishableKey,
|
||||
const [intentRefreshKey, setIntentRefreshKey] = useState(0);
|
||||
const [processingProvider, setProcessingProvider] = useState<Provider | null>(null);
|
||||
|
||||
const stripePromise = useMemo(
|
||||
() => (stripePublishableKey ? loadStripe(stripePublishableKey) : null),
|
||||
[stripePublishableKey]
|
||||
);
|
||||
const stripePromise = useMemo(() => getStripe(stripePublishableKey), [stripePublishableKey]);
|
||||
const isFree = useMemo(() => (selectedPackage ? selectedPackage.price <= 0 : false), [selectedPackage]);
|
||||
const isReseller = selectedPackage?.type === 'reseller';
|
||||
|
||||
|
||||
8
resources/js/types/vite-env.d.ts
vendored
8
resources/js/types/vite-env.d.ts
vendored
@@ -1 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_ENABLE_TENANT_SWITCHER?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
|
||||
21
resources/js/utils/stripe.ts
Normal file
21
resources/js/utils/stripe.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { Stripe } from '@stripe/stripe-js';
|
||||
|
||||
const stripePromiseCache = new Map<string, Promise<Stripe | null>>();
|
||||
|
||||
export async function getStripe(publishableKey?: string): Promise<Stripe | null> {
|
||||
if (!publishableKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!stripePromiseCache.has(publishableKey)) {
|
||||
const promise = import('@stripe/stripe-js').then(({ loadStripe }) => loadStripe(publishableKey));
|
||||
stripePromiseCache.set(publishableKey, promise);
|
||||
}
|
||||
|
||||
return stripePromiseCache.get(publishableKey) ?? null;
|
||||
}
|
||||
|
||||
export function clearStripeCache(): void {
|
||||
stripePromiseCache.clear();
|
||||
}
|
||||
|
||||
@@ -75,27 +75,27 @@ return [
|
||||
'table' => [
|
||||
'tenant' => 'Mandant',
|
||||
'join' => 'Beitreten',
|
||||
'join_tokens_total' => 'Join-Tokens: :count',
|
||||
'join_tokens_missing' => 'Noch keine Join-Tokens erstellt',
|
||||
'join_tokens_total' => 'Einladungen: :count',
|
||||
'join_tokens_missing' => 'Noch keine Einladungen erstellt',
|
||||
],
|
||||
'actions' => [
|
||||
'toggle_active' => 'Aktiv umschalten',
|
||||
'join_link_qr' => 'Beitrittslink / QR',
|
||||
'join_link_qr' => 'Einladungslink & QR',
|
||||
'download_photos' => 'Alle Fotos herunterladen',
|
||||
],
|
||||
'modal' => [
|
||||
'join_link_heading' => 'Beitrittslink der Veranstaltung',
|
||||
'join_link_heading' => 'Einladungslink der Veranstaltung',
|
||||
],
|
||||
'messages' => [
|
||||
'join_link_copied' => 'Beitrittslink kopiert',
|
||||
'join_link_copied' => 'Einladungslink kopiert',
|
||||
],
|
||||
'join_link' => [
|
||||
'event_label' => 'Veranstaltung',
|
||||
'deprecated_notice' => 'Der direkte Zugriff über den Event-Slug :slug wurde deaktiviert. Teile die Join-Tokens unten oder öffne in der Admin-App „QR & Einladungen“, um neue Codes zu verwalten.',
|
||||
'deprecated_notice' => 'Der direkte Zugriff über den Event-Slug :slug wurde deaktiviert. Teile die Einladungslinks unten oder öffne in der Admin-App „QR & Einladungen“, um neue Codes zu verwalten.',
|
||||
'open_admin' => 'Admin-App öffnen',
|
||||
'link_label' => 'Beitrittslink',
|
||||
'link_label' => 'Einladungslink',
|
||||
'copy_link' => 'Kopieren',
|
||||
'no_tokens' => 'Noch keine Join-Tokens vorhanden. Erstelle im Admin-Bereich ein Token, um dein Event zu teilen.',
|
||||
'no_tokens' => 'Noch keine Einladungen vorhanden. Erstelle im Admin-Bereich eine Einladung, um dein Event zu teilen.',
|
||||
'token_default' => 'Einladung #:id',
|
||||
'token_usage' => 'Nutzung: :usage / :limit',
|
||||
'token_active' => 'Aktiv',
|
||||
|
||||
@@ -75,26 +75,26 @@ return [
|
||||
'table' => [
|
||||
'tenant' => 'Tenant',
|
||||
'join' => 'Join',
|
||||
'join_tokens_total' => 'Join tokens: :count',
|
||||
'join_tokens_missing' => 'No join tokens created yet',
|
||||
'join_tokens_total' => 'Invitations: :count',
|
||||
'join_tokens_missing' => 'No invitations created yet',
|
||||
],
|
||||
'actions' => [
|
||||
'toggle_active' => 'Toggle Active',
|
||||
'join_link_qr' => 'Join Link / QR',
|
||||
'join_link_qr' => 'Invitation Link & QR',
|
||||
'download_photos' => 'Download all photos',
|
||||
],
|
||||
'modal' => [
|
||||
'join_link_heading' => 'Event Join Link',
|
||||
'join_link_heading' => 'Event Invitation Link',
|
||||
],
|
||||
'messages' => [
|
||||
'join_link_copied' => 'Join link copied',
|
||||
'join_link_copied' => 'Invitation link copied',
|
||||
],
|
||||
'join_link' => [
|
||||
'event_label' => 'Event',
|
||||
'slug_label' => 'Slug: :slug',
|
||||
'link_label' => 'Join Link',
|
||||
'link_label' => 'Invitation Link',
|
||||
'copy_link' => 'Copy',
|
||||
'no_tokens' => 'No tokens available yet. Create a token in the admin app to share your event.',
|
||||
'no_tokens' => 'No invitations yet. Create one in the admin app to share your event.',
|
||||
'token_default' => 'Invitation #:id',
|
||||
'token_usage' => 'Usage: :usage / :limit',
|
||||
'token_active' => 'Active',
|
||||
@@ -102,7 +102,7 @@ return [
|
||||
'layouts_heading' => 'Printable layouts',
|
||||
'layouts_fallback' => 'Open layout overview',
|
||||
'token_expiry' => 'Expires at :date',
|
||||
'deprecated_notice' => 'Direct access via slug :slug has been retired. Share the join tokens below or manage QR layouts in the admin app.',
|
||||
'deprecated_notice' => 'Direct access via slug :slug has been retired. Share the invitations below or manage QR layouts in the admin app.',
|
||||
'open_admin' => 'Open admin app',
|
||||
],
|
||||
'analytics' => [
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\Api\EventPublicController;
|
||||
use App\Http\Controllers\Api\PackageController;
|
||||
use App\Http\Controllers\Api\StripeController;
|
||||
use App\Http\Controllers\Api\Tenant\DashboardController;
|
||||
use App\Http\Controllers\Api\Tenant\EmotionController;
|
||||
use App\Http\Controllers\Api\Tenant\EventController;
|
||||
use App\Http\Controllers\Api\Tenant\EventJoinTokenController;
|
||||
use App\Http\Controllers\Api\Tenant\EventJoinTokenLayoutController;
|
||||
use App\Http\Controllers\Api\Tenant\SettingsController;
|
||||
use App\Http\Controllers\Api\Tenant\TaskController;
|
||||
use App\Http\Controllers\Api\Tenant\TaskCollectionController;
|
||||
use App\Http\Controllers\Api\Tenant\EventTypeController;
|
||||
use App\Http\Controllers\Api\Tenant\PhotoController;
|
||||
use App\Http\Controllers\Api\Tenant\EmotionController;
|
||||
use App\Http\Controllers\Api\Tenant\SettingsController;
|
||||
use App\Http\Controllers\Api\Tenant\TaskCollectionController;
|
||||
use App\Http\Controllers\Api\Tenant\TaskController;
|
||||
use App\Http\Controllers\Api\TenantPackageController;
|
||||
use App\Http\Controllers\OAuthController;
|
||||
use App\Http\Controllers\RevenueCatWebhookController;
|
||||
use App\Http\Controllers\Api\PackageController;
|
||||
use App\Http\Controllers\Api\TenantPackageController;
|
||||
use App\Http\Controllers\Api\StripeController;
|
||||
use App\Http\Controllers\StripeWebhookController;
|
||||
use App\Http\Controllers\Tenant\CreditController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
@@ -54,6 +56,8 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
||||
|
||||
Route::middleware(['tenant.token', 'tenant.isolation', 'throttle:tenant-api'])->prefix('tenant')->group(function () {
|
||||
Route::get('me', [OAuthController::class, 'me'])->name('tenant.me');
|
||||
Route::get('dashboard', DashboardController::class)->name('tenant.dashboard');
|
||||
Route::get('event-types', EventTypeController::class)->name('tenant.event-types.index');
|
||||
|
||||
Route::apiResource('events', EventController::class)
|
||||
->only(['index', 'show', 'destroy'])
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
use App\Http\Controllers\CheckoutController;
|
||||
use App\Http\Controllers\CheckoutGoogleController;
|
||||
use App\Http\Controllers\LocaleController;
|
||||
use App\Http\Controllers\LegalPageController;
|
||||
use App\Http\Controllers\LocaleController;
|
||||
use App\Http\Controllers\MarketingController;
|
||||
use App\Http\Controllers\Tenant\EventPhotoArchiveController;
|
||||
use App\Http\Controllers\PayPalController;
|
||||
use App\Http\Controllers\PayPalWebhookController;
|
||||
use App\Http\Controllers\Tenant\EventPhotoArchiveController;
|
||||
use App\Models\Package;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Inertia\Inertia;
|
||||
@@ -42,10 +42,16 @@ Route::get('/blog/{slug}', [MarketingController::class, 'blogShow'])->name('blog
|
||||
Route::get('/packages', [MarketingController::class, 'packagesIndex'])->name('packages');
|
||||
Route::get('/anlaesse/{type}', [MarketingController::class, 'occasionsType'])->name('anlaesse.type');
|
||||
Route::get('/success/{packageId?}', [MarketingController::class, 'success'])->name('marketing.success');
|
||||
Route::view('/event-admin/auth/callback', 'admin')->name('tenant.admin.auth.callback');
|
||||
Route::view('/event-admin/login', 'admin')->name('tenant.admin.login');
|
||||
Route::view('/event-admin/logout', 'admin')->name('tenant.admin.logout');
|
||||
Route::view('/event-admin/{view?}', 'admin')->where('view', '.*')->name('tenant.admin.app');
|
||||
Route::prefix('event-admin')->group(function () {
|
||||
$renderAdmin = fn () => view('admin');
|
||||
|
||||
Route::get('/auth/callback', $renderAdmin)->name('tenant.admin.auth.callback');
|
||||
Route::get('/login', $renderAdmin)->name('tenant.admin.login');
|
||||
Route::get('/logout', $renderAdmin)->name('tenant.admin.logout');
|
||||
Route::get('/{view?}', $renderAdmin)
|
||||
->where('view', '.*')
|
||||
->name('tenant.admin.app');
|
||||
});
|
||||
Route::view('/event', 'guest')->name('guest.pwa.landing');
|
||||
Route::view('/g/{token}', 'guest')->where('token', '.*')->name('guest.gallery');
|
||||
Route::view('/e/{token}/{path?}', 'guest')
|
||||
|
||||
@@ -2,17 +2,17 @@
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\Package;
|
||||
use App\Models\Event;
|
||||
use App\Models\User;
|
||||
use App\Models\TenantPackage;
|
||||
use App\Models\EventPackage;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
use App\Models\Package;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantPackage;
|
||||
use App\Models\User;
|
||||
use App\Services\EventJoinTokenService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\TestCase;
|
||||
|
||||
class EventControllerTest extends TestCase
|
||||
{
|
||||
@@ -46,6 +46,10 @@ class EventControllerTest extends TestCase
|
||||
'package_id' => $package->id,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('event_join_tokens', [
|
||||
'event_id' => $event->id,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('package_purchases', [
|
||||
'event_id' => $event->id,
|
||||
'package_id' => $package->id,
|
||||
|
||||
115
tests/Feature/Tenant/DashboardSummaryTest.php
Normal file
115
tests/Feature/Tenant/DashboardSummaryTest.php
Normal file
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Tenant;
|
||||
|
||||
use App\Http\Controllers\Api\Tenant\DashboardController;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventType;
|
||||
use App\Models\Package;
|
||||
use App\Models\Photo;
|
||||
use App\Models\Task;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantPackage;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Arr;
|
||||
use Tests\TestCase;
|
||||
|
||||
class DashboardSummaryTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_it_returns_dashboard_metrics_for_tenant(): void
|
||||
{
|
||||
app()->setLocale('de');
|
||||
|
||||
$tenant = Tenant::factory()->create([
|
||||
'event_credits_balance' => 5,
|
||||
]);
|
||||
|
||||
$eventType = EventType::factory()->create();
|
||||
|
||||
$eventWithTasks = Event::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'event_type_id' => $eventType->id,
|
||||
'status' => 'published',
|
||||
'is_active' => true,
|
||||
'date' => now()->addDays(3),
|
||||
]);
|
||||
|
||||
$eventWithoutTasks = Event::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'event_type_id' => $eventType->id,
|
||||
'status' => 'draft',
|
||||
'is_active' => false,
|
||||
'date' => now()->addDays(10),
|
||||
]);
|
||||
|
||||
$task = Task::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_completed' => true,
|
||||
]);
|
||||
|
||||
$eventWithTasks->tasks()->attach($task->id);
|
||||
|
||||
Photo::factory()->create([
|
||||
'event_id' => $eventWithTasks->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
'created_at' => now()->subDays(2),
|
||||
]);
|
||||
|
||||
Photo::factory()->create([
|
||||
'event_id' => $eventWithTasks->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
'created_at' => now()->subDays(8),
|
||||
]);
|
||||
|
||||
$package = Package::factory()
|
||||
->reseller()
|
||||
->create([
|
||||
'name' => 'Standard',
|
||||
'name_translations' => ['de' => 'Standard', 'en' => 'Standard'],
|
||||
'price' => 59,
|
||||
]);
|
||||
|
||||
$activePackage = TenantPackage::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'package_id' => $package->id,
|
||||
'expires_at' => now()->addMonth(),
|
||||
'used_events' => 1,
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
$controller = new DashboardController;
|
||||
$request = Request::create('/api/v1/tenant/dashboard', 'GET');
|
||||
$request->attributes->set('decoded_token', ['tenant_id' => $tenant->id]);
|
||||
|
||||
$response = $controller($request);
|
||||
|
||||
$this->assertSame(200, $response->getStatusCode());
|
||||
|
||||
$payload = $response->getData(true);
|
||||
|
||||
$this->assertSame(1, Arr::get($payload, 'active_events'));
|
||||
$this->assertSame(1, Arr::get($payload, 'new_photos'));
|
||||
$this->assertSame(50, Arr::get($payload, 'task_progress'));
|
||||
$this->assertSame(5, Arr::get($payload, 'credit_balance'));
|
||||
$this->assertSame(2, Arr::get($payload, 'upcoming_events'));
|
||||
|
||||
$activePackagePayload = Arr::get($payload, 'active_package');
|
||||
|
||||
$this->assertIsArray($activePackagePayload);
|
||||
$this->assertSame('Standard', Arr::get($activePackagePayload, 'name'));
|
||||
$this->assertSame($activePackage->remaining_events, Arr::get($activePackagePayload, 'remaining_events'));
|
||||
|
||||
$this->assertSame(
|
||||
$activePackage->expires_at->toIso8601String(),
|
||||
Arr::get($payload, 'active_package.expires_at')
|
||||
);
|
||||
|
||||
$this->assertSame(
|
||||
$tenant->event_credits_balance,
|
||||
Arr::get($payload, 'credit_balance')
|
||||
);
|
||||
}
|
||||
}
|
||||
138
tests/Feature/Tenant/EventListTest.php
Normal file
138
tests/Feature/Tenant/EventListTest.php
Normal file
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Tenant;
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Models\EventPackage;
|
||||
use App\Models\Package;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class EventListTest extends TenantTestCase
|
||||
{
|
||||
public function test_index_returns_events_without_packages(): void
|
||||
{
|
||||
$event = Event::factory()
|
||||
->for($this->tenant)
|
||||
->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'name' => 'Package-less Event',
|
||||
'slug' => 'package-less-event',
|
||||
]);
|
||||
|
||||
$response = $this->authenticatedRequest('GET', '/api/v1/tenant/events');
|
||||
|
||||
$response->assertOk();
|
||||
$payload = $response->json('data');
|
||||
|
||||
$this->assertNotEmpty($payload, 'Expected at least one event in the response');
|
||||
|
||||
$matchingEvent = collect($payload)->firstWhere('id', $event->id);
|
||||
|
||||
$this->assertNotNull($matchingEvent, 'Created event should be present in response payload');
|
||||
$this->assertNull($matchingEvent['package'], 'Events without package should return null package data');
|
||||
}
|
||||
|
||||
public function test_index_includes_package_details_when_available(): void
|
||||
{
|
||||
$package = Package::factory()->create([
|
||||
'type' => 'endcustomer',
|
||||
'name' => 'Standard',
|
||||
'name_translations' => [
|
||||
'de' => 'Standard',
|
||||
'en' => 'Standard',
|
||||
],
|
||||
'price' => 59,
|
||||
'gallery_days' => 45,
|
||||
]);
|
||||
|
||||
$event = Event::factory()
|
||||
->for($this->tenant)
|
||||
->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'name' => 'Packaged Event',
|
||||
'slug' => 'packaged-event',
|
||||
]);
|
||||
|
||||
EventPackage::create([
|
||||
'event_id' => $event->id,
|
||||
'package_id' => $package->id,
|
||||
'purchased_price' => $package->price,
|
||||
'purchased_at' => Carbon::now()->subDay(),
|
||||
'gallery_expires_at' => Carbon::now()->addDays($package->gallery_days ?? 30),
|
||||
'used_photos' => 0,
|
||||
'used_guests' => 0,
|
||||
]);
|
||||
|
||||
$response = $this->authenticatedRequest('GET', '/api/v1/tenant/events');
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$payload = collect($response->json('data'));
|
||||
$matchingEvent = $payload->firstWhere('id', $event->id);
|
||||
|
||||
$this->assertNotNull($matchingEvent, 'Packaged event should be present in response payload');
|
||||
|
||||
$this->assertIsArray($matchingEvent['package']);
|
||||
$this->assertSame($package->id, $matchingEvent['package']['id']);
|
||||
$this->assertSame('Standard', $matchingEvent['package']['name']);
|
||||
$this->assertSame('59.00', $matchingEvent['package']['price']);
|
||||
}
|
||||
|
||||
public function test_index_scopes_events_to_authenticated_tenant(): void
|
||||
{
|
||||
$foreignTenant = Tenant::factory()->create();
|
||||
|
||||
Event::factory()->for($foreignTenant)->create([
|
||||
'name' => 'Foreign Event',
|
||||
'slug' => 'foreign-event',
|
||||
]);
|
||||
|
||||
$ownEvent = Event::factory()->for($this->tenant)->create([
|
||||
'name' => 'Own Event',
|
||||
'slug' => 'own-event',
|
||||
]);
|
||||
|
||||
$response = $this->authenticatedRequest('GET', '/api/v1/tenant/events');
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$payload = collect($response->json('data'));
|
||||
|
||||
$this->assertTrue($payload->pluck('id')->contains($ownEvent->id), 'Authenticated tenant should see own event.');
|
||||
$this->assertFalse($payload->pluck('slug')->contains('foreign-event'), 'Events from other tenants must be filtered out.');
|
||||
}
|
||||
|
||||
public function test_index_handles_event_package_without_package_model(): void
|
||||
{
|
||||
$event = Event::factory()->for($this->tenant)->create([
|
||||
'name' => 'Legacy Event',
|
||||
'slug' => 'legacy-event',
|
||||
]);
|
||||
|
||||
$package = Package::factory()->create([
|
||||
'type' => 'endcustomer',
|
||||
'price' => 49,
|
||||
]);
|
||||
|
||||
EventPackage::create([
|
||||
'event_id' => $event->id,
|
||||
'package_id' => $package->id,
|
||||
'purchased_price' => $package->price,
|
||||
'purchased_at' => Carbon::now()->subDays(5),
|
||||
'gallery_expires_at' => Carbon::now()->addDays(15),
|
||||
'used_photos' => 10,
|
||||
'used_guests' => 25,
|
||||
]);
|
||||
|
||||
$package->delete();
|
||||
|
||||
$response = $this->authenticatedRequest('GET', '/api/v1/tenant/events');
|
||||
$response->assertOk();
|
||||
|
||||
$matchingEvent = collect($response->json('data'))->firstWhere('id', $event->id);
|
||||
|
||||
$this->assertNotNull($matchingEvent, 'Event should still be returned even if package record is missing.');
|
||||
$this->assertNull($matchingEvent['package'], 'Package payload should be null when relation cannot be resolved.');
|
||||
}
|
||||
}
|
||||
86
tests/Feature/Tenant/EventManagementTest.php
Normal file
86
tests/Feature/Tenant/EventManagementTest.php
Normal file
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Tenant;
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Models\EventType;
|
||||
use App\Models\Package;
|
||||
use App\Models\TenantPackage;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class EventManagementTest extends TenantTestCase
|
||||
{
|
||||
public function test_event_types_endpoint_returns_translated_types(): void
|
||||
{
|
||||
$types = EventType::factory()->count(2)->create([
|
||||
'name' => [
|
||||
'de' => 'Feier',
|
||||
'en' => 'Celebration',
|
||||
],
|
||||
'icon' => 'party',
|
||||
]);
|
||||
|
||||
$response = $this->authenticatedRequest('GET', '/api/v1/tenant/event-types');
|
||||
|
||||
$response->assertOk();
|
||||
$payload = $response->json('data');
|
||||
|
||||
$this->assertCount(2, $payload);
|
||||
$first = collect($payload)->firstWhere('id', $types->first()->id);
|
||||
|
||||
$this->assertNotNull($first, 'Expected event type to be present');
|
||||
$this->assertSame('Feier', $first['name']);
|
||||
$this->assertArrayHasKey('slug', $first);
|
||||
$this->assertArrayHasKey('name_translations', $first);
|
||||
$this->assertArrayHasKey('icon', $first);
|
||||
$this->assertArrayHasKey('settings', $first);
|
||||
}
|
||||
|
||||
public function test_event_can_be_created_with_event_type_and_date(): void
|
||||
{
|
||||
$eventType = EventType::factory()->create([
|
||||
'name' => [
|
||||
'de' => 'Hochzeit',
|
||||
'en' => 'Wedding',
|
||||
],
|
||||
'icon' => 'ring',
|
||||
]);
|
||||
|
||||
$package = Package::factory()->endcustomer()->create([
|
||||
'price' => 79.90,
|
||||
]);
|
||||
|
||||
TenantPackage::factory()
|
||||
->for($this->tenant)
|
||||
->for($package)
|
||||
->create([
|
||||
'used_events' => 0,
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
$this->tenant->update([
|
||||
'event_credits_balance' => 1,
|
||||
]);
|
||||
|
||||
$payload = [
|
||||
'name' => 'Launch Event',
|
||||
'slug' => 'launch-event',
|
||||
'event_type_id' => $eventType->id,
|
||||
'event_date' => Carbon::now()->addDays(10)->toDateString(),
|
||||
'status' => 'draft',
|
||||
];
|
||||
|
||||
$response = $this->authenticatedRequest('POST', '/api/v1/tenant/events', $payload);
|
||||
|
||||
$response->assertCreated();
|
||||
|
||||
$response->assertJsonPath('data.slug', 'launch-event');
|
||||
$response->assertJsonPath('data.event_type_id', $eventType->id);
|
||||
|
||||
$this->assertDatabaseHas(Event::class, [
|
||||
'slug' => 'launch-event',
|
||||
'event_type_id' => $eventType->id,
|
||||
'tenant_id' => $this->tenant->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
39
tests/e2e/event-admin-dashboard.test.ts
Normal file
39
tests/e2e/event-admin-dashboard.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { test, expectFixture as expect } from './utils/test-fixtures';
|
||||
|
||||
test.describe('Tenant Admin – core flows', () => {
|
||||
test('dashboard shows key sections for seeded tenant', async ({ signInTenantAdmin, page }) => {
|
||||
await signInTenantAdmin();
|
||||
|
||||
await expect(page).toHaveURL(/\/event-admin(\/welcome)?/);
|
||||
|
||||
if (page.url().includes('/event-admin/welcome')) {
|
||||
await page.getByRole('button', { name: /Direkt zum Dashboard/i }).click();
|
||||
}
|
||||
|
||||
await expect(page.getByRole('heading', { name: /Hallo/i })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /Neues Event/i })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /Guided Setup/i })).toBeVisible();
|
||||
|
||||
await expect(page.getByRole('heading', { name: /Hallo Lumen Moments!/i })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /Neues Event/i })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /Guided Setup/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('events overview lists published and draft events', async ({ signInTenantAdmin, page }) => {
|
||||
await signInTenantAdmin();
|
||||
await page.goto('/event-admin/events');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await expect(page.getByRole('heading', { name: /Deine Events/i })).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByRole('button', { name: /Neues Event/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('billing page lists the active package and history', async ({ signInTenantAdmin, page }) => {
|
||||
await signInTenantAdmin();
|
||||
await page.goto('/event-admin/billing');
|
||||
|
||||
await expect(page.getByRole('heading', { name: /Pakete & Abrechnung/i })).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByRole('heading', { name: /Paketübersicht/i })).toBeVisible();
|
||||
await expect(page.getByText(/Paket-Historie/)).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,6 @@
|
||||
import { test as base, expect, Page } from '@playwright/test';
|
||||
import 'dotenv/config';
|
||||
import { test as base, expect, Page, APIRequestContext } from '@playwright/test';
|
||||
import { randomBytes, createHash } from 'node:crypto';
|
||||
|
||||
export type TenantCredentials = {
|
||||
email: string;
|
||||
@@ -10,8 +12,8 @@ export type TenantAdminFixtures = {
|
||||
signInTenantAdmin: () => Promise<void>;
|
||||
};
|
||||
|
||||
const tenantAdminEmail = process.env.E2E_TENANT_EMAIL;
|
||||
const tenantAdminPassword = process.env.E2E_TENANT_PASSWORD;
|
||||
const tenantAdminEmail = process.env.E2E_TENANT_EMAIL ?? 'hello@lumen-moments.demo';
|
||||
const tenantAdminPassword = process.env.E2E_TENANT_PASSWORD ?? 'Demo1234!';
|
||||
|
||||
export const test = base.extend<TenantAdminFixtures>({
|
||||
tenantAdminCredentials: async ({}, use) => {
|
||||
@@ -42,10 +44,93 @@ export const test = base.extend<TenantAdminFixtures>({
|
||||
|
||||
export const expectFixture = expect;
|
||||
|
||||
async function performTenantSignIn(page: Page, credentials: TenantCredentials) {
|
||||
await page.goto('/event-admin/login');
|
||||
await page.fill('input[name="email"]', credentials.email);
|
||||
await page.fill('input[name="password"]', credentials.password);
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForURL(/\/event-admin(\/welcome)?/);
|
||||
const clientId = process.env.VITE_OAUTH_CLIENT_ID ?? 'tenant-admin-app';
|
||||
const redirectUri = new URL('/event-admin/auth/callback', process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:8000').toString();
|
||||
const scopes = (process.env.VITE_OAUTH_SCOPES as string | undefined) ?? 'tenant:read tenant:write';
|
||||
|
||||
async function performTenantSignIn(page: Page, _credentials: TenantCredentials) {
|
||||
const tokens = await exchangeTokens(page.request);
|
||||
|
||||
await page.addInitScript(({ stored }) => {
|
||||
localStorage.setItem('tenant_oauth_tokens.v1', JSON.stringify(stored));
|
||||
}, { stored: tokens });
|
||||
|
||||
await page.goto('/event-admin');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
}
|
||||
|
||||
type StoredTokenPayload = {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresAt: number;
|
||||
scope?: string;
|
||||
};
|
||||
|
||||
async function exchangeTokens(request: APIRequestContext): Promise<StoredTokenPayload> {
|
||||
const verifier = generateCodeVerifier();
|
||||
const challenge = generateCodeChallenge(verifier);
|
||||
const state = randomBytes(12).toString('hex');
|
||||
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
scope: scopes,
|
||||
state,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: 'S256',
|
||||
});
|
||||
|
||||
const authResponse = await request.get(`/api/v1/oauth/authorize?${params.toString()}`, {
|
||||
maxRedirects: 0,
|
||||
headers: {
|
||||
'x-playwright-test': 'tenant-admin',
|
||||
},
|
||||
});
|
||||
|
||||
if (authResponse.status() >= 400) {
|
||||
throw new Error(`OAuth authorize failed: ${authResponse.status()} ${await authResponse.text()}`);
|
||||
}
|
||||
|
||||
const location = authResponse.headers()['location'];
|
||||
if (!location) {
|
||||
throw new Error('OAuth authorize did not return redirect location');
|
||||
}
|
||||
|
||||
const code = new URL(location).searchParams.get('code');
|
||||
if (!code) {
|
||||
throw new Error('OAuth authorize response missing code');
|
||||
}
|
||||
|
||||
const tokenResponse = await request.post('/api/v1/oauth/token', {
|
||||
form: {
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
code_verifier: verifier,
|
||||
},
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok()) {
|
||||
throw new Error(`OAuth token exchange failed: ${tokenResponse.status()} ${await tokenResponse.text()}`);
|
||||
}
|
||||
|
||||
const body = await tokenResponse.json();
|
||||
const expiresIn = typeof body.expires_in === 'number' ? body.expires_in : 3600;
|
||||
|
||||
return {
|
||||
accessToken: body.access_token,
|
||||
refreshToken: body.refresh_token,
|
||||
expiresAt: Date.now() + Math.max(expiresIn - 30, 0) * 1000,
|
||||
scope: body.scope,
|
||||
};
|
||||
}
|
||||
|
||||
function generateCodeVerifier(): string {
|
||||
return randomBytes(32).toString('base64url');
|
||||
}
|
||||
|
||||
function generateCodeChallenge(verifier: string): string {
|
||||
return createHash('sha256').update(verifier).digest('base64url');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user