- Added public gallery API with token-expiry enforcement, branding payload, cursor pagination, and per-photo download stream (app/Http/Controllers/Api/EventPublicController.php:1, routes/api.php:16). 410 is returned when the package gallery duration has lapsed.

- Served the guest PWA at /g/{token} and introduced a mobile-friendly gallery page with lazy-loaded thumbnails, themed colors, lightbox, and download links plus new gallery data client (resources/js/guest/pages/PublicGalleryPage.tsx:1, resources/js/guest/services/galleryApi.ts:1, resources/js/guest/router.tsx:1). Added i18n strings for the public gallery experience (resources/js/guest/i18n/messages.ts:1).
- Ensured checkout step changes snap back to the progress bar on mobile via smooth scroll anchoring (resources/ js/pages/marketing/checkout/CheckoutWizard.tsx:1).
- Enabled tenant admins to export all approved event photos through a new download action that streams a ZIP archive, with translations and routing in place (app/Http/Controllers/Tenant/EventPhotoArchiveController.php:1, app/Filament/Resources/EventResource.php:1, routes/web.php:1, resources/lang/de/admin.php:1, resources/lang/en/admin.php:1).
This commit is contained in:
Codex Agent
2025-10-17 23:24:06 +02:00
parent 5817270c35
commit ae9b9160ac
20 changed files with 1410 additions and 3 deletions

17
.dockerignore Normal file
View File

@@ -0,0 +1,17 @@
node_modules
vendor
storage/framework/cache/*.php
storage/framework/sessions/*
storage/framework/testing/*
storage/logs/*
public/hot
public/storage
.git
.gitignore
.env
.env.*
npm-debug.log
yarn-error.log
docker-compose.override.yml
docs/queue-supervisor/*.log

98
Dockerfile Normal file
View File

@@ -0,0 +1,98 @@
# syntax=docker/dockerfile:1.6
ARG PHP_VERSION=8.3
ARG NODE_VERSION=20
################################################################################
# Node build stage - compile front-end assets
################################################################################
FROM node:${NODE_VERSION}-bookworm AS node_builder
WORKDIR /var/www/html
COPY package.json package-lock.json ./
RUN npm ci --no-audit --prefer-offline
COPY . .
RUN npm run build
################################################################################
# Composer dependencies
################################################################################
FROM composer:2 AS vendor
WORKDIR /var/www/html
COPY composer.json composer.lock ./
# Install production dependencies only, skip scripts (they run at runtime)
RUN composer install \
--no-dev \
--no-scripts \
--no-interaction \
--prefer-dist \
--optimize-autoloader
################################################################################
# PHP-FPM runtime image
################################################################################
FROM php:${PHP_VERSION}-fpm-bullseye AS app
ARG UID=1000
ARG GID=1000
ENV APP_ENV=production \
APP_DEBUG=false \
PHP_OPCACHE_VALIDATE_TIMESTAMPS=0
WORKDIR /opt/app
# Install system dependencies & PHP extensions
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
git \
curl \
libjpeg62-turbo-dev \
libpng-dev \
libfreetype6-dev \
libzip-dev \
libonig-dev \
libicu-dev \
libxml2-dev \
unzip \
nano \
rsync \
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install -j$(nproc) \
bcmath \
exif \
gd \
intl \
opcache \
pcntl \
pdo_mysql \
zip \
&& pecl install redis \
&& docker-php-ext-enable redis \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Copy application source
COPY . .
# Copy vendor dependencies and build artifacts
COPY --from=vendor /var/www/html/vendor ./vendor
COPY --from=node_builder /var/www/html/public/build ./public/build
# Copy production php.ini overrides if present
COPY docker/php/php.ini /usr/local/etc/php/conf.d/app.ini
COPY docker/php/opcache.ini /usr/local/etc/php/conf.d/opcache.ini
# Entrypoint prepares deployment directory on persistent volume
COPY docker/app/entrypoint.sh /usr/local/bin/app-entrypoint
RUN chmod +x /usr/local/bin/app-entrypoint
EXPOSE 9000
ENTRYPOINT ["app-entrypoint"]
CMD ["php-fpm"]

View File

@@ -134,6 +134,12 @@ class EventResource extends Resource
->label(__('admin.events.actions.toggle_active'))
->icon('heroicon-o-power')
->action(fn ($record) => $record->update(['is_active' => ! $record->is_active])),
Actions\Action::make('download_photos')
->label(__('admin.events.actions.download_photos'))
->icon('heroicon-o-arrow-down-tray')
->url(fn (Event $record) => route('tenant.events.photos.archive', $record))
->openUrlInNewTab()
->visible(fn (Event $record) => $record->photos()->where('status', 'approved')->exists()),
Actions\Action::make('join_tokens')
->label(__('admin.events.actions.join_link_qr'))
->icon('heroicon-o-qr-code')

View File

@@ -10,7 +10,7 @@ use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Actions\ViewAction;
use Filament\Forms\Components\Section;
use Filament\Schemas\Components\Section;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;

View File

@@ -2,6 +2,7 @@
namespace App\Http\Controllers\Api;
use Carbon\Carbon;
use Carbon\CarbonImmutable;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller as BaseController;
@@ -17,6 +18,9 @@ use App\Services\Storage\EventStorageManager;
use App\Models\Event;
use App\Models\EventJoinToken;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Arr;
use App\Models\Photo;
use App\Models\EventMediaAsset;
class EventPublicController extends BaseController
{
@@ -103,6 +107,45 @@ class EventPublicController extends BaseController
return [$event, $joinToken];
}
/**
* @return JsonResponse|array{0: Event, 1: EventJoinToken}
*/
private function resolveGalleryEvent(Request $request, string $token): JsonResponse|array
{
$result = $this->resolvePublishedEvent($request, $token, ['id']);
if ($result instanceof JsonResponse) {
return $result;
}
[$eventRecord, $joinToken] = $result;
$event = Event::with(['tenant', 'eventPackage.package'])->find($eventRecord->id);
if (! $event) {
return response()->json([
'error' => [
'code' => 'event_not_found',
'message' => 'The event associated with this gallery could not be located.',
],
], Response::HTTP_NOT_FOUND);
}
$expiresAt = optional($event->eventPackage)->gallery_expires_at;
if ($expiresAt instanceof Carbon && $expiresAt->isPast()) {
return response()->json([
'error' => [
'code' => 'gallery_expired',
'message' => 'The gallery is no longer available for this event.',
'expired_at' => $expiresAt->toIso8601String(),
],
], Response::HTTP_GONE);
}
return [$event, $joinToken];
}
private function handleTokenFailure(Request $request, string $rateLimiterKey, string $code, int $status, array $context = []): JsonResponse
{
if (RateLimiter::tooManyAttempts($rateLimiterKey, 10)) {
@@ -175,6 +218,251 @@ class EventPublicController extends BaseController
return $path; // fallback as-is
}
private function translateLocalized(string|array|null $value, string $locale, string $fallback = ''): string
{
if (is_array($value)) {
$primary = $value[$locale] ?? $value['de'] ?? $value['en'] ?? null;
return $primary ?? (reset($value) ?: $fallback);
}
if (is_string($value) && $value !== '') {
return $value;
}
return $fallback;
}
private function buildGalleryBranding(Event $event): array
{
$defaultPrimary = '#f43f5e';
$defaultSecondary = '#fb7185';
$defaultBackground = '#ffffff';
$eventBranding = Arr::get($event->settings, 'branding', []);
$tenantBranding = Arr::get($event->tenant?->settings, 'branding', []);
return [
'primary_color' => Arr::get($eventBranding, 'primary_color')
?? Arr::get($tenantBranding, 'primary_color')
?? $defaultPrimary,
'secondary_color' => Arr::get($eventBranding, 'secondary_color')
?? Arr::get($tenantBranding, 'secondary_color')
?? $defaultSecondary,
'background_color' => Arr::get($eventBranding, 'background_color')
?? Arr::get($tenantBranding, 'background_color')
?? $defaultBackground,
];
}
private function encodeGalleryCursor(Photo $photo): string
{
return base64_encode(json_encode([
'id' => $photo->id,
'created_at' => $photo->created_at?->toIso8601String(),
]));
}
private function decodeGalleryCursor(?string $cursor): ?array
{
if (! $cursor) {
return null;
}
$decoded = json_decode(base64_decode($cursor, true) ?: '', true);
if (! is_array($decoded) || ! isset($decoded['id'], $decoded['created_at'])) {
return null;
}
try {
return [
'id' => (int) $decoded['id'],
'created_at' => Carbon::parse($decoded['created_at']),
];
} catch (\Throwable $e) {
return null;
}
}
private function makeGalleryPhotoResource(Photo $photo, string $token): array
{
$thumbnail = $this->toPublicUrl($photo->thumbnail_path ?? null) ?? $this->toPublicUrl($photo->file_path ?? null);
$full = $this->toPublicUrl($photo->file_path ?? null);
return [
'id' => $photo->id,
'thumbnail_url' => $thumbnail,
'full_url' => $full,
'download_url' => route('api.v1.gallery.photos.download', [
'token' => $token,
'photo' => $photo->id,
]),
'likes_count' => $photo->likes_count,
'guest_name' => $photo->guest_name,
'created_at' => $photo->created_at?->toIso8601String(),
];
}
public function gallery(Request $request, string $token)
{
$locale = $request->query('locale', app()->getLocale());
$resolved = $this->resolveGalleryEvent($request, $token);
if ($resolved instanceof JsonResponse) {
return $resolved;
}
/** @var array{0: Event, 1: EventJoinToken} $resolved */
[$event] = $resolved;
$branding = $this->buildGalleryBranding($event);
$expiresAt = optional($event->eventPackage)->gallery_expires_at;
return response()->json([
'event' => [
'id' => $event->id,
'name' => $this->translateLocalized($event->name, $locale, 'Fotospiel Event'),
'slug' => $event->slug,
'description' => $this->translateLocalized($event->description, $locale, ''),
'gallery_expires_at' => $expiresAt?->toIso8601String(),
],
'branding' => $branding,
]);
}
public function galleryPhotos(Request $request, string $token)
{
$resolved = $this->resolveGalleryEvent($request, $token);
if ($resolved instanceof JsonResponse) {
return $resolved;
}
/** @var array{0: Event, 1: EventJoinToken} $resolved */
[$event] = $resolved;
$limit = (int) $request->query('limit', 30);
$limit = max(1, min($limit, 60));
$cursor = $this->decodeGalleryCursor($request->query('cursor'));
$query = Photo::query()
->where('event_id', $event->id)
->where('status', 'approved')
->orderByDesc('created_at')
->orderByDesc('id');
if ($cursor) {
/** @var Carbon $cursorTime */
$cursorTime = $cursor['created_at'];
$cursorId = $cursor['id'];
$query->where(function ($inner) use ($cursorTime, $cursorId) {
$inner->where('created_at', '<', $cursorTime)
->orWhere(function ($nested) use ($cursorTime, $cursorId) {
$nested->where('created_at', '=', $cursorTime)
->where('id', '<', $cursorId);
});
});
}
$photos = $query->limit($limit + 1)->get();
$hasMore = $photos->count() > $limit;
$items = $photos->take($limit);
$nextCursor = null;
if ($hasMore) {
$cursorPhoto = $photos->slice($limit, 1)->first();
if ($cursorPhoto instanceof Photo) {
$nextCursor = $this->encodeGalleryCursor($cursorPhoto);
}
}
return response()->json([
'data' => $items->map(fn (Photo $photo) => $this->makeGalleryPhotoResource($photo, $token))->all(),
'next_cursor' => $nextCursor,
]);
}
public function galleryPhotoDownload(Request $request, string $token, int $photo)
{
$resolved = $this->resolveGalleryEvent($request, $token);
if ($resolved instanceof JsonResponse) {
return $resolved;
}
/** @var array{0: Event, 1: EventJoinToken} $resolved */
[$event] = $resolved;
$record = Photo::with('mediaAsset')
->where('id', $photo)
->where('event_id', $event->id)
->where('status', 'approved')
->first();
if (! $record) {
return response()->json([
'error' => [
'code' => 'photo_not_found',
'message' => 'The requested photo is no longer available.',
],
], Response::HTTP_NOT_FOUND);
}
$asset = $record->mediaAsset ?? EventMediaAsset::where('photo_id', $record->id)->where('variant', 'original')->first();
if ($asset) {
$disk = $asset->disk ?? config('filesystems.default');
$path = $asset->path ?? $record->file_path;
try {
if ($path && Storage::disk($disk)->exists($path)) {
$stream = Storage::disk($disk)->readStream($path);
if ($stream) {
$extension = pathinfo($path, PATHINFO_EXTENSION) ?: 'jpg';
$filename = sprintf('fotospiel-event-%s-photo-%s.%s', $event->id, $record->id, $extension);
$mime = $asset->mime_type ?? 'image/jpeg';
return response()->streamDownload(function () use ($stream) {
fpassthru($stream);
fclose($stream);
}, $filename, [
'Content-Type' => $mime,
]);
}
}
} catch (\Throwable $e) {
Log::warning('Gallery photo download failed', [
'event_id' => $event->id,
'photo_id' => $record->id,
'disk' => $asset->disk ?? null,
'path' => $asset->path ?? null,
'error' => $e->getMessage(),
]);
}
}
$publicUrl = $this->toPublicUrl($record->file_path ?? null);
if ($publicUrl) {
return redirect()->away($publicUrl);
}
return response()->json([
'error' => [
'code' => 'photo_unavailable',
'message' => 'The requested photo could not be downloaded.',
],
], Response::HTTP_NOT_FOUND);
}
public function event(Request $request, string $token)
{
$result = $this->resolvePublishedEvent($request, $token, [

View File

@@ -0,0 +1,132 @@
<?php
namespace App\Http\Controllers\Tenant;
use App\Http\Controllers\Controller;
use App\Models\Event;
use App\Models\EventMediaAsset;
use App\Models\Photo;
use Illuminate\Contracts\Filesystem\FileNotFoundException;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
use ZipArchive;
class EventPhotoArchiveController extends Controller
{
public function __invoke(Request $request, Event $event): StreamedResponse
{
$user = Auth::user();
if (! $user || (int) $user->tenant_id !== (int) $event->tenant_id) {
abort(403, 'Unauthorized');
}
$photos = Photo::query()
->with('mediaAsset')
->where('event_id', $event->id)
->where('status', 'approved')
->orderBy('created_at')
->get();
if ($photos->isEmpty()) {
abort(404, 'No approved photos available for this event.');
}
$zip = new ZipArchive();
$tempPath = tempnam(sys_get_temp_dir(), 'fotospiel-photos-');
if ($tempPath === false || $zip->open($tempPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
abort(500, 'Unable to generate archive.');
}
foreach ($photos as $photo) {
$filename = $this->buildFilename($event, $photo);
$asset = $photo->mediaAsset ?? EventMediaAsset::query()
->where('photo_id', $photo->id)
->where('variant', 'original')
->first();
$added = false;
if ($asset && $asset->path) {
$added = $this->addDiskFileToArchive($zip, $asset->disk ?? config('filesystems.default'), $asset->path, $filename);
}
if (! $added && $photo->file_path) {
$added = $this->addDiskFileToArchive($zip, config('filesystems.default'), $photo->file_path, $filename);
}
if (! $added) {
Log::warning('Skipping photo in archive build', [
'event_id' => $event->id,
'photo_id' => $photo->id,
]);
}
}
$zip->close();
$downloadName = sprintf('fotospiel-event-%s-photos.zip', $event->slug ?? $event->id);
return response()->streamDownload(function () use ($tempPath) {
$stream = fopen($tempPath, 'rb');
if (! $stream) {
throw new FileNotFoundException('Archive could not be opened.');
}
fpassthru($stream);
fclose($stream);
unlink($tempPath);
}, $downloadName, [
'Content-Type' => 'application/zip',
]);
}
private function buildFilename(Event $event, Photo $photo): string
{
$timestamp = $photo->created_at?->format('Ymd_His') ?? now()->format('Ymd_His');
$extension = pathinfo($photo->file_path ?? '', PATHINFO_EXTENSION) ?: 'jpg';
return sprintf('%s-photo-%d.%s', $timestamp, $photo->id, $extension);
}
private function addDiskFileToArchive(ZipArchive $zip, ?string $diskName, string $path, string $filename): bool
{
$disk = $diskName ? Storage::disk($diskName) : Storage::disk(config('filesystems.default'));
try {
if (method_exists($disk, 'path')) {
$absolute = $disk->path($path);
if (is_file($absolute)) {
return $zip->addFile($absolute, $filename);
}
}
$stream = $disk->readStream($path);
if ($stream) {
$contents = stream_get_contents($stream);
fclose($stream);
if ($contents !== false) {
return $zip->addFromString($filename, $contents);
}
}
} catch (\Throwable $e) {
Log::warning('Failed to add file to archive', [
'disk' => $diskName,
'path' => $path,
'error' => $e->getMessage(),
]);
}
return false;
}
}

137
docker-compose.yml Normal file
View File

@@ -0,0 +1,137 @@
version: "3.9"
services:
app:
build:
context: .
dockerfile: Dockerfile
args:
PHP_VERSION: 8.3
NODE_VERSION: 20
image: fotospiel-app:latest
env_file:
- docker/.env.docker
environment:
APP_ENV: ${APP_ENV:-production}
APP_DEBUG: ${APP_DEBUG:-false}
APP_SOURCE: /opt/app
APP_TARGET: /var/www/html
APP_USER: www-data
APP_GROUP: www-data
volumes:
- app-code:/var/www/html
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_started
restart: unless-stopped
web:
image: nginx:1.25-alpine
depends_on:
- app
volumes:
- app-code:/var/www/html:ro
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
ports:
- "${APP_HTTP_PORT:-8080}:80"
restart: unless-stopped
queue:
image: fotospiel-app:latest
command: /var/www/html/docs/queue-supervisor/queue-worker.sh default
env_file:
- docker/.env.docker
environment:
APP_ENV: ${APP_ENV:-production}
volumes:
- app-code:/var/www/html
depends_on:
- app
- redis
restart: unless-stopped
media-storage-worker:
image: fotospiel-app:latest
command: /var/www/html/docs/queue-supervisor/queue-worker.sh media-storage
env_file:
- docker/.env.docker
environment:
APP_ENV: ${APP_ENV:-production}
QUEUE_TRIES: 5
QUEUE_SLEEP: 5
volumes:
- app-code:/var/www/html
depends_on:
- app
- redis
restart: unless-stopped
scheduler:
image: fotospiel-app:latest
command: php artisan schedule:work
env_file:
- docker/.env.docker
environment:
APP_ENV: ${APP_ENV:-production}
volumes:
- app-code:/var/www/html
depends_on:
- app
restart: unless-stopped
horizon:
image: fotospiel-app:latest
command: /var/www/html/docs/queue-supervisor/horizon.sh
env_file:
- docker/.env.docker
environment:
APP_ENV: ${APP_ENV:-production}
volumes:
- app-code:/var/www/html
depends_on:
- app
- redis
restart: unless-stopped
profiles: ["horizon"]
redis:
image: redis:7.4-alpine
command: ["redis-server", "--save", "", "--appendonly", "no"]
volumes:
- redis-data:/data
ports:
- "${APP_REDIS_PORT:-6379}:6379"
restart: unless-stopped
mysql:
image: mysql:8.4
environment:
MYSQL_DATABASE: ${DB_DATABASE:-fotospiel}
MYSQL_USER: ${DB_USERNAME:-fotospiel}
MYSQL_PASSWORD: ${DB_PASSWORD:-secret}
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-secret-root}
volumes:
- mysql-data:/var/lib/mysql
ports:
- "${APP_DB_PORT:-3306}:3306"
healthcheck:
test:
[
"CMD",
"mysqladmin",
"ping",
"-h",
"127.0.0.1",
"-p${DB_PASSWORD:-secret}"
]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
volumes:
app-code:
mysql-data:
redis-data:

109
docs/deployment/docker.md Normal file
View File

@@ -0,0 +1,109 @@
# Docker Deployment Guide
This guide describes the recommended, repeatable way to run the Fotospiel platform in Docker for production or high-fidelity staging environments. It pairs a multi-stage build (PHP-FPM + asset pipeline) with a Compose stack that includes Nginx, worker processes, Redis, and MySQL.
## 1. Prerequisites
- Docker Engine 24+ and Docker Compose v2.
- A `.env` file for the application (see step 4).
- Optional: an external MySQL/Redis if you do not want to run the bundled containers.
## 2. Build the application image
```bash
docker compose build app
```
The build performs the following steps:
1. Installs Node dependencies and runs `npm run build` to produce production assets.
2. Installs PHP dependencies with Composer (`--no-dev --no-scripts`).
3. Creates a PHP 8.3 FPM image with required extensions (GD, intl, Redis, etc.).
4. Stores the compiled application under `/opt/app`; the runtime entrypoint syncs it into the shared volume when a container starts.
## 3. Configure environment
Copy the sample Docker environment file and edit the secrets:
```bash
cp docker/.env.docker docker/.env.docker.local
```
Set (at minimum):
- `APP_KEY` — generate with `docker compose run --rm app php artisan key:generate --show`.
- Database credentials (`DB_*`). The provided MySQL service defaults to `fotospiel/secret`.
- `STORAGE_ALERT_EMAIL` — recipient for upload failure alerts (optional).
Point `docker-compose.yml` to the file you created by either renaming it to `docker/.env.docker` or adjusting the `env_file` entries.
## 4. Boot the stack
```bash
docker compose up -d
```
Services started:
- `app`: PHP-FPM container serving the Laravel application.
- `web`: Nginx proxy forwarding requests to PHP-FPM on port `8080` by default (`APP_HTTP_PORT`).
- `queue` & `media-storage-worker`: queue consumers (default + media archival).
- `scheduler`: runs `php artisan schedule:work`.
- `horizon` (optional, disabled unless `--profile horizon` is supplied).
- `redis` & `mysql`.
### Migrations & seeds
Run once after the first boot or when deploying new schema changes:
```bash
docker compose exec app php artisan migrate --force
docker compose exec app php artisan db:seed --class=MediaStorageTargetSeeder --force
```
If you already have data, skip the seeder or seed only new records.
## 5. Queue & Horizon management
Worker entrypoints live in `docs/queue-supervisor/`. The Compose services mount the same application volume so code stays in sync. Adjust concurrency by scaling services:
```bash
docker compose up -d --scale queue=2 --scale media-storage-worker=2
```
To enable Horizon (dashboard, smart balancing):
```bash
docker compose --profile horizon up -d horizon
```
The dashboard becomes available at `/horizon` and is protected by the Filament super-admin auth guard.
## 6. Persistent data & volumes
- `app-code` — contains the synced application, including the `storage` directory and generated assets.
- `mysql-data` — MySQL data files.
- `redis-data` — Redis persistence (disabled by default; change the Redis command if you want AOF snapshots).
Back up the volumes before upgrades to maintain tenant media and database state.
## 7. Updating the stack
1. `git pull` the repository (or deploy your release branch).
2. `docker compose build app`.
3. `docker compose up -d`.
4. Run migrations + seeders if required.
5. Check logs: `docker compose logs -f app queue media-storage-worker`.
Because the app image keeps the authoritative copy of the code, each container restart rsyncs fresh sources into the shared volume ensuring reliable updates without lingering artefacts.
## 8. Production hardening
- Terminate TLS with a dedicated reverse proxy (Traefik, Caddy, AWS ALB, etc.) in front of the `web` container.
- Point `APP_URL` to your public domain and enable trusted proxies.
- Externalize MySQL/Redis to managed services for better resilience.
- Configure backups for the `storage` directories and database dumps.
- Hook into your observability stack (e.g., ship container logs to Loki or ELK).
With the provided configuration you can bootstrap a consistent Docker-based deployment across environments while keeping queue workers, migrations, and asset builds manageable. Adjust service definitions as needed for staging vs. production.

View File

@@ -0,0 +1,103 @@
## Docker Queue & Horizon Setup
This directory bundles ready-to-use entrypoint scripts and deployment notes for running Fotospiels queue workers inside Docker containers. The examples assume you already run the main application in Docker (e.g. via `docker-compose.yml`) and share the same application image for workers.
### 1. Prepare the application image
Make sure the worker scripts are copied into the image and marked as executable:
```dockerfile
# Dockerfile
COPY docs/queue-supervisor /var/www/html/docs/queue-supervisor
RUN chmod +x /var/www/html/docs/queue-supervisor/*.sh
```
If you keep the project root mounted as a volume during development the `chmod` step can be skipped because the files will inherit host permissions.
### 2. Queue worker containers
Add one or more worker services to `docker-compose.yml`. The production compose file in the repo already defines `queue` and `media-storage-worker` services that call these scripts; the snippet below shows the essential pattern if you need to tweak scaling.
```yaml
services:
queue-worker:
image: fotospiel-app # reuse the main app image
restart: unless-stopped
depends_on:
- redis # or your queue backend
environment:
APP_ENV: ${APP_ENV:-production}
QUEUE_CONNECTION: redis
QUEUE_TRIES: 3 # optional overrides
QUEUE_SLEEP: 3
command: >
/var/www/html/docs/queue-supervisor/queue-worker.sh default
media-storage-worker:
image: fotospiel-app
restart: unless-stopped
depends_on:
- redis
environment:
APP_ENV: ${APP_ENV:-production}
QUEUE_CONNECTION: redis
QUEUE_TRIES: 5
QUEUE_SLEEP: 5
command: >
/var/www/html/docs/queue-supervisor/queue-worker.sh media-storage
```
Scale workers by increasing `deploy.replicas` (Swarm) or adding `scale` counts (Compose v2).
### 3. Optional: Horizon container
If you prefer Horizons dashboard and auto-balancing, add another service:
```yaml
services:
horizon:
image: fotospiel-app
restart: unless-stopped
depends_on:
- redis
environment:
APP_ENV: ${APP_ENV:-production}
QUEUE_CONNECTION: redis
command: >
/var/www/html/docs/queue-supervisor/horizon.sh
```
Expose Horizon via your web proxy and protect it with authentication (the app already guards `/horizon` behind the super admin panel login if configured).
### 4. Environment variables
- `QUEUE_CONNECTION` — should match the driver configured in `.env` (`redis` recommended).
- `QUEUE_TRIES`, `QUEUE_SLEEP`, `QUEUE_TIMEOUT`, `QUEUE_MAX_TIME` — optional tuning knobs consumed by `queue-worker.sh`.
- `STORAGE_ALERT_EMAIL` — enables upload failure notifications introduced in the new storage pipeline.
- Redis / database credentials must be available in the worker containers exactly like the web container.
### 5. Bootstrapping reminder
Before starting workers on a new environment:
```bash
php artisan migrate
php artisan db:seed --class=MediaStorageTargetSeeder
```
Existing assets should be backfilled into `event_media_assets` with a one-off artisan command before enabling automatic archival jobs.
### 6. Monitoring & logs
- Containers log to STDOUT; aggregate via `docker logs` or a centralized stack.
- Horizon users can inspect `/horizon` for queue lengths and failed jobs.
- With plain workers run `php artisan queue:failed` (inside the container) to inspect failures and `php artisan queue:retry all` after resolving issues.
### 7. Rolling updates
When deploying new code:
1. Build and push updated app image.
2. Run migrations & seeders.
3. Recreate worker/horizon containers: `docker compose up -d --force-recreate queue-worker media-storage-worker horizon`.
4. Tail logs to confirm workers boot cleanly and start consuming jobs.

View File

@@ -0,0 +1,11 @@
#!/usr/bin/env bash
# Horizon entrypoint for Docker containers.
set -euo pipefail
cd "${APP_PATH:-/var/www/html}"
echo "[horizon] Booting Horizon..."
exec php artisan horizon

View File

@@ -0,0 +1,27 @@
#!/usr/bin/env bash
# Generic queue worker entrypoint for Docker containers.
# Usage: queue-worker.sh [queue-name(s)]
# Example: queue-worker.sh default
# queue-worker.sh default,media-storage
set -euo pipefail
cd "${APP_PATH:-/var/www/html}"
CONNECTION="${QUEUE_CONNECTION:-redis}"
QUEUES="${1:-default}"
SLEEP="${QUEUE_SLEEP:-3}"
TRIES="${QUEUE_TRIES:-3}"
TIMEOUT="${QUEUE_TIMEOUT:-60}"
MAX_TIME="${QUEUE_MAX_TIME:-0}"
ARGS=("$CONNECTION" "--queue=${QUEUES}" "--sleep=${SLEEP}" "--tries=${TRIES}" "--timeout=${TIMEOUT}")
if [[ "${MAX_TIME}" != "0" ]]; then
ARGS+=("--max-time=${MAX_TIME}")
fi
echo "[queue-worker] Starting queue:work ${ARGS[*]}"
exec php artisan queue:work "${ARGS[@]}"

View File

@@ -178,6 +178,19 @@ export const messages: Record<LocaleCode, NestedMessages> = {
title: 'Nicht gefunden',
description: 'Die Seite konnte nicht gefunden werden.',
},
galleryPublic: {
title: 'Galerie',
loading: 'Galerie wird geladen ...',
loadingMore: 'Weitere Fotos werden geladen',
loadError: 'Die Galerie konnte nicht geladen werden.',
loadMore: 'Mehr anzeigen',
download: 'Herunterladen',
expiredTitle: 'Galerie nicht verfügbar',
expiredDescription: 'Die Galerie für dieses Event ist abgelaufen.',
emptyTitle: 'Noch keine Fotos',
emptyDescription: 'Sobald Fotos freigegeben sind, erscheinen sie hier.',
lightboxGuestFallback: 'Gast',
},
uploadQueue: {
title: 'Uploads',
description: 'Warteschlange mit Fortschritt und erneuten Versuchen; Hintergrund-Sync umschalten.',
@@ -510,6 +523,19 @@ export const messages: Record<LocaleCode, NestedMessages> = {
title: 'Not found',
description: 'We could not find the page you requested.',
},
galleryPublic: {
title: 'Gallery',
loading: 'Loading gallery ...',
loadingMore: 'Loading more photos',
loadError: 'The gallery could not be loaded.',
loadMore: 'Show more',
download: 'Download',
expiredTitle: 'Gallery unavailable',
expiredDescription: 'The gallery for this event has expired.',
emptyTitle: 'No photos yet',
emptyDescription: 'Once photos are approved they will appear here.',
lightboxGuestFallback: 'Guest',
},
uploadQueue: {
title: 'Uploads',
description: 'Queue with progress/retry and background sync toggle.',

View File

@@ -0,0 +1,333 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useParams } from 'react-router-dom';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogFooter } from '@/components/ui/dialog';
import { fetchGalleryMeta, fetchGalleryPhotos, type GalleryMetaResponse, type GalleryPhotoResource } from '../services/galleryApi';
import { useTranslation } from '../i18n/useTranslation';
import { DEFAULT_LOCALE, isLocaleCode } from '../i18n/messages';
import { AlertTriangle, Download, Loader2, X } from 'lucide-react';
interface GalleryState {
meta: GalleryMetaResponse | null;
photos: GalleryPhotoResource[];
cursor: string | null;
loading: boolean;
loadingMore: boolean;
error: string | null;
expired: boolean;
}
const INITIAL_STATE: GalleryState = {
meta: null,
photos: [],
cursor: null,
loading: true,
loadingMore: false,
error: null,
expired: false,
};
const GALLERY_PAGE_SIZE = 30;
export default function PublicGalleryPage(): JSX.Element | null {
const { token } = useParams<{ token: string }>();
const { t } = useTranslation();
const [state, setState] = useState<GalleryState>(INITIAL_STATE);
const [lightboxOpen, setLightboxOpen] = useState(false);
const [selectedPhoto, setSelectedPhoto] = useState<GalleryPhotoResource | null>(null);
const sentinelRef = useRef<HTMLDivElement | null>(null);
const localeStorageKey = token ? `guestGalleryLocale_${token}` : 'guestGalleryLocale';
const storedLocale = typeof window !== 'undefined' && token ? localStorage.getItem(localeStorageKey) : null;
const effectiveLocale = storedLocale && isLocaleCode(storedLocale as any) ? (storedLocale as any) : DEFAULT_LOCALE;
const applyMeta = useCallback((meta: GalleryMetaResponse) => {
if (typeof window !== 'undefined' && token) {
localStorage.setItem(localeStorageKey, effectiveLocale);
}
setState((prev) => ({
...prev,
meta,
}));
}, [effectiveLocale, localeStorageKey, token]);
const loadInitial = useCallback(async () => {
if (!token) {
return;
}
setState((prev) => ({ ...prev, loading: true, error: null, expired: false, photos: [], cursor: null }));
try {
const meta = await fetchGalleryMeta(token, effectiveLocale);
applyMeta(meta);
const photoResponse = await fetchGalleryPhotos(token, null, GALLERY_PAGE_SIZE);
setState((prev) => ({
...prev,
loading: false,
photos: photoResponse.data,
cursor: photoResponse.next_cursor,
}));
} catch (error) {
const err = error as Error & { code?: string | number };
if (err.code === 'gallery_expired' || err.code === 410) {
setState((prev) => ({ ...prev, loading: false, expired: true }));
} else {
setState((prev) => ({
...prev,
loading: false,
error: err.message || t('galleryPublic.loadError'),
}));
}
}
}, [token, applyMeta, effectiveLocale, t]);
useEffect(() => {
loadInitial();
}, [loadInitial]);
const loadMore = useCallback(async () => {
if (!token || !state.cursor || state.loadingMore) {
return;
}
setState((prev) => ({ ...prev, loadingMore: true }));
try {
const response = await fetchGalleryPhotos(token, state.cursor, GALLERY_PAGE_SIZE);
setState((prev) => ({
...prev,
photos: [...prev.photos, ...response.data],
cursor: response.next_cursor,
loadingMore: false,
}));
} catch (error) {
const err = error as Error;
setState((prev) => ({
...prev,
loadingMore: false,
error: err.message || t('galleryPublic.loadError'),
}));
}
}, [state.cursor, state.loadingMore, token, t]);
useEffect(() => {
if (!state.cursor || !sentinelRef.current) {
return;
}
const observer = new IntersectionObserver((entries) => {
const firstEntry = entries[0];
if (firstEntry?.isIntersecting) {
loadMore();
}
}, {
rootMargin: '400px',
threshold: 0,
});
observer.observe(sentinelRef.current);
return () => observer.disconnect();
}, [state.cursor, loadMore]);
const themeStyles = useMemo(() => {
if (!state.meta) {
return {} as React.CSSProperties;
}
return {
'--gallery-primary': state.meta.branding.primary_color,
'--gallery-secondary': state.meta.branding.secondary_color,
'--gallery-background': state.meta.branding.background_color,
} as React.CSSProperties & Record<string, string>;
}, [state.meta]);
const headerStyle = useMemo(() => {
if (!state.meta) {
return {};
}
return {
background: state.meta.branding.primary_color,
color: '#ffffff',
} satisfies React.CSSProperties;
}, [state.meta]);
const accentStyle = useMemo(() => {
if (!state.meta) {
return {};
}
return {
color: state.meta.branding.primary_color,
} satisfies React.CSSProperties;
}, [state.meta]);
const backgroundStyle = useMemo(() => {
if (!state.meta) {
return {};
}
return {
backgroundColor: state.meta.branding.background_color,
} satisfies React.CSSProperties;
}, [state.meta]);
const openLightbox = useCallback((photo: GalleryPhotoResource) => {
setSelectedPhoto(photo);
setLightboxOpen(true);
}, []);
const closeLightbox = useCallback(() => {
setLightboxOpen(false);
setSelectedPhoto(null);
}, []);
if (!token) {
return null;
}
if (state.expired) {
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-6 px-6 text-center" style={backgroundStyle}>
<AlertTriangle className="h-12 w-12 text-destructive" aria-hidden />
<div className="space-y-2">
<h1 className="text-2xl font-semibold text-foreground">{t('galleryPublic.expiredTitle')}</h1>
<p className="text-sm text-muted-foreground">{t('galleryPublic.expiredDescription')}</p>
</div>
</div>
);
}
return (
<div className="min-h-screen" style={{ ...themeStyles, ...backgroundStyle }}>
<header className="sticky top-0 z-20 shadow-sm" style={headerStyle}>
<div className="mx-auto flex w-full max-w-5xl items-center justify-between px-5 py-4">
<div className="text-left">
<p className="text-xs uppercase tracking-widest opacity-80">Fotospiel</p>
<h1 className="text-xl font-semibold leading-tight">
{state.meta?.event.name || t('galleryPublic.title')}
</h1>
{state.meta?.event.gallery_expires_at && (
<p className="text-[11px] opacity-80">
{new Date(state.meta.event.gallery_expires_at).toLocaleDateString()}
</p>
)}
</div>
</div>
</header>
<main className="mx-auto flex w-full max-w-5xl flex-col gap-6 px-5 py-6">
{state.meta?.event.description && (
<div className="rounded-xl bg-white/70 p-4 shadow-sm backdrop-blur">
<p className="text-sm text-muted-foreground">{state.meta.event.description}</p>
</div>
)}
{state.error && (
<Alert variant="destructive">
<AlertTitle>{t('galleryPublic.loadError')}</AlertTitle>
<AlertDescription>
{state.error}
</AlertDescription>
</Alert>
)}
{state.loading && (
<div className="flex flex-col items-center justify-center gap-3 py-16 text-center">
<Loader2 className="h-10 w-10 animate-spin" aria-hidden />
<p className="text-sm text-muted-foreground">{t('galleryPublic.loading')}</p>
</div>
)}
{!state.loading && state.photos.length === 0 && !state.error && (
<div className="rounded-xl border border-dashed border-muted/60 p-10 text-center">
<h2 className="text-lg font-semibold text-foreground">{t('galleryPublic.emptyTitle')}</h2>
<p className="mt-2 text-sm text-muted-foreground">{t('galleryPublic.emptyDescription')}</p>
</div>
)}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
{state.photos.map((photo) => (
<button
key={photo.id}
type="button"
className="group relative overflow-hidden rounded-xl bg-white shadow-sm transition-transform hover:-translate-y-1 hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-offset-2"
onClick={() => openLightbox(photo)}
style={accentStyle}
>
<img
src={photo.thumbnail_url ?? photo.full_url ?? ''}
alt={photo.guest_name ? `${photo.guest_name}s Foto` : `Foto ${photo.id}`}
loading="lazy"
className="h-full w-full object-cover"
/>
</button>
))}
</div>
<div ref={sentinelRef} className="h-1 w-full" aria-hidden />
{state.loadingMore && (
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
{t('galleryPublic.loadingMore')}
</div>
)}
{!state.loading && state.cursor && (
<div className="flex justify-center">
<Button variant="outline" onClick={loadMore} disabled={state.loadingMore}>
{state.loadingMore ? <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden /> : null}
{t('galleryPublic.loadMore')}
</Button>
</div>
)}
</main>
<Dialog open={lightboxOpen} onOpenChange={(open) => (open ? setLightboxOpen(true) : closeLightbox())}>
<DialogContent className="max-w-3xl gap-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">
{selectedPhoto?.guest_name || t('galleryPublic.lightboxGuestFallback')}
</p>
<p className="text-xs text-muted-foreground">
{selectedPhoto?.created_at ? new Date(selectedPhoto.created_at).toLocaleString() : ''}
</p>
</div>
<Button variant="ghost" size="icon" onClick={closeLightbox}>
<X className="h-4 w-4" aria-hidden />
<span className="sr-only">{t('common.actions.close')}</span>
</Button>
</div>
<div className="relative overflow-hidden rounded-2xl bg-black/5">
{selectedPhoto?.full_url && (
<img
src={selectedPhoto.full_url}
alt={selectedPhoto?.guest_name || `Foto ${selectedPhoto?.id}`}
className="w-full object-contain"
/>
)}
</div>
<DialogFooter className="flex flex-col items-start gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="text-xs text-muted-foreground">
{selectedPhoto?.likes_count ? `${selectedPhoto.likes_count}` : ''}
</div>
{selectedPhoto?.download_url && (
<Button asChild className="gap-2" style={accentStyle}>
<a href={selectedPhoto.download_url} target="_blank" rel="noopener noreferrer">
<Download className="h-4 w-4" aria-hidden />
{t('galleryPublic.download')}
</a>
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -21,6 +21,7 @@ import AchievementsPage from './pages/AchievementsPage';
import SlideshowPage from './pages/SlideshowPage';
import SettingsPage from './pages/SettingsPage';
import LegalPage from './pages/LegalPage';
import PublicGalleryPage from './pages/PublicGalleryPage';
import NotFoundPage from './pages/NotFoundPage';
import { LocaleProvider } from './i18n/LocaleContext';
import { DEFAULT_LOCALE, isLocaleCode } from './i18n/messages';
@@ -57,6 +58,7 @@ export const router = createBrowserRouter([
{ index: true, element: <ProfileSetupPage /> },
],
},
{ path: '/g/:token', element: <PublicGalleryPage /> },
{
path: '/e/:token',
element: <HomeLayout />,

View File

@@ -0,0 +1,81 @@
import type { LocaleCode } from '../i18n/messages';
export interface GalleryBranding {
primary_color: string;
secondary_color: string;
background_color: string;
}
export interface GalleryMetaResponse {
event: {
id: number;
name: string;
slug?: string | null;
description?: string | null;
gallery_expires_at?: string | null;
};
branding: GalleryBranding;
}
export interface GalleryPhotoResource {
id: number;
thumbnail_url: string | null;
full_url: string | null;
download_url: string;
likes_count: number;
guest_name?: string | null;
created_at?: string | null;
}
export interface GalleryPhotosResponse {
data: GalleryPhotoResource[];
next_cursor: string | null;
}
async function handleResponse<T>(response: Response): Promise<T> {
if (response.status === 204) {
return {} as T;
}
const data = await response.json().catch(() => null);
if (!response.ok) {
const error = new Error((data && data.error && data.error.message) || 'Request failed');
(error as any).code = data?.error?.code ?? response.status;
throw error;
}
return data as T;
}
export async function fetchGalleryMeta(token: string, locale?: LocaleCode): Promise<GalleryMetaResponse> {
const params = new URLSearchParams();
if (locale) params.set('locale', locale);
const response = await fetch(`/api/v1/gallery/${encodeURIComponent(token)}${params.toString() ? `?${params.toString()}` : ''}`, {
headers: {
'Accept': 'application/json',
},
credentials: 'omit',
});
return handleResponse<GalleryMetaResponse>(response);
}
export async function fetchGalleryPhotos(token: string, cursor?: string | null, limit = 30): Promise<GalleryPhotosResponse> {
const params = new URLSearchParams();
params.set('limit', String(limit));
if (cursor) {
params.set('cursor', cursor);
}
const response = await fetch(`/api/v1/gallery/${encodeURIComponent(token)}/photos?${params.toString()}`, {
headers: {
'Accept': 'application/json',
},
credentials: 'omit',
});
return handleResponse<GalleryPhotosResponse>(response);
}

View File

@@ -1,4 +1,4 @@
import React, { useMemo } from "react";
import React, { useMemo, useRef, useEffect } from "react";
import { useTranslation } from 'react-i18next';
import { Steps } from "@/components/ui/Steps";
import { Button } from "@/components/ui/button";
@@ -54,6 +54,8 @@ const baseStepConfig: { id: CheckoutStepId; titleKey: string; descriptionKey: st
const WizardBody: React.FC<{ stripePublishableKey: string; paypalClientId: string; privacyHtml: string }> = ({ stripePublishableKey, paypalClientId, privacyHtml }) => {
const { t } = useTranslation('marketing');
const { currentStep, nextStep, previousStep } = useCheckoutWizard();
const progressRef = useRef<HTMLDivElement | null>(null);
const hasMountedRef = useRef(false);
const stepConfig = useMemo(() =>
baseStepConfig.map(step => ({
@@ -73,9 +75,29 @@ const WizardBody: React.FC<{ stripePublishableKey: string; paypalClientId: strin
return (currentIndex / (stepConfig.length - 1)) * 100;
}, [currentIndex, stepConfig]);
useEffect(() => {
if (typeof window === 'undefined' || !progressRef.current) {
return;
}
if (!hasMountedRef.current) {
hasMountedRef.current = true;
return;
}
const element = progressRef.current;
const rect = element.getBoundingClientRect();
const scrollTop = window.scrollY + rect.top - 16; // slightly above the progress bar
window.scrollTo({
top: Math.max(scrollTop, 0),
behavior: 'smooth',
});
}, [currentStep]);
return (
<div className="space-y-8">
<div className="space-y-4">
<div ref={progressRef} className="space-y-4">
<Progress value={progress} />
<Steps steps={stepConfig} currentStep={currentIndex >= 0 ? currentIndex : 0} />
</div>

View File

@@ -76,6 +76,7 @@ return [
'actions' => [
'toggle_active' => 'Aktiv umschalten',
'join_link_qr' => 'Beitrittslink / QR',
'download_photos' => 'Alle Fotos herunterladen',
],
'modal' => [
'join_link_heading' => 'Beitrittslink der Veranstaltung',

View File

@@ -76,6 +76,7 @@ return [
'actions' => [
'toggle_active' => 'Toggle Active',
'join_link_qr' => 'Join Link / QR',
'download_photos' => 'Download all photos',
],
'modal' => [
'join_link_heading' => 'Event Join Link',

View File

@@ -38,6 +38,12 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
Route::get('/photos/{id}', [EventPublicController::class, 'photo'])->name('photos.show');
Route::post('/photos/{id}/like', [EventPublicController::class, 'like'])->name('photos.like');
Route::post('/events/{token}/upload', [EventPublicController::class, 'upload'])->name('events.upload');
Route::get('/gallery/{token}', [EventPublicController::class, 'gallery'])->name('gallery.show');
Route::get('/gallery/{token}/photos', [EventPublicController::class, 'galleryPhotos'])->name('gallery.photos');
Route::get('/gallery/{token}/photos/{photo}/download', [EventPublicController::class, 'galleryPhotoDownload'])
->whereNumber('photo')
->name('gallery.photos.download');
});
Route::middleware(['tenant.token', 'tenant.isolation', 'throttle:tenant-api'])->prefix('tenant')->group(function () {

View File

@@ -4,6 +4,7 @@ use App\Http\Controllers\CheckoutController;
use App\Http\Controllers\LocaleController;
use App\Http\Controllers\LegalPageController;
use App\Http\Controllers\MarketingController;
use App\Http\Controllers\Tenant\EventPhotoArchiveController;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
@@ -39,10 +40,16 @@ Route::get('/anlaesse/{type}', [MarketingController::class, 'occasionsType'])->n
Route::get('/success/{packageId?}', [MarketingController::class, 'success'])->name('marketing.success');
Route::view('/event-admin/{view?}', 'admin')->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::middleware('auth')->group(function () {
Route::get('/buy/{packageId}', [MarketingController::class, 'buyPackages'])->name('marketing.buy');
});
Route::middleware('auth')->group(function () {
Route::get('/tenant/events/{event}/photos/archive', EventPhotoArchiveController::class)
->name('tenant.events.photos.archive');
});
Route::get('/purchase-wizard/{package}', [CheckoutController::class, 'show'])->name('purchase.wizard');
Route::get('/checkout/{package}', [CheckoutController::class, 'show'])->name('checkout.show');
Route::post('/checkout/login', [CheckoutController::class, 'login'])->name('checkout.login');