Compare commits
13 Commits
19425c0f62
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e78f3ab8d | ||
|
|
386d0004ed | ||
|
|
e233cddcc8 | ||
|
|
e39ddd2143 | ||
|
|
b1f9f7cee0 | ||
|
|
916b204688 | ||
|
|
d45cb6a087 | ||
|
|
f574ffaf38 | ||
|
|
b866179521 | ||
|
|
3ba784154b | ||
|
|
96aaea23e4 | ||
|
|
4b1785fb85 | ||
|
|
8aba034344 |
@@ -1 +0,0 @@
|
|||||||
fotospiel-app-vc3
|
|
||||||
@@ -38,6 +38,9 @@ This repository hosts a multi-tenant event photo platform (Laravel 12, PHP 8.3,
|
|||||||
- resources/js/admin/ — Tenant Admin PWA source (React 19, Capacitor/TWA ready).
|
- resources/js/admin/ — Tenant Admin PWA source (React 19, Capacitor/TWA ready).
|
||||||
- resources/js/pages/ — Inertia pages (React).
|
- resources/js/pages/ — Inertia pages (React).
|
||||||
- docs/archive/README.md — historical PRP context.
|
- docs/archive/README.md — historical PRP context.
|
||||||
|
- Marketing frontend language files:
|
||||||
|
- Source translations: `resources/lang/{de,en}/marketing.php` and `resources/lang/{de,en}/marketing.json`.
|
||||||
|
- Runtime i18next JSON served to the frontend: `public/lang/{de,en}/marketing.json` (must stay in sync with the source files).
|
||||||
|
|
||||||
## Standard Workflows
|
## Standard Workflows
|
||||||
- Coding tasks (Codegen Agent):
|
- Coding tasks (Codegen Agent):
|
||||||
|
|||||||
@@ -100,6 +100,8 @@ COPY . .
|
|||||||
COPY --from=vendor /var/www/html/vendor ./vendor
|
COPY --from=vendor /var/www/html/vendor ./vendor
|
||||||
COPY --from=node_builder /var/www/html/public/build ./public/build
|
COPY --from=node_builder /var/www/html/public/build ./public/build
|
||||||
|
|
||||||
|
RUN php artisan vendor:publish --tag=livewire:assets --force --no-interaction
|
||||||
|
|
||||||
RUN php artisan config:clear \
|
RUN php artisan config:clear \
|
||||||
&& php artisan config:cache \
|
&& php artisan config:cache \
|
||||||
&& php artisan route:clear \
|
&& php artisan route:clear \
|
||||||
|
|||||||
@@ -1093,12 +1093,8 @@ class EventPublicController extends BaseController
|
|||||||
$brandingAllowed = $this->determineBrandingAllowed($event);
|
$brandingAllowed = $this->determineBrandingAllowed($event);
|
||||||
|
|
||||||
$eventBranding = $brandingAllowed ? Arr::get($event->settings, 'branding', []) : [];
|
$eventBranding = $brandingAllowed ? Arr::get($event->settings, 'branding', []) : [];
|
||||||
$tenantBranding = $brandingAllowed ? Arr::get($event->tenant?->settings, 'branding', []) : [];
|
|
||||||
|
|
||||||
$useDefault = (bool) Arr::get($eventBranding, 'use_default_branding', Arr::get($eventBranding, 'use_default', false));
|
$useDefault = (bool) Arr::get($eventBranding, 'use_default_branding', Arr::get($eventBranding, 'use_default', false));
|
||||||
$sources = $brandingAllowed
|
$sources = $brandingAllowed ? [$eventBranding] : [[]];
|
||||||
? ($useDefault ? [$tenantBranding] : [$eventBranding, $tenantBranding])
|
|
||||||
: [[]];
|
|
||||||
|
|
||||||
$primary = $this->normalizeHexColor(
|
$primary = $this->normalizeHexColor(
|
||||||
$this->firstStringFromSources($sources, ['palette.primary', 'primary_color']),
|
$this->firstStringFromSources($sources, ['palette.primary', 'primary_color']),
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ use App\Support\WatermarkConfigResolver;
|
|||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
@@ -115,6 +116,7 @@ class PhotoController extends Controller
|
|||||||
$event = Event::where('slug', $eventSlug)
|
$event = Event::where('slug', $eventSlug)
|
||||||
->where('tenant_id', $tenantId)
|
->where('tenant_id', $tenantId)
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
|
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
|
||||||
|
|
||||||
if ($photo->event_id !== $event->id) {
|
if ($photo->event_id !== $event->id) {
|
||||||
return ApiError::response(
|
return ApiError::response(
|
||||||
@@ -321,7 +323,7 @@ class PhotoController extends Controller
|
|||||||
$disk = $this->eventStorageManager->getHotDiskForEvent($event);
|
$disk = $this->eventStorageManager->getHotDiskForEvent($event);
|
||||||
|
|
||||||
// Generate unique filename
|
// Generate unique filename
|
||||||
$extension = $file->getClientOriginalExtension();
|
$extension = $this->resolvePhotoExtension($file);
|
||||||
$filename = Str::uuid().'.'.$extension;
|
$filename = Str::uuid().'.'.$extension;
|
||||||
$path = "events/{$eventSlug}/photos/{$filename}";
|
$path = "events/{$eventSlug}/photos/{$filename}";
|
||||||
|
|
||||||
@@ -563,6 +565,7 @@ class PhotoController extends Controller
|
|||||||
$event = Event::where('slug', $eventSlug)
|
$event = Event::where('slug', $eventSlug)
|
||||||
->where('tenant_id', $tenantId)
|
->where('tenant_id', $tenantId)
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
|
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
|
||||||
|
|
||||||
if ($photo->event_id !== $event->id) {
|
if ($photo->event_id !== $event->id) {
|
||||||
return ApiError::response(
|
return ApiError::response(
|
||||||
@@ -779,6 +782,7 @@ class PhotoController extends Controller
|
|||||||
$event = Event::where('slug', $eventSlug)
|
$event = Event::where('slug', $eventSlug)
|
||||||
->where('tenant_id', $tenantId)
|
->where('tenant_id', $tenantId)
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
|
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
|
||||||
|
|
||||||
$photos = Photo::where('event_id', $event->id)
|
$photos = Photo::where('event_id', $event->id)
|
||||||
->where('status', 'pending')
|
->where('status', 'pending')
|
||||||
@@ -1043,4 +1047,23 @@ class PhotoController extends Controller
|
|||||||
|
|
||||||
return array_values(array_unique(array_filter($candidates)));
|
return array_values(array_unique(array_filter($candidates)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function resolvePhotoExtension(UploadedFile $file): string
|
||||||
|
{
|
||||||
|
$extension = strtolower((string) $file->extension());
|
||||||
|
|
||||||
|
if (! in_array($extension, ['jpg', 'jpeg', 'png', 'webp'], true)) {
|
||||||
|
$extension = strtolower((string) $file->getClientOriginalExtension());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! in_array($extension, ['jpg', 'jpeg', 'png', 'webp'], true)) {
|
||||||
|
$extension = match ($file->getMimeType()) {
|
||||||
|
'image/png' => 'png',
|
||||||
|
'image/webp' => 'webp',
|
||||||
|
default => 'jpg',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return $extension === 'jpeg' ? 'jpg' : $extension;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -157,6 +157,10 @@ class AuthenticatedSessionController extends Controller
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (str_starts_with($candidate, '//')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (str_starts_with($candidate, '/')) {
|
if (str_starts_with($candidate, '/')) {
|
||||||
return $candidate;
|
return $candidate;
|
||||||
}
|
}
|
||||||
@@ -170,7 +174,7 @@ class AuthenticatedSessionController extends Controller
|
|||||||
|
|
||||||
$appHost = parse_url($request->getSchemeAndHttpHost(), PHP_URL_HOST);
|
$appHost = parse_url($request->getSchemeAndHttpHost(), PHP_URL_HOST);
|
||||||
|
|
||||||
if ($appHost && ! Str::endsWith($targetHost, $appHost)) {
|
if (! $appHost || ! $this->isAllowedReturnHost($targetHost, $appHost)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,7 +226,7 @@ class AuthenticatedSessionController extends Controller
|
|||||||
$scheme = $parsed['scheme'] ?? null;
|
$scheme = $parsed['scheme'] ?? null;
|
||||||
$requestHost = parse_url($request->getSchemeAndHttpHost(), PHP_URL_HOST);
|
$requestHost = parse_url($request->getSchemeAndHttpHost(), PHP_URL_HOST);
|
||||||
|
|
||||||
if ($scheme && $host && $requestHost && ! Str::endsWith($host, $requestHost)) {
|
if ($scheme && $host && $requestHost && ! $this->isAllowedReturnHost($host, $requestHost)) {
|
||||||
return '/event-admin/dashboard';
|
return '/event-admin/dashboard';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,6 +269,15 @@ class AuthenticatedSessionController extends Controller
|
|||||||
return $decoded;
|
return $decoded;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function isAllowedReturnHost(string $targetHost, string $appHost): bool
|
||||||
|
{
|
||||||
|
if ($targetHost === $appHost) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Str::endsWith($targetHost, '.'.$appHost);
|
||||||
|
}
|
||||||
|
|
||||||
private function rememberTenantAdminTarget(Request $request, ?string $target): void
|
private function rememberTenantAdminTarget(Request $request, ?string $target): void
|
||||||
{
|
{
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
|
|||||||
@@ -100,13 +100,30 @@ class TenantAdminFacebookController extends Controller
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (str_starts_with($decoded, '//')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_starts_with($decoded, '/')) {
|
||||||
|
return $decoded;
|
||||||
|
}
|
||||||
|
|
||||||
$targetHost = parse_url($decoded, PHP_URL_HOST);
|
$targetHost = parse_url($decoded, PHP_URL_HOST);
|
||||||
$appHost = parse_url($request->getSchemeAndHttpHost(), PHP_URL_HOST);
|
$appHost = parse_url($request->getSchemeAndHttpHost(), PHP_URL_HOST);
|
||||||
|
|
||||||
if ($targetHost && $appHost && ! Str::endsWith($targetHost, $appHost)) {
|
if (! $targetHost || ! $appHost || ! $this->isAllowedReturnHost($targetHost, $appHost)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $decoded;
|
return $decoded;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function isAllowedReturnHost(string $targetHost, string $appHost): bool
|
||||||
|
{
|
||||||
|
if ($targetHost === $appHost) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Str::endsWith($targetHost, '.'.$appHost);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,13 +100,30 @@ class TenantAdminGoogleController extends Controller
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (str_starts_with($decoded, '//')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_starts_with($decoded, '/')) {
|
||||||
|
return $decoded;
|
||||||
|
}
|
||||||
|
|
||||||
$targetHost = parse_url($decoded, PHP_URL_HOST);
|
$targetHost = parse_url($decoded, PHP_URL_HOST);
|
||||||
$appHost = parse_url($request->getSchemeAndHttpHost(), PHP_URL_HOST);
|
$appHost = parse_url($request->getSchemeAndHttpHost(), PHP_URL_HOST);
|
||||||
|
|
||||||
if ($targetHost && $appHost && ! Str::endsWith($targetHost, $appHost)) {
|
if (! $targetHost || ! $appHost || ! $this->isAllowedReturnHost($targetHost, $appHost)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $decoded;
|
return $decoded;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function isAllowedReturnHost(string $targetHost, string $appHost): bool
|
||||||
|
{
|
||||||
|
if ($targetHost === $appHost) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Str::endsWith($targetHost, '.'.$appHost);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,21 +30,21 @@ return [
|
|||||||
],
|
],
|
||||||
[
|
[
|
||||||
'key' => 'gift-standard',
|
'key' => 'gift-standard',
|
||||||
'label' => 'Geschenk Standard',
|
'label' => 'Geschenk Classic',
|
||||||
'amount' => 59.00,
|
'amount' => 59.00,
|
||||||
'currency' => 'EUR',
|
'currency' => 'EUR',
|
||||||
'paddle_price_id' => env('PADDLE_GIFT_PRICE_STANDARD', 'pri_01kbwccfvzrf4z2f1r62vns7gh'),
|
'paddle_price_id' => env('PADDLE_GIFT_PRICE_STANDARD', 'pri_01kbwccfvzrf4z2f1r62vns7gh'),
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'key' => 'gift-standard-usd',
|
'key' => 'gift-standard-usd',
|
||||||
'label' => 'Gift Standard (USD)',
|
'label' => 'Gift Classic (USD)',
|
||||||
'amount' => 65.00,
|
'amount' => 65.00,
|
||||||
'currency' => 'USD',
|
'currency' => 'USD',
|
||||||
'paddle_price_id' => env('PADDLE_GIFT_PRICE_STANDARD_USD'),
|
'paddle_price_id' => env('PADDLE_GIFT_PRICE_STANDARD_USD'),
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'key' => 'gift-standard-gbp',
|
'key' => 'gift-standard-gbp',
|
||||||
'label' => 'Gift Standard (GBP)',
|
'label' => 'Gift Classic (GBP)',
|
||||||
'amount' => 55.00,
|
'amount' => 55.00,
|
||||||
'currency' => 'GBP',
|
'currency' => 'GBP',
|
||||||
'paddle_price_id' => env('PADDLE_GIFT_PRICE_STANDARD_GBP'),
|
'paddle_price_id' => env('PADDLE_GIFT_PRICE_STANDARD_GBP'),
|
||||||
@@ -70,27 +70,6 @@ return [
|
|||||||
'currency' => 'GBP',
|
'currency' => 'GBP',
|
||||||
'paddle_price_id' => env('PADDLE_GIFT_PRICE_PREMIUM_GBP'),
|
'paddle_price_id' => env('PADDLE_GIFT_PRICE_PREMIUM_GBP'),
|
||||||
],
|
],
|
||||||
[
|
|
||||||
'key' => 'gift-premium-plus',
|
|
||||||
'label' => 'Geschenk Premium Plus',
|
|
||||||
'amount' => 149.00,
|
|
||||||
'currency' => 'EUR',
|
|
||||||
'paddle_price_id' => env('PADDLE_GIFT_PRICE_PREMIUM_PLUS', 'pri_01kbwccgnjzwrjy5xg1yp981p6'),
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'key' => 'gift-premium-plus-usd',
|
|
||||||
'label' => 'Gift Premium Plus (USD)',
|
|
||||||
'amount' => 159.00,
|
|
||||||
'currency' => 'USD',
|
|
||||||
'paddle_price_id' => env('PADDLE_GIFT_PRICE_PREMIUM_PLUS_USD'),
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'key' => 'gift-premium-plus-gbp',
|
|
||||||
'label' => 'Gift Premium Plus (GBP)',
|
|
||||||
'amount' => 139.00,
|
|
||||||
'currency' => 'GBP',
|
|
||||||
'paddle_price_id' => env('PADDLE_GIFT_PRICE_PREMIUM_PLUS_GBP'),
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
|
|
||||||
// Package types a voucher coupon should apply to.
|
// Package types a voucher coupon should apply to.
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ class CouponSeeder extends Seeder
|
|||||||
[
|
[
|
||||||
'code' => 'UPGRADE30',
|
'code' => 'UPGRADE30',
|
||||||
'name' => 'Upgrade 30 €',
|
'name' => 'Upgrade 30 €',
|
||||||
'description' => '30 € Nachlass als Upgrade-Anreiz von Starter auf Standard/Premium.',
|
'description' => '30 € Nachlass als Upgrade-Anreiz von Starter auf Classic/Premium.',
|
||||||
'type' => CouponType::FLAT,
|
'type' => CouponType::FLAT,
|
||||||
'amount' => 30.00,
|
'amount' => 30.00,
|
||||||
'currency' => 'EUR',
|
'currency' => 'EUR',
|
||||||
@@ -77,7 +77,7 @@ class CouponSeeder extends Seeder
|
|||||||
[
|
[
|
||||||
'code' => 'SEASON50',
|
'code' => 'SEASON50',
|
||||||
'name' => 'Hochzeits-Saison 50 €',
|
'name' => 'Hochzeits-Saison 50 €',
|
||||||
'description' => 'Saisonaler 50 € Rabatt für die Hochzeitssaison auf Standard/Premium.',
|
'description' => 'Saisonaler 50 € Rabatt für die Hochzeitssaison auf Classic/Premium.',
|
||||||
'type' => CouponType::FLAT,
|
'type' => CouponType::FLAT,
|
||||||
'amount' => 50.00,
|
'amount' => 50.00,
|
||||||
'currency' => 'EUR',
|
'currency' => 'EUR',
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ class PackageSeeder extends Seeder
|
|||||||
'max_events_per_year' => 1,
|
'max_events_per_year' => 1,
|
||||||
'watermark_allowed' => false,
|
'watermark_allowed' => false,
|
||||||
'branding_allowed' => false,
|
'branding_allowed' => false,
|
||||||
'features' => ['basic_uploads', 'limited_sharing', 'custom_tasks'],
|
'features' => ['basic_uploads', 'limited_sharing', 'custom_tasks', 'live_slideshow'],
|
||||||
'paddle_product_id' => 'pro_01k8jcxx2g1vj9snqbga4283ej',
|
'paddle_product_id' => 'pro_01k8jcxx2g1vj9snqbga4283ej',
|
||||||
'paddle_price_id' => 'pri_01k8jcxx8qktxvqzzv0nkjjj27',
|
'paddle_price_id' => 'pri_01k8jcxx8qktxvqzzv0nkjjj27',
|
||||||
'description' => <<<'TEXT'
|
'description' => <<<'TEXT'
|
||||||
@@ -51,10 +51,10 @@ TEXT,
|
|||||||
],
|
],
|
||||||
[
|
[
|
||||||
'slug' => 'standard',
|
'slug' => 'standard',
|
||||||
'name' => 'Standard',
|
'name' => 'Classic',
|
||||||
'name_translations' => [
|
'name_translations' => [
|
||||||
'de' => 'Standard',
|
'de' => 'Classic',
|
||||||
'en' => 'Standard',
|
'en' => 'Classic',
|
||||||
],
|
],
|
||||||
'type' => PackageType::ENDCUSTOMER,
|
'type' => PackageType::ENDCUSTOMER,
|
||||||
'price' => 59.00,
|
'price' => 59.00,
|
||||||
@@ -151,10 +151,10 @@ TEXT,
|
|||||||
],
|
],
|
||||||
[
|
[
|
||||||
'slug' => 'm-medium-reseller',
|
'slug' => 'm-medium-reseller',
|
||||||
'name' => 'Partner Standard',
|
'name' => 'Partner Classic',
|
||||||
'name_translations' => [
|
'name_translations' => [
|
||||||
'de' => 'Partner Standard',
|
'de' => 'Partner Classic',
|
||||||
'en' => 'Partner Standard',
|
'en' => 'Partner Classic',
|
||||||
],
|
],
|
||||||
'type' => PackageType::RESELLER,
|
'type' => PackageType::RESELLER,
|
||||||
'included_package_slug' => 'standard',
|
'included_package_slug' => 'standard',
|
||||||
@@ -171,15 +171,15 @@ TEXT,
|
|||||||
'paddle_product_id' => 'pro_01k8jcxtrxw7jsew52jnax901q',
|
'paddle_product_id' => 'pro_01k8jcxtrxw7jsew52jnax901q',
|
||||||
'paddle_price_id' => 'pri_01k8jcxv06nsgy8ym8mnfrfm5v',
|
'paddle_price_id' => 'pri_01k8jcxv06nsgy8ym8mnfrfm5v',
|
||||||
'description' => <<<'TEXT'
|
'description' => <<<'TEXT'
|
||||||
Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf Standard‑Niveau. Empfohlen innerhalb von 24 Monaten zu nutzen.
|
Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf Classic‑Niveau. Empfohlen innerhalb von 24 Monaten zu nutzen.
|
||||||
TEXT,
|
TEXT,
|
||||||
'description_translations' => [
|
'description_translations' => [
|
||||||
'de' => 'Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf Standard‑Niveau. Empfohlen innerhalb von 24 Monaten zu nutzen.',
|
'de' => 'Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf Classic‑Niveau. Empfohlen innerhalb von 24 Monaten zu nutzen.',
|
||||||
'en' => 'Event-Kontingent for Partner / Agencies: {{max_events_per_year}} events at Standard level. Recommended to use within 24 months.',
|
'en' => 'Event-Kontingent for Partner / Agencies: {{max_events_per_year}} events at Classic level. Recommended to use within 24 months.',
|
||||||
],
|
],
|
||||||
'description_table' => [
|
'description_table' => [
|
||||||
['title' => 'Events', 'value' => '{{max_events_per_year}} Events'],
|
['title' => 'Events', 'value' => '{{max_events_per_year}} Events'],
|
||||||
['title' => 'Inklusive Event-Level', 'value' => 'Standard'],
|
['title' => 'Inklusive Event-Level', 'value' => 'Classic'],
|
||||||
['title' => 'Empfehlung', 'value' => 'Empfohlen innerhalb von 24 Monaten zu nutzen.'],
|
['title' => 'Empfehlung', 'value' => 'Empfohlen innerhalb von 24 Monaten zu nutzen.'],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -273,15 +273,15 @@ TEXT,
|
|||||||
'paddle_product_id' => 'pro_01k8jct3gz9ks5mg6z61q6nrxb',
|
'paddle_product_id' => 'pro_01k8jct3gz9ks5mg6z61q6nrxb',
|
||||||
'paddle_price_id' => 'pri_01k8jcxsa8axwpjnybhjbcrb06',
|
'paddle_price_id' => 'pri_01k8jcxsa8axwpjnybhjbcrb06',
|
||||||
'description' => <<<'TEXT'
|
'description' => <<<'TEXT'
|
||||||
Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf Standard‑Niveau. Empfohlen innerhalb von 24 Monaten zu nutzen.
|
Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf Classic‑Niveau. Empfohlen innerhalb von 24 Monaten zu nutzen.
|
||||||
TEXT,
|
TEXT,
|
||||||
'description_translations' => [
|
'description_translations' => [
|
||||||
'de' => 'Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf Standard‑Niveau. Empfohlen innerhalb von 24 Monaten zu nutzen.',
|
'de' => 'Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf Classic‑Niveau. Empfohlen innerhalb von 24 Monaten zu nutzen.',
|
||||||
'en' => 'Event-Kontingent for Partner / Agencies: {{max_events_per_year}} events at Standard level. Recommended to use within 24 months.',
|
'en' => 'Event-Kontingent for Partner / Agencies: {{max_events_per_year}} events at Classic level. Recommended to use within 24 months.',
|
||||||
],
|
],
|
||||||
'description_table' => [
|
'description_table' => [
|
||||||
['title' => 'Events', 'value' => '{{max_events_per_year}} Events'],
|
['title' => 'Events', 'value' => '{{max_events_per_year}} Events'],
|
||||||
['title' => 'Inklusive Event-Level', 'value' => 'Standard'],
|
['title' => 'Inklusive Event-Level', 'value' => 'Classic'],
|
||||||
['title' => 'Empfehlung', 'value' => 'Empfohlen innerhalb von 24 Monaten zu nutzen.'],
|
['title' => 'Empfehlung', 'value' => 'Empfohlen innerhalb von 24 Monaten zu nutzen.'],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -75,7 +75,6 @@ const sidebars = {
|
|||||||
items: [
|
items: [
|
||||||
'ops/deployment/docker',
|
'ops/deployment/docker',
|
||||||
'ops/deployment/dokploy',
|
'ops/deployment/dokploy',
|
||||||
'ops/deployment/join-token-analytics',
|
|
||||||
'ops/deployment/lokale-podman-adressen',
|
'ops/deployment/lokale-podman-adressen',
|
||||||
'ops/deployment/public-api-incident-playbook',
|
'ops/deployment/public-api-incident-playbook',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -109,7 +109,7 @@
|
|||||||
"priority_support": "Priorisierter Support",
|
"priority_support": "Priorisierter Support",
|
||||||
"cancel_link": "Paket verwalten: :link",
|
"cancel_link": "Paket verwalten: :link",
|
||||||
"hero_kicker": "Pakete, die mit eurem Event mitwachsen",
|
"hero_kicker": "Pakete, die mit eurem Event mitwachsen",
|
||||||
"hero_title": "Entdecken Sie unsere flexiblen Packages",
|
"hero_title": "Entdecken Sie unsere flexiblen Event-Pakete",
|
||||||
"hero_description": "Von kostenlosem Einstieg bis Premium-Features: Passen Sie Ihr Event-Paket an Ihre Bedürfnisse an. Einfach, sicher und skalierbar.",
|
"hero_description": "Von kostenlosem Einstieg bis Premium-Features: Passen Sie Ihr Event-Paket an Ihre Bedürfnisse an. Einfach, sicher und skalierbar.",
|
||||||
"hero_secondary": "Teste den kompletten Gäste-Flow in unserer Live-Demo – kein Login, kein App-Store.",
|
"hero_secondary": "Teste den kompletten Gäste-Flow in unserer Live-Demo – kein Login, kein App-Store.",
|
||||||
"cta_demo": "Demo ansehen",
|
"cta_demo": "Demo ansehen",
|
||||||
@@ -117,7 +117,7 @@
|
|||||||
"cta_explore_highlight": "Lieblingspaket sichern",
|
"cta_explore_highlight": "Lieblingspaket sichern",
|
||||||
"gift_cta": "Paket verschenken",
|
"gift_cta": "Paket verschenken",
|
||||||
"tab_endcustomer": "Einzel-Events",
|
"tab_endcustomer": "Einzel-Events",
|
||||||
"tab_reseller": "Partner / Agentur",
|
"tab_reseller": "mehrere Events",
|
||||||
"section_endcustomer": "Packages für Endkunden (Einmalkauf pro Event)",
|
"section_endcustomer": "Packages für Endkunden (Einmalkauf pro Event)",
|
||||||
"section_reseller": "Packages für Partner / Agenturen (Event-Kontingent)",
|
"section_reseller": "Packages für Partner / Agenturen (Event-Kontingent)",
|
||||||
"bundles_title": "Partner & Agentur Bundles",
|
"bundles_title": "Partner & Agentur Bundles",
|
||||||
@@ -156,7 +156,7 @@
|
|||||||
"feature_watermark_custom": "Eigenes Wasserzeichen",
|
"feature_watermark_custom": "Eigenes Wasserzeichen",
|
||||||
"feature_branding": "Branding",
|
"feature_branding": "Branding",
|
||||||
"feature_support": "Support",
|
"feature_support": "Support",
|
||||||
"feature_basic_uploads": "Basis-Uploads",
|
"feature_basic_uploads": "Download aller Fotos",
|
||||||
"feature_unlimited_sharing": "Unbegrenztes Teilen",
|
"feature_unlimited_sharing": "Unbegrenztes Teilen",
|
||||||
"feature_no_watermark": "Kein Wasserzeichen",
|
"feature_no_watermark": "Kein Wasserzeichen",
|
||||||
"feature_custom_tasks": "Benutzerdefinierte Tasks",
|
"feature_custom_tasks": "Benutzerdefinierte Tasks",
|
||||||
@@ -523,9 +523,9 @@
|
|||||||
"family": "Familienfeiern"
|
"family": "Familienfeiern"
|
||||||
},
|
},
|
||||||
"blog": "Blog",
|
"blog": "Blog",
|
||||||
"packages": "Packages",
|
"packages": "Pakete",
|
||||||
"contact": "Kontakt",
|
"contact": "Kontakt",
|
||||||
"discover_packages": "Packages entdecken",
|
"discover_packages": "Pakete entdecken",
|
||||||
"language": "Sprache",
|
"language": "Sprache",
|
||||||
"language_de": "Deutsch",
|
"language_de": "Deutsch",
|
||||||
"language_en": "English",
|
"language_en": "English",
|
||||||
@@ -837,7 +837,7 @@
|
|||||||
"label": "Setup vom Account zur Galerie"
|
"label": "Setup vom Account zur Galerie"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"value": "0 Apps",
|
"value": "keine App-Installation erforderlich",
|
||||||
"label": "Gäste nutzen nur ihren Browser"
|
"label": "Gäste nutzen nur ihren Browser"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -104,7 +104,7 @@
|
|||||||
"cta_explore_highlight": "Explore top packages",
|
"cta_explore_highlight": "Explore top packages",
|
||||||
"gift_cta": "Gift a package",
|
"gift_cta": "Gift a package",
|
||||||
"tab_endcustomer": "End Customers",
|
"tab_endcustomer": "End Customers",
|
||||||
"tab_reseller": "Partner / Agency",
|
"tab_reseller": "Bundles",
|
||||||
"section_endcustomer": "Packages for End Customers (One-time purchase per event)",
|
"section_endcustomer": "Packages for End Customers (One-time purchase per event)",
|
||||||
"section_reseller": "Packages for Partner / Agencies (Event bundle)",
|
"section_reseller": "Packages for Partner / Agencies (Event bundle)",
|
||||||
"bundles_title": "Partner & Agency Bundles",
|
"bundles_title": "Partner & Agency Bundles",
|
||||||
|
|||||||
@@ -660,6 +660,31 @@ html.guest-theme.dark {
|
|||||||
50% { background-position: 100% 50%; }
|
50% { background-position: 100% 50%; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.guest-aurora {
|
||||||
|
background-size: 300% 300%;
|
||||||
|
animation: aurora 18s ease infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guest-aurora-soft {
|
||||||
|
background-size: 260% 260%;
|
||||||
|
animation: aurora 26s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes guest-cta-pulse {
|
||||||
|
0%, 100% {
|
||||||
|
box-shadow: 0 12px 28px var(--cta-glow, rgba(0, 0, 0, 0.25)), 0 0 0 0 var(--cta-ring, rgba(0, 0, 0, 0));
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 18px 45px var(--cta-glow, rgba(0, 0, 0, 0.35)), 0 0 0 12px var(--cta-ring, rgba(0, 0, 0, 0.12));
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.guest-cta-pulse {
|
||||||
|
animation: guest-cta-pulse 3.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
.bg-aurora-enhanced {
|
.bg-aurora-enhanced {
|
||||||
background: radial-gradient(circle at 20% 80%, #a8edea 0%, #fed6e3 50%, #d299c2 100%), linear-gradient(-45deg, #ee7752, #e73c7e, #23a6d5, #23d5ab);
|
background: radial-gradient(circle at 20% 80%, #a8edea 0%, #fed6e3 50%, #d299c2 100%), linear-gradient(-45deg, #ee7752, #e73c7e, #23a6d5, #23d5ab);
|
||||||
background-size: 400% 400%, 400% 400%;
|
background-size: 400% 400%, 400% 400%;
|
||||||
@@ -672,6 +697,14 @@ html.guest-theme.dark {
|
|||||||
animation: aurora 20s ease infinite;
|
animation: aurora 20s ease infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.guest-aurora,
|
||||||
|
.guest-aurora-soft,
|
||||||
|
.guest-cta-pulse {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.guest-immersive .guest-header {
|
.guest-immersive .guest-header {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { useTheme } from '@tamagui/core';
|
|||||||
|
|
||||||
const DEV_TENANT_KEYS = [
|
const DEV_TENANT_KEYS = [
|
||||||
{ key: 'cust-standard-empty', label: 'Endkunde – Starter (kein Event)' },
|
{ key: 'cust-standard-empty', label: 'Endkunde – Starter (kein Event)' },
|
||||||
{ key: 'cust-starter-wedding', label: 'Endkunde – Standard (Hochzeit)' },
|
{ key: 'cust-starter-wedding', label: 'Endkunde – Classic (Hochzeit)' },
|
||||||
{ key: 'reseller-s-active', label: 'Reseller S – 3 aktive Events' },
|
{ key: 'reseller-s-active', label: 'Reseller S – 3 aktive Events' },
|
||||||
{ key: 'reseller-s-full', label: 'Reseller S – voll belegt (5/5)' },
|
{ key: 'reseller-s-full', label: 'Reseller S – voll belegt (5/5)' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
@@ -2448,6 +2448,28 @@ export async function getTenantSettings(): Promise<TenantSettingsPayload> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateTenantSettings(settings: Record<string, unknown>): Promise<TenantSettingsPayload> {
|
||||||
|
const response = await authorizedFetch('/api/v1/tenant/settings', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ settings }),
|
||||||
|
});
|
||||||
|
const data = await jsonOrThrow<{ data?: { id?: number; settings?: Record<string, unknown>; updated_at?: string | null } }>(
|
||||||
|
response,
|
||||||
|
'Failed to update tenant settings',
|
||||||
|
);
|
||||||
|
const payload = (data.data ?? {}) as Record<string, unknown>;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: Number(payload.id ?? 0),
|
||||||
|
settings: (payload.settings ?? {}) as Record<string, unknown>,
|
||||||
|
updated_at: (payload.updated_at ?? null) as string | null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function getTenantPackagesOverview(options?: { force?: boolean }): Promise<{
|
export async function getTenantPackagesOverview(options?: { force?: boolean }): Promise<{
|
||||||
packages: TenantPackageSummary[];
|
packages: TenantPackageSummary[];
|
||||||
activePackage: TenantPackageSummary | null;
|
activePackage: TenantPackageSummary | null;
|
||||||
|
|||||||
@@ -16,9 +16,10 @@ export interface TenantProfile {
|
|||||||
id: number;
|
id: number;
|
||||||
tenant_id: number;
|
tenant_id: number;
|
||||||
role?: string | null;
|
role?: string | null;
|
||||||
name?: string;
|
name?: string | null;
|
||||||
slug?: string;
|
slug?: string;
|
||||||
email?: string | null;
|
email?: string | null;
|
||||||
|
avatar_url?: string | null;
|
||||||
features?: Record<string, unknown>;
|
features?: Record<string, unknown>;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"login": {
|
"login": {
|
||||||
"title": "Event-Admin",
|
"title": "Event-Admin",
|
||||||
|
"pageTitle": "Login",
|
||||||
"badge": "Fotospiel Event Admin",
|
"badge": "Fotospiel Event Admin",
|
||||||
"hero_tagline": "Kontrolle behalten, entspannt bleiben",
|
"hero_tagline": "Kontrolle behalten, entspannt bleiben",
|
||||||
"hero_title": "Das Cockpit für dein Fotospiel Event",
|
"hero_title": "Das Cockpit für dein Fotospiel Event",
|
||||||
@@ -90,6 +91,9 @@
|
|||||||
"appearance_label": "Darstellung"
|
"appearance_label": "Darstellung"
|
||||||
},
|
},
|
||||||
"redirecting": "Weiterleitung zum Login …",
|
"redirecting": "Weiterleitung zum Login …",
|
||||||
|
"logout": {
|
||||||
|
"title": "Abmeldung wird vorbereitet …"
|
||||||
|
},
|
||||||
"processing": {
|
"processing": {
|
||||||
"title": "Anmeldung wird verarbeitet …",
|
"title": "Anmeldung wird verarbeitet …",
|
||||||
"copy": "Einen Moment bitte, wir bereiten dein Dashboard vor."
|
"copy": "Einen Moment bitte, wir bereiten dein Dashboard vor."
|
||||||
|
|||||||
@@ -200,7 +200,7 @@
|
|||||||
"plans": {
|
"plans": {
|
||||||
"title": "Pakete im Überblick",
|
"title": "Pakete im Überblick",
|
||||||
"subtitle": "Wähle das passende Kontingent",
|
"subtitle": "Wähle das passende Kontingent",
|
||||||
"hint": "Starter, Standard oder Partner – alles mit Moderation & QR-Codes.",
|
"hint": "Starter, Classic oder Partner – alles mit Moderation & QR-Codes.",
|
||||||
"starter": {
|
"starter": {
|
||||||
"title": "Starter",
|
"title": "Starter",
|
||||||
"badge": "Für ein Event",
|
"badge": "Für ein Event",
|
||||||
@@ -209,7 +209,7 @@
|
|||||||
"p3": "Moderation & Galerie-Link"
|
"p3": "Moderation & Galerie-Link"
|
||||||
},
|
},
|
||||||
"standard": {
|
"standard": {
|
||||||
"title": "Standard",
|
"title": "Classic",
|
||||||
"badge": "Beliebt",
|
"badge": "Beliebt",
|
||||||
"highlight": "Mehr Kontingent & Branding",
|
"highlight": "Mehr Kontingent & Branding",
|
||||||
"p1": "Mehr Events pro Jahr",
|
"p1": "Mehr Events pro Jahr",
|
||||||
|
|||||||
@@ -763,7 +763,7 @@
|
|||||||
"surface": "Fläche",
|
"surface": "Fläche",
|
||||||
"lockedBranding": "Branding ist in diesem Paket gesperrt.",
|
"lockedBranding": "Branding ist in diesem Paket gesperrt.",
|
||||||
"lockedTitle": "Branding freischalten",
|
"lockedTitle": "Branding freischalten",
|
||||||
"lockedBody": "Upgrade auf Standard oder Premium, um Event-Branding zu nutzen.",
|
"lockedBody": "Upgrade auf Classic oder Premium, um Event-Branding zu nutzen.",
|
||||||
"upgradeAction": "Paket upgraden",
|
"upgradeAction": "Paket upgraden",
|
||||||
"source": "Branding-Quelle",
|
"source": "Branding-Quelle",
|
||||||
"sourceHint": "Nutze das Standard-Branding oder passe nur dieses Event an.",
|
"sourceHint": "Nutze das Standard-Branding oder passe nur dieses Event an.",
|
||||||
@@ -983,6 +983,7 @@
|
|||||||
"layouts": "Druck-Layouts",
|
"layouts": "Druck-Layouts",
|
||||||
"preview": "Anpassen & Exportieren",
|
"preview": "Anpassen & Exportieren",
|
||||||
"createLink": "Neuen QR-Link erstellen",
|
"createLink": "Neuen QR-Link erstellen",
|
||||||
|
"createLinkConfirm": "Neuen QR-Link erstellen? Dadurch werden alle bisherigen Ausdrucke ungültig und alle Personen mit dem alten Link verlieren den Zugang.",
|
||||||
"mobileLinkLabel": "Mobiler Link",
|
"mobileLinkLabel": "Mobiler Link",
|
||||||
"created": "Neuer QR-Link erstellt",
|
"created": "Neuer QR-Link erstellt",
|
||||||
"createFailed": "Link konnte nicht erstellt werden.",
|
"createFailed": "Link konnte nicht erstellt werden.",
|
||||||
@@ -2933,7 +2934,7 @@
|
|||||||
"recommendedUsage": "Empfohlen innerhalb von 24 Monaten zu nutzen.",
|
"recommendedUsage": "Empfohlen innerhalb von 24 Monaten zu nutzen.",
|
||||||
"tiers": {
|
"tiers": {
|
||||||
"starter": "Starter",
|
"starter": "Starter",
|
||||||
"standard": "Standard",
|
"standard": "Classic",
|
||||||
"premium": "Premium"
|
"premium": "Premium"
|
||||||
},
|
},
|
||||||
"compare": {
|
"compare": {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
"appName": "Event Admin",
|
"appName": "Event Admin",
|
||||||
|
"documentTitle": "Fotospiel.App Event Admin",
|
||||||
"selectEvent": "Wähle ein Event, um fortzufahren",
|
"selectEvent": "Wähle ein Event, um fortzufahren",
|
||||||
"empty": "Lege dein erstes Event an, um zu starten",
|
"empty": "Lege dein erstes Event an, um zu starten",
|
||||||
"eventSwitcher": "Event auswählen",
|
"eventSwitcher": "Event auswählen",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"login": {
|
"login": {
|
||||||
"title": "Event Admin",
|
"title": "Event Admin",
|
||||||
|
"pageTitle": "Login",
|
||||||
"badge": "Fotospiel Event Admin",
|
"badge": "Fotospiel Event Admin",
|
||||||
"hero_tagline": "Stay in control, stay relaxed",
|
"hero_tagline": "Stay in control, stay relaxed",
|
||||||
"hero_title": "Your cockpit for every Fotospiel event",
|
"hero_title": "Your cockpit for every Fotospiel event",
|
||||||
@@ -90,6 +91,9 @@
|
|||||||
"appearance_label": "Appearance"
|
"appearance_label": "Appearance"
|
||||||
},
|
},
|
||||||
"redirecting": "Redirecting to login …",
|
"redirecting": "Redirecting to login …",
|
||||||
|
"logout": {
|
||||||
|
"title": "Signing out …"
|
||||||
|
},
|
||||||
"processing": {
|
"processing": {
|
||||||
"title": "Signing you in …",
|
"title": "Signing you in …",
|
||||||
"copy": "One moment please while we prepare your dashboard."
|
"copy": "One moment please while we prepare your dashboard."
|
||||||
|
|||||||
@@ -200,7 +200,7 @@
|
|||||||
"plans": {
|
"plans": {
|
||||||
"title": "Packages at a glance",
|
"title": "Packages at a glance",
|
||||||
"subtitle": "Choose the right quota",
|
"subtitle": "Choose the right quota",
|
||||||
"hint": "Starter, Standard or Partner – all include moderation & invites.",
|
"hint": "Starter, Classic or Partner – all include moderation & invites.",
|
||||||
"starter": {
|
"starter": {
|
||||||
"title": "Starter",
|
"title": "Starter",
|
||||||
"badge": "For one event",
|
"badge": "For one event",
|
||||||
@@ -209,7 +209,7 @@
|
|||||||
"p3": "Moderation & gallery link"
|
"p3": "Moderation & gallery link"
|
||||||
},
|
},
|
||||||
"standard": {
|
"standard": {
|
||||||
"title": "Standard",
|
"title": "Classic",
|
||||||
"badge": "Popular",
|
"badge": "Popular",
|
||||||
"highlight": "More quota & branding",
|
"highlight": "More quota & branding",
|
||||||
"p1": "More events per year",
|
"p1": "More events per year",
|
||||||
|
|||||||
@@ -759,7 +759,7 @@
|
|||||||
"surface": "Surface",
|
"surface": "Surface",
|
||||||
"lockedBranding": "Branding is locked for this package.",
|
"lockedBranding": "Branding is locked for this package.",
|
||||||
"lockedTitle": "Unlock branding",
|
"lockedTitle": "Unlock branding",
|
||||||
"lockedBody": "Upgrade to Standard or Premium to unlock event branding.",
|
"lockedBody": "Upgrade to Classic or Premium to unlock event branding.",
|
||||||
"upgradeAction": "Upgrade package",
|
"upgradeAction": "Upgrade package",
|
||||||
"source": "Branding source",
|
"source": "Branding source",
|
||||||
"sourceHint": "Use the default branding or customize this event only.",
|
"sourceHint": "Use the default branding or customize this event only.",
|
||||||
@@ -979,6 +979,7 @@
|
|||||||
"layouts": "Print Layouts",
|
"layouts": "Print Layouts",
|
||||||
"preview": "Customize & Export",
|
"preview": "Customize & Export",
|
||||||
"createLink": "Create new QR link",
|
"createLink": "Create new QR link",
|
||||||
|
"createLinkConfirm": "Create a new QR link? This will invalidate all printed materials and everyone with the old link will lose access.",
|
||||||
"mobileLinkLabel": "Mobile link",
|
"mobileLinkLabel": "Mobile link",
|
||||||
"created": "New QR link created",
|
"created": "New QR link created",
|
||||||
"createFailed": "Could not create link.",
|
"createFailed": "Could not create link.",
|
||||||
@@ -2935,7 +2936,7 @@
|
|||||||
"recommendedUsage": "Recommended to use within 24 months.",
|
"recommendedUsage": "Recommended to use within 24 months.",
|
||||||
"tiers": {
|
"tiers": {
|
||||||
"starter": "Starter",
|
"starter": "Starter",
|
||||||
"standard": "Standard",
|
"standard": "Classic",
|
||||||
"premium": "Premium"
|
"premium": "Premium"
|
||||||
},
|
},
|
||||||
"compare": {
|
"compare": {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
"appName": "Event Admin",
|
"appName": "Event Admin",
|
||||||
|
"documentTitle": "Fotospiel.App Event Admin",
|
||||||
"selectEvent": "Select an event to continue",
|
"selectEvent": "Select an event to continue",
|
||||||
"empty": "Create your first event to get started",
|
"empty": "Create your first event to get started",
|
||||||
"eventSwitcher": "Choose an event",
|
"eventSwitcher": "Choose an event",
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ const defaults = {
|
|||||||
accent: '#222222',
|
accent: '#222222',
|
||||||
background: '#ffffff',
|
background: '#ffffff',
|
||||||
surface: '#f0f0f0',
|
surface: '#f0f0f0',
|
||||||
|
headingFont: 'Default Heading',
|
||||||
|
bodyFont: 'Default Body',
|
||||||
mode: 'auto' as const,
|
mode: 'auto' as const,
|
||||||
buttonStyle: 'filled' as const,
|
buttonStyle: 'filled' as const,
|
||||||
buttonRadius: 12,
|
buttonRadius: 12,
|
||||||
@@ -87,7 +89,6 @@ describe('extractBrandingForm', () => {
|
|||||||
position: 'center',
|
position: 'center',
|
||||||
size: 'l',
|
size: 'l',
|
||||||
},
|
},
|
||||||
use_default_branding: true,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -105,7 +106,6 @@ describe('extractBrandingForm', () => {
|
|||||||
expect(result.logoValue).toBe('🎉');
|
expect(result.logoValue).toBe('🎉');
|
||||||
expect(result.logoPosition).toBe('center');
|
expect(result.logoPosition).toBe('center');
|
||||||
expect(result.logoSize).toBe('l');
|
expect(result.logoSize).toBe('l');
|
||||||
expect(result.useDefaultBranding).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('normalizes stored logo paths for previews', () => {
|
it('normalizes stored logo paths for previews', () => {
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ export type BrandingFormValues = {
|
|||||||
buttonPrimary: string;
|
buttonPrimary: string;
|
||||||
buttonSecondary: string;
|
buttonSecondary: string;
|
||||||
linkColor: string;
|
linkColor: string;
|
||||||
useDefaultBranding: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type BrandingFormDefaults = Pick<
|
export type BrandingFormDefaults = Pick<
|
||||||
@@ -26,6 +25,8 @@ export type BrandingFormDefaults = Pick<
|
|||||||
| 'accent'
|
| 'accent'
|
||||||
| 'background'
|
| 'background'
|
||||||
| 'surface'
|
| 'surface'
|
||||||
|
| 'headingFont'
|
||||||
|
| 'bodyFont'
|
||||||
| 'mode'
|
| 'mode'
|
||||||
| 'buttonStyle'
|
| 'buttonStyle'
|
||||||
| 'buttonRadius'
|
| 'buttonRadius'
|
||||||
@@ -130,8 +131,8 @@ export function extractBrandingForm(settings: unknown, defaults: BrandingFormDef
|
|||||||
accent,
|
accent,
|
||||||
background,
|
background,
|
||||||
surface,
|
surface,
|
||||||
headingFont: headingFont ?? '',
|
headingFont: headingFont ?? defaults.headingFont ?? '',
|
||||||
bodyFont: bodyFont ?? '',
|
bodyFont: bodyFont ?? defaults.bodyFont ?? '',
|
||||||
fontSize,
|
fontSize,
|
||||||
logoDataUrl: resolveAssetPreviewUrl(logoUploadValue),
|
logoDataUrl: resolveAssetPreviewUrl(logoUploadValue),
|
||||||
logoValue,
|
logoValue,
|
||||||
@@ -144,6 +145,5 @@ export function extractBrandingForm(settings: unknown, defaults: BrandingFormDef
|
|||||||
buttonPrimary,
|
buttonPrimary,
|
||||||
buttonSecondary,
|
buttonSecondary,
|
||||||
linkColor,
|
linkColor,
|
||||||
useDefaultBranding: branding.use_default_branding === true,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useAuth } from '../auth/context';
|
|||||||
import { ADMIN_DEFAULT_AFTER_LOGIN_PATH } from '../constants';
|
import { ADMIN_DEFAULT_AFTER_LOGIN_PATH } from '../constants';
|
||||||
import { decodeReturnTo, resolveReturnTarget } from '../lib/returnTo';
|
import { decodeReturnTo, resolveReturnTarget } from '../lib/returnTo';
|
||||||
import { useAdminTheme } from './theme';
|
import { useAdminTheme } from './theme';
|
||||||
|
import { useDocumentTitle } from './hooks/useDocumentTitle';
|
||||||
|
|
||||||
export default function AuthCallbackPage(): React.ReactElement {
|
export default function AuthCallbackPage(): React.ReactElement {
|
||||||
const { status } = useAuth();
|
const { status } = useAuth();
|
||||||
@@ -21,6 +22,8 @@ export default function AuthCallbackPage(): React.ReactElement {
|
|||||||
paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 16px)',
|
paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 16px)',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useDocumentTitle(t('processing.title', 'Signing you in …'));
|
||||||
|
|
||||||
const searchParams = React.useMemo(() => new URLSearchParams(window.location.search), []);
|
const searchParams = React.useMemo(() => new URLSearchParams(window.location.search), []);
|
||||||
const rawReturnTo = searchParams.get('return_to');
|
const rawReturnTo = searchParams.get('return_to');
|
||||||
|
|
||||||
|
|||||||
@@ -524,7 +524,7 @@ function PackageCard({
|
|||||||
pkg.included_package_slug === 'starter'
|
pkg.included_package_slug === 'starter'
|
||||||
? t('shop.partner.tiers.starter', 'Starter')
|
? t('shop.partner.tiers.starter', 'Starter')
|
||||||
: pkg.included_package_slug === 'standard'
|
: pkg.included_package_slug === 'standard'
|
||||||
? t('shop.partner.tiers.standard', 'Standard')
|
? t('shop.partner.tiers.standard', 'Classic')
|
||||||
: pkg.included_package_slug === 'pro'
|
: pkg.included_package_slug === 'pro'
|
||||||
? t('shop.partner.tiers.premium', 'Premium')
|
? t('shop.partner.tiers.premium', 'Premium')
|
||||||
: pkg.included_package_slug;
|
: pkg.included_package_slug;
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ const BRANDING_FORM_DEFAULTS = {
|
|||||||
accent: DEFAULT_EVENT_BRANDING.secondaryColor,
|
accent: DEFAULT_EVENT_BRANDING.secondaryColor,
|
||||||
background: DEFAULT_EVENT_BRANDING.backgroundColor,
|
background: DEFAULT_EVENT_BRANDING.backgroundColor,
|
||||||
surface: DEFAULT_EVENT_BRANDING.palette?.surface ?? DEFAULT_EVENT_BRANDING.backgroundColor,
|
surface: DEFAULT_EVENT_BRANDING.palette?.surface ?? DEFAULT_EVENT_BRANDING.backgroundColor,
|
||||||
|
headingFont: DEFAULT_EVENT_BRANDING.typography?.heading ?? DEFAULT_EVENT_BRANDING.fontFamily ?? '',
|
||||||
|
bodyFont: DEFAULT_EVENT_BRANDING.typography?.body ?? DEFAULT_EVENT_BRANDING.fontFamily ?? '',
|
||||||
mode: DEFAULT_EVENT_BRANDING.mode ?? 'auto',
|
mode: DEFAULT_EVENT_BRANDING.mode ?? 'auto',
|
||||||
buttonStyle: DEFAULT_EVENT_BRANDING.buttons?.style ?? 'filled',
|
buttonStyle: DEFAULT_EVENT_BRANDING.buttons?.style ?? 'filled',
|
||||||
buttonRadius: DEFAULT_EVENT_BRANDING.buttons?.radius ?? 12,
|
buttonRadius: DEFAULT_EVENT_BRANDING.buttons?.radius ?? 12,
|
||||||
@@ -40,14 +42,11 @@ const BRANDING_FORM_DEFAULTS = {
|
|||||||
logoSize: DEFAULT_EVENT_BRANDING.logo?.size ?? 'm',
|
logoSize: DEFAULT_EVENT_BRANDING.logo?.size ?? 'm',
|
||||||
};
|
};
|
||||||
|
|
||||||
const BRANDING_FORM_BASE: BrandingFormValues = {
|
const buildBrandingFormBase = (defaults: typeof BRANDING_FORM_DEFAULTS): BrandingFormValues => ({
|
||||||
...BRANDING_FORM_DEFAULTS,
|
...defaults,
|
||||||
headingFont: '',
|
|
||||||
bodyFont: '',
|
|
||||||
logoDataUrl: '',
|
logoDataUrl: '',
|
||||||
logoValue: '',
|
logoValue: '',
|
||||||
useDefaultBranding: false,
|
});
|
||||||
};
|
|
||||||
|
|
||||||
const FONT_SIZE_SCALE: Record<BrandingFormValues['fontSize'], number> = {
|
const FONT_SIZE_SCALE: Record<BrandingFormValues['fontSize'], number> = {
|
||||||
s: 0.94,
|
s: 0.94,
|
||||||
@@ -61,6 +60,38 @@ const LOGO_SIZE_PREVIEW: Record<BrandingFormValues['logoSize'], number> = {
|
|||||||
l: 44,
|
l: 44,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resolveBrandingDefaults = (tenantBranding: BrandingFormValues | null) => {
|
||||||
|
if (!tenantBranding) {
|
||||||
|
return BRANDING_FORM_DEFAULTS;
|
||||||
|
}
|
||||||
|
|
||||||
|
const primary = tenantBranding.primary.trim() ? tenantBranding.primary : BRANDING_FORM_DEFAULTS.primary;
|
||||||
|
const accent = tenantBranding.accent.trim() ? tenantBranding.accent : BRANDING_FORM_DEFAULTS.accent;
|
||||||
|
const background = tenantBranding.background.trim() ? tenantBranding.background : BRANDING_FORM_DEFAULTS.background;
|
||||||
|
const surface = tenantBranding.surface.trim() ? tenantBranding.surface : BRANDING_FORM_DEFAULTS.surface;
|
||||||
|
const headingFont = tenantBranding.headingFont.trim()
|
||||||
|
? tenantBranding.headingFont
|
||||||
|
: BRANDING_FORM_DEFAULTS.headingFont;
|
||||||
|
const bodyFont = tenantBranding.bodyFont.trim()
|
||||||
|
? tenantBranding.bodyFont
|
||||||
|
: BRANDING_FORM_DEFAULTS.bodyFont;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...BRANDING_FORM_DEFAULTS,
|
||||||
|
primary,
|
||||||
|
accent,
|
||||||
|
background,
|
||||||
|
surface,
|
||||||
|
headingFont,
|
||||||
|
bodyFont,
|
||||||
|
fontSize: tenantBranding.fontSize ?? BRANDING_FORM_DEFAULTS.fontSize,
|
||||||
|
mode: tenantBranding.mode ?? BRANDING_FORM_DEFAULTS.mode,
|
||||||
|
buttonPrimary: primary,
|
||||||
|
buttonSecondary: accent,
|
||||||
|
linkColor: accent,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
type WatermarkPosition =
|
type WatermarkPosition =
|
||||||
| 'top-left'
|
| 'top-left'
|
||||||
| 'top-center'
|
| 'top-center'
|
||||||
@@ -95,7 +126,7 @@ export default function MobileBrandingPage() {
|
|||||||
const { textStrong, muted, subtle, border, primary, accentSoft, danger, surfaceMuted, surface } = useAdminTheme();
|
const { textStrong, muted, subtle, border, primary, accentSoft, danger, surfaceMuted, surface } = useAdminTheme();
|
||||||
|
|
||||||
const [event, setEvent] = React.useState<TenantEvent | null>(null);
|
const [event, setEvent] = React.useState<TenantEvent | null>(null);
|
||||||
const [form, setForm] = React.useState<BrandingFormValues>(BRANDING_FORM_BASE);
|
const [form, setForm] = React.useState<BrandingFormValues>(() => buildBrandingFormBase(BRANDING_FORM_DEFAULTS));
|
||||||
const [watermarkForm, setWatermarkForm] = React.useState<WatermarkForm>({
|
const [watermarkForm, setWatermarkForm] = React.useState<WatermarkForm>({
|
||||||
mode: 'base',
|
mode: 'base',
|
||||||
assetPath: '',
|
assetPath: '',
|
||||||
@@ -120,10 +151,13 @@ export default function MobileBrandingPage() {
|
|||||||
const [fontsLoaded, setFontsLoaded] = React.useState(false);
|
const [fontsLoaded, setFontsLoaded] = React.useState(false);
|
||||||
const [tenantBranding, setTenantBranding] = React.useState<BrandingFormValues | null>(null);
|
const [tenantBranding, setTenantBranding] = React.useState<BrandingFormValues | null>(null);
|
||||||
const [tenantBrandingLoaded, setTenantBrandingLoaded] = React.useState(false);
|
const [tenantBrandingLoaded, setTenantBrandingLoaded] = React.useState(false);
|
||||||
|
const [formInitialized, setFormInitialized] = React.useState(false);
|
||||||
|
const resolvedDefaults = React.useMemo(() => resolveBrandingDefaults(tenantBranding), [tenantBranding]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!slug) return;
|
if (!slug) return;
|
||||||
(async () => {
|
(async () => {
|
||||||
|
setFormInitialized(false);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await getEvent(slug);
|
const data = await getEvent(slug);
|
||||||
@@ -132,7 +166,6 @@ export default function MobileBrandingPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setEvent(data);
|
setEvent(data);
|
||||||
setForm(extractBrandingForm(data.settings ?? {}, BRANDING_FORM_DEFAULTS));
|
|
||||||
setWatermarkForm(extractWatermark(data));
|
setWatermarkForm(extractWatermark(data));
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -145,6 +178,12 @@ export default function MobileBrandingPage() {
|
|||||||
})();
|
})();
|
||||||
}, [slug, t]);
|
}, [slug, t]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!event || !tenantBrandingLoaded || formInitialized) return;
|
||||||
|
setForm(extractBrandingForm(event.settings ?? {}, resolvedDefaults));
|
||||||
|
setFormInitialized(true);
|
||||||
|
}, [event, tenantBrandingLoaded, formInitialized, resolvedDefaults]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!showFontsSheet || fontsLoaded) return;
|
if (!showFontsSheet || fontsLoaded) return;
|
||||||
setFontsLoading(true);
|
setFontsLoading(true);
|
||||||
@@ -177,7 +216,7 @@ export default function MobileBrandingPage() {
|
|||||||
};
|
};
|
||||||
}, [tenantBrandingLoaded]);
|
}, [tenantBrandingLoaded]);
|
||||||
|
|
||||||
const previewForm = form.useDefaultBranding && tenantBranding ? tenantBranding : form;
|
const previewForm = form;
|
||||||
const previewBackground = previewForm.background;
|
const previewBackground = previewForm.background;
|
||||||
const previewSurfaceCandidate = previewForm.surface || previewBackground;
|
const previewSurfaceCandidate = previewForm.surface || previewBackground;
|
||||||
const backgroundLuminance = relativeLuminance(previewBackground);
|
const backgroundLuminance = relativeLuminance(previewBackground);
|
||||||
@@ -206,7 +245,7 @@ export default function MobileBrandingPage() {
|
|||||||
const brandingAllowed = isBrandingAllowed(event ?? null);
|
const brandingAllowed = isBrandingAllowed(event ?? null);
|
||||||
const customWatermarkAllowed = watermarkAllowed && brandingAllowed;
|
const customWatermarkAllowed = watermarkAllowed && brandingAllowed;
|
||||||
const watermarkLocked = watermarkAllowed && !brandingAllowed;
|
const watermarkLocked = watermarkAllowed && !brandingAllowed;
|
||||||
const brandingDisabled = !brandingAllowed || form.useDefaultBranding;
|
const brandingDisabled = !brandingAllowed;
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setWatermarkForm((prev) => {
|
setWatermarkForm((prev) => {
|
||||||
@@ -243,7 +282,6 @@ export default function MobileBrandingPage() {
|
|||||||
|
|
||||||
settings.branding = {
|
settings.branding = {
|
||||||
...(typeof settings.branding === 'object' ? (settings.branding as Record<string, unknown>) : {}),
|
...(typeof settings.branding === 'object' ? (settings.branding as Record<string, unknown>) : {}),
|
||||||
use_default_branding: form.useDefaultBranding,
|
|
||||||
primary_color: form.primary,
|
primary_color: form.primary,
|
||||||
secondary_color: form.accent,
|
secondary_color: form.accent,
|
||||||
accent_color: form.accent,
|
accent_color: form.accent,
|
||||||
@@ -334,7 +372,7 @@ export default function MobileBrandingPage() {
|
|||||||
|
|
||||||
function handleReset() {
|
function handleReset() {
|
||||||
if (event) {
|
if (event) {
|
||||||
setForm(extractBrandingForm(event.settings ?? {}, BRANDING_FORM_DEFAULTS));
|
setForm(extractBrandingForm(event.settings ?? {}, resolvedDefaults));
|
||||||
setWatermarkForm(extractWatermark(event));
|
setWatermarkForm(extractWatermark(event));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -656,42 +694,13 @@ export default function MobileBrandingPage() {
|
|||||||
{!brandingAllowed ? (
|
{!brandingAllowed ? (
|
||||||
<UpgradeCard
|
<UpgradeCard
|
||||||
title={t('events.branding.lockedTitle', 'Unlock branding')}
|
title={t('events.branding.lockedTitle', 'Unlock branding')}
|
||||||
body={t('events.branding.lockedBody', 'Upgrade to Standard or Premium to unlock event branding.')}
|
body={t('events.branding.lockedBody', 'Upgrade to Classic or Premium to unlock event branding.')}
|
||||||
actionLabel={t('events.branding.upgradeAction', 'Upgrade package')}
|
actionLabel={t('events.branding.upgradeAction', 'Upgrade package')}
|
||||||
onPress={() => navigate(adminPath('/mobile/billing/shop?feature=custom_branding'))}
|
onPress={() => navigate(adminPath('/mobile/billing/shop?feature=custom_branding'))}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<MobileCard space="$3">
|
<>
|
||||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
|
||||||
{t('events.branding.source', 'Branding Source')}
|
|
||||||
</Text>
|
|
||||||
<Text fontSize="$sm" color={muted}>
|
|
||||||
{t('events.branding.sourceHint', 'Use the default branding or customize this event only.')}
|
|
||||||
</Text>
|
|
||||||
<XStack space="$2">
|
|
||||||
<ModeButton
|
|
||||||
label={t('events.branding.useDefault', 'Default')}
|
|
||||||
active={form.useDefaultBranding}
|
|
||||||
onPress={() => setForm((prev) => ({ ...prev, useDefaultBranding: true }))}
|
|
||||||
disabled={!brandingAllowed}
|
|
||||||
/>
|
|
||||||
<ModeButton
|
|
||||||
label={t('events.branding.useCustom', 'This event')}
|
|
||||||
active={!form.useDefaultBranding}
|
|
||||||
onPress={() => setForm((prev) => ({ ...prev, useDefaultBranding: false }))}
|
|
||||||
disabled={!brandingAllowed}
|
|
||||||
/>
|
|
||||||
</XStack>
|
|
||||||
<Text fontSize="$xs" color={muted}>
|
|
||||||
{form.useDefaultBranding
|
|
||||||
? t('events.branding.usingDefault', 'Account-Branding aktiv')
|
|
||||||
: t('events.branding.usingCustom', 'Event-Branding aktiv')}
|
|
||||||
</Text>
|
|
||||||
</MobileCard>
|
|
||||||
|
|
||||||
{form.useDefaultBranding ? null : (
|
|
||||||
<>
|
|
||||||
<MobileCard space="$3">
|
<MobileCard space="$3">
|
||||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||||
{t('events.branding.mode', 'Theme')}
|
{t('events.branding.mode', 'Theme')}
|
||||||
@@ -1024,8 +1033,7 @@ export default function MobileBrandingPage() {
|
|||||||
disabled={brandingDisabled}
|
disabled={brandingDisabled}
|
||||||
/>
|
/>
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
</>
|
</>
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
renderWatermarkTab()
|
renderWatermarkTab()
|
||||||
@@ -1324,7 +1332,7 @@ function LabeledSlider({
|
|||||||
max={max}
|
max={max}
|
||||||
step={step}
|
step={step}
|
||||||
value={[value]}
|
value={[value]}
|
||||||
onValueChange={(next) => onChange(next[0] ?? value)}
|
onValueChange={(next: number[]) => onChange(next[0] ?? value)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
<Slider.Track height={6} borderRadius={999} backgroundColor={border}>
|
<Slider.Track height={6} borderRadius={999} backgroundColor={border}>
|
||||||
|
|||||||
@@ -108,12 +108,13 @@ function SectionHeader({
|
|||||||
|
|
||||||
function StatusBadge({ status }: { status: string }) {
|
function StatusBadge({ status }: { status: string }) {
|
||||||
const { t } = useTranslation('management');
|
const { t } = useTranslation('management');
|
||||||
const config =
|
type StatusTone = 'success' | 'warning' | 'danger' | 'muted';
|
||||||
{
|
const statuses: Record<string, { tone: StatusTone; label: string }> = {
|
||||||
published: { tone: 'success', label: t('events.status.published', 'Live') },
|
published: { tone: 'success', label: t('events.status.published', 'Live') },
|
||||||
draft: { tone: 'warning', label: t('events.status.draft', 'Draft') },
|
draft: { tone: 'warning', label: t('events.status.draft', 'Draft') },
|
||||||
archived: { tone: 'muted', label: t('events.status.archived', 'Archived') },
|
archived: { tone: 'muted', label: t('events.status.archived', 'Archived') },
|
||||||
}[status] || { tone: 'muted', label: status };
|
};
|
||||||
|
const config = statuses[status] ?? { tone: 'muted', label: status };
|
||||||
|
|
||||||
return <PillBadge tone={config.tone}>{config.label}</PillBadge>;
|
return <PillBadge tone={config.tone}>{config.label}</PillBadge>;
|
||||||
}
|
}
|
||||||
@@ -408,7 +409,7 @@ function LifecycleHero({
|
|||||||
<DashboardCard variant={cardVariant} padding={cardPadding}>
|
<DashboardCard variant={cardVariant} padding={cardPadding}>
|
||||||
<YStack space={isEmbedded ? '$2.5' : '$3'}>
|
<YStack space={isEmbedded ? '$2.5' : '$3'}>
|
||||||
<XStack alignItems="center" space="$2.5">
|
<XStack alignItems="center" space="$2.5">
|
||||||
<YStack width={40} height={40} borderRadius={20} backgroundColor={theme.success} alignItems="center" justifyContent="center">
|
<YStack width={40} height={40} borderRadius={20} backgroundColor={theme.successText} alignItems="center" justifyContent="center">
|
||||||
<CheckCircle2 size={20} color="white" />
|
<CheckCircle2 size={20} color="white" />
|
||||||
</YStack>
|
</YStack>
|
||||||
<YStack>
|
<YStack>
|
||||||
@@ -486,7 +487,7 @@ function LifecycleHero({
|
|||||||
</Text>
|
</Text>
|
||||||
<Switch
|
<Switch
|
||||||
checked={published}
|
checked={published}
|
||||||
onCheckedChange={(checked) => handlePublishChange(Boolean(checked))}
|
onCheckedChange={(checked: boolean) => handlePublishChange(Boolean(checked))}
|
||||||
size="$2"
|
size="$2"
|
||||||
disabled={isPublishing}
|
disabled={isPublishing}
|
||||||
aria-label={t('eventForm.fields.publish.label', 'Publish immediately')}
|
aria-label={t('eventForm.fields.publish.label', 'Publish immediately')}
|
||||||
@@ -625,26 +626,27 @@ function UnifiedToolGrid({ event, navigate, permissions, isMember, isCompleted }
|
|||||||
const { t } = useTranslation(['management', 'dashboard']);
|
const { t } = useTranslation(['management', 'dashboard']);
|
||||||
const slug = event?.slug;
|
const slug = event?.slug;
|
||||||
if (!slug) return null;
|
if (!slug) return null;
|
||||||
|
type ToolItem = { label: string; icon: any; path: string; color?: string };
|
||||||
|
|
||||||
const experienceItems = [
|
const experienceItems: ToolItem[] = [
|
||||||
{ label: t('management:photos.gallery.title', 'Photos'), icon: ImageIcon, path: `/mobile/events/${slug}/control-room`, color: theme.primary },
|
{ label: t('management:photos.gallery.title', 'Photos'), icon: ImageIcon, path: `/mobile/events/${slug}/control-room`, color: theme.primary },
|
||||||
!isCompleted ? { label: t('management:events.quick.liveShowSettings', 'Slide Show'), icon: Tv, path: `/mobile/events/${slug}/live-show/settings`, color: '#F59E0B' } : null,
|
!isCompleted ? { label: t('management:events.quick.liveShowSettings', 'Slide Show'), icon: Tv, path: `/mobile/events/${slug}/live-show/settings`, color: '#F59E0B' } : null,
|
||||||
!isCompleted ? { label: t('events.tasks.badge', 'Photo tasks'), icon: ListTodo, path: `/mobile/events/${slug}/tasks`, color: theme.accent } : null,
|
!isCompleted ? { label: t('events.tasks.badge', 'Photo tasks'), icon: ListTodo, path: `/mobile/events/${slug}/tasks`, color: theme.accent } : null,
|
||||||
!isCompleted ? { label: t('management:events.quick.photobooth', 'Photobooth'), icon: Camera, path: `/mobile/events/${slug}/photobooth`, color: '#8B5CF6' } : null,
|
!isCompleted ? { label: t('management:events.quick.photobooth', 'Photobooth'), icon: Camera, path: `/mobile/events/${slug}/photobooth`, color: '#8B5CF6' } : null,
|
||||||
].filter((item): item is { label: string; icon: any; path: string; color?: string } => Boolean(item));
|
].filter(Boolean) as ToolItem[];
|
||||||
|
|
||||||
const operationsItems = [
|
const operationsItems: ToolItem[] = [
|
||||||
!isCompleted ? { label: t('management:invites.badge', 'QR Codes'), icon: QrCode, path: `/mobile/events/${slug}/qr`, color: '#10B981' } : null,
|
!isCompleted ? { label: t('management:invites.badge', 'QR Codes'), icon: QrCode, path: `/mobile/events/${slug}/qr`, color: '#10B981' } : null,
|
||||||
{ label: t('management:events.quick.guests', 'Guests'), icon: Users, path: `/mobile/events/${slug}/members`, color: ADMIN_ACTION_COLORS.guests },
|
{ label: t('management:events.quick.guests', 'Guests'), icon: Users, path: `/mobile/events/${slug}/members`, color: ADMIN_ACTION_COLORS.guests },
|
||||||
!isCompleted ? { label: t('management:events.quick.guestMessages', 'Messages'), icon: Megaphone, path: `/mobile/events/${slug}/guest-notifications`, color: ADMIN_ACTION_COLORS.guestMessages } : null,
|
!isCompleted ? { label: t('management:events.quick.guestMessages', 'Messages'), icon: Megaphone, path: `/mobile/events/${slug}/guest-notifications`, color: ADMIN_ACTION_COLORS.guestMessages } : null,
|
||||||
!isCompleted ? { label: t('events.branding.titleShort', 'Branding'), icon: Layout, path: `/mobile/events/${slug}/branding`, color: ADMIN_ACTION_COLORS.branding } : null,
|
!isCompleted ? { label: t('events.branding.titleShort', 'Branding'), icon: Layout, path: `/mobile/events/${slug}/branding`, color: ADMIN_ACTION_COLORS.branding } : null,
|
||||||
].filter((item): item is { label: string; icon: any; path: string; color?: string } => Boolean(item));
|
].filter(Boolean) as ToolItem[];
|
||||||
|
|
||||||
const adminItems = [
|
const adminItems: ToolItem[] = [
|
||||||
{ label: t('management:mobileDashboard.shortcutAnalytics', 'Analytics'), icon: TrendingUp, path: `/mobile/events/${slug}/analytics`, color: ADMIN_ACTION_COLORS.analytics },
|
{ label: t('management:mobileDashboard.shortcutAnalytics', 'Analytics'), icon: TrendingUp, path: `/mobile/events/${slug}/analytics`, color: ADMIN_ACTION_COLORS.analytics },
|
||||||
!isCompleted ? { label: t('events.recap.exportTitleShort', 'Exports'), icon: Download, path: `/mobile/exports`, color: ADMIN_ACTION_COLORS.recap } : null,
|
!isCompleted ? { label: t('events.recap.exportTitleShort', 'Exports'), icon: Download, path: `/mobile/exports`, color: ADMIN_ACTION_COLORS.recap } : null,
|
||||||
{ label: t('management:mobileProfile.settings', 'Settings'), icon: Settings, path: `/mobile/events/${slug}/edit`, color: ADMIN_ACTION_COLORS.settings },
|
{ label: t('management:mobileProfile.settings', 'Settings'), icon: Settings, path: `/mobile/events/${slug}/edit`, color: ADMIN_ACTION_COLORS.settings },
|
||||||
].filter((item): item is { label: string; icon: any; path: string; color?: string } => Boolean(item));
|
].filter(Boolean) as ToolItem[];
|
||||||
|
|
||||||
const sections = [
|
const sections = [
|
||||||
{
|
{
|
||||||
@@ -719,11 +721,11 @@ function UnifiedToolGrid({ event, navigate, permissions, isMember, isCompleted }
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RecentPhotosSection({ photos, navigate, slug }: { photos: TenantPhoto[], navigate: any, slug: string }) {
|
function RecentPhotosSection({ photos, navigate, slug }: { photos: TenantPhoto[]; navigate: any; slug?: string }) {
|
||||||
const theme = useAdminTheme();
|
const theme = useAdminTheme();
|
||||||
const { t } = useTranslation('management');
|
const { t } = useTranslation('management');
|
||||||
|
|
||||||
if (!photos.length) return null;
|
if (!photos.length || !slug) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardCard>
|
<DashboardCard>
|
||||||
|
|||||||
@@ -1110,7 +1110,7 @@ export default function MobileEventControlRoomPage() {
|
|||||||
size="$3"
|
size="$3"
|
||||||
checked={autoApproveHighlights}
|
checked={autoApproveHighlights}
|
||||||
disabled={controlRoomSaving}
|
disabled={controlRoomSaving}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked: boolean) =>
|
||||||
saveControlRoomSettings({
|
saveControlRoomSettings({
|
||||||
...controlRoomSettings,
|
...controlRoomSettings,
|
||||||
auto_approve_highlights: Boolean(checked),
|
auto_approve_highlights: Boolean(checked),
|
||||||
@@ -1139,7 +1139,7 @@ export default function MobileEventControlRoomPage() {
|
|||||||
size="$3"
|
size="$3"
|
||||||
checked={autoAddApprovedToLive}
|
checked={autoAddApprovedToLive}
|
||||||
disabled={controlRoomSaving || isImmediateUploads}
|
disabled={controlRoomSaving || isImmediateUploads}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked: boolean) => {
|
||||||
if (isImmediateUploads) {
|
if (isImmediateUploads) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1164,7 +1164,7 @@ export default function MobileEventControlRoomPage() {
|
|||||||
size="$3"
|
size="$3"
|
||||||
checked={autoRemoveLiveOnHide}
|
checked={autoRemoveLiveOnHide}
|
||||||
disabled={controlRoomSaving}
|
disabled={controlRoomSaving}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked: boolean) =>
|
||||||
saveControlRoomSettings({
|
saveControlRoomSettings({
|
||||||
...controlRoomSettings,
|
...controlRoomSettings,
|
||||||
auto_remove_live_on_hide: Boolean(checked),
|
auto_remove_live_on_hide: Boolean(checked),
|
||||||
@@ -1388,7 +1388,7 @@ export default function MobileEventControlRoomPage() {
|
|||||||
<ToggleGroup
|
<ToggleGroup
|
||||||
type="single"
|
type="single"
|
||||||
value={moderationFilter}
|
value={moderationFilter}
|
||||||
onValueChange={(value) => value && setModerationFilter(value as ModerationFilter)}
|
onValueChange={(value: string) => value && setModerationFilter(value as ModerationFilter)}
|
||||||
>
|
>
|
||||||
<XStack space="$1.5">
|
<XStack space="$1.5">
|
||||||
{MODERATION_FILTERS.map((option) => {
|
{MODERATION_FILTERS.map((option) => {
|
||||||
@@ -1576,7 +1576,7 @@ export default function MobileEventControlRoomPage() {
|
|||||||
<ToggleGroup
|
<ToggleGroup
|
||||||
type="single"
|
type="single"
|
||||||
value={liveStatusFilter}
|
value={liveStatusFilter}
|
||||||
onValueChange={(value) => value && setLiveStatusFilter(value as LiveShowQueueStatus)}
|
onValueChange={(value: string) => value && setLiveStatusFilter(value as LiveShowQueueStatus)}
|
||||||
>
|
>
|
||||||
<XStack space="$1.5">
|
<XStack space="$1.5">
|
||||||
{LIVE_STATUS_OPTIONS.map((option) => {
|
{LIVE_STATUS_OPTIONS.map((option) => {
|
||||||
|
|||||||
@@ -245,7 +245,7 @@ export default function MobileEventFormPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (slugValue === 'standard') {
|
if (slugValue === 'standard') {
|
||||||
return 'Standard';
|
return 'Classic';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (slugValue === 'pro') {
|
if (slugValue === 'pro') {
|
||||||
@@ -527,7 +527,7 @@ export default function MobileEventFormPage() {
|
|||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" space="$2">
|
||||||
<Switch
|
<Switch
|
||||||
checked={form.published}
|
checked={form.published}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked: boolean) =>
|
||||||
setForm((prev) => ({ ...prev, published: Boolean(checked) }))
|
setForm((prev) => ({ ...prev, published: Boolean(checked) }))
|
||||||
}
|
}
|
||||||
size="$3"
|
size="$3"
|
||||||
@@ -546,7 +546,7 @@ export default function MobileEventFormPage() {
|
|||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" space="$2">
|
||||||
<Switch
|
<Switch
|
||||||
checked={form.tasksEnabled}
|
checked={form.tasksEnabled}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked: boolean) =>
|
||||||
setForm((prev) => ({ ...prev, tasksEnabled: Boolean(checked) }))
|
setForm((prev) => ({ ...prev, tasksEnabled: Boolean(checked) }))
|
||||||
}
|
}
|
||||||
size="$3"
|
size="$3"
|
||||||
@@ -577,7 +577,7 @@ export default function MobileEventFormPage() {
|
|||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" space="$2">
|
||||||
<Switch
|
<Switch
|
||||||
checked={form.autoApproveUploads}
|
checked={form.autoApproveUploads}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked: boolean) =>
|
||||||
setForm((prev) => ({ ...prev, autoApproveUploads: Boolean(checked) }))
|
setForm((prev) => ({ ...prev, autoApproveUploads: Boolean(checked) }))
|
||||||
}
|
}
|
||||||
size="$3"
|
size="$3"
|
||||||
|
|||||||
@@ -677,7 +677,7 @@ function EffectSlider({
|
|||||||
max={max}
|
max={max}
|
||||||
step={step}
|
step={step}
|
||||||
value={[value]}
|
value={[value]}
|
||||||
onValueChange={(next) => onChange(next[0] ?? value)}
|
onValueChange={(next: number[]) => onChange(next[0] ?? value)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
<Slider.Track height={6} borderRadius={999} backgroundColor={border}>
|
<Slider.Track height={6} borderRadius={999} backgroundColor={border}>
|
||||||
|
|||||||
@@ -1158,7 +1158,7 @@ export default function MobileEventTasksPage() {
|
|||||||
|
|
||||||
<Tabs
|
<Tabs
|
||||||
value={activeTab}
|
value={activeTab}
|
||||||
onValueChange={(value) => setActiveTab(value as TaskSectionKey)}
|
onValueChange={(value: string) => setActiveTab(value as TaskSectionKey)}
|
||||||
flexDirection="column"
|
flexDirection="column"
|
||||||
alignItems="stretch"
|
alignItems="stretch"
|
||||||
width="100%"
|
width="100%"
|
||||||
@@ -1411,7 +1411,7 @@ export default function MobileEventTasksPage() {
|
|||||||
>
|
>
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
value={emotionFilter}
|
value={emotionFilter}
|
||||||
onValueChange={(val) => {
|
onValueChange={(val: string) => {
|
||||||
setEmotionFilter(val);
|
setEmotionFilter(val);
|
||||||
setShowEmotionFilterSheet(false);
|
setShowEmotionFilterSheet(false);
|
||||||
}}
|
}}
|
||||||
@@ -1441,7 +1441,7 @@ export default function MobileEventTasksPage() {
|
|||||||
|
|
||||||
<AlertDialog
|
<AlertDialog
|
||||||
open={Boolean(deleteCandidate)}
|
open={Boolean(deleteCandidate)}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open: boolean) => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setDeleteCandidate(null);
|
setDeleteCandidate(null);
|
||||||
}
|
}
|
||||||
@@ -1496,7 +1496,7 @@ export default function MobileEventTasksPage() {
|
|||||||
|
|
||||||
<AlertDialog
|
<AlertDialog
|
||||||
open={bulkDeleteOpen}
|
open={bulkDeleteOpen}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open: boolean) => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setBulkDeleteOpen(false);
|
setBulkDeleteOpen(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -324,7 +324,7 @@ function EventsList({
|
|||||||
<ToggleGroup
|
<ToggleGroup
|
||||||
type="single"
|
type="single"
|
||||||
value={statusFilter}
|
value={statusFilter}
|
||||||
onValueChange={(value) => value && onStatusChange(value as EventStatusKey)}
|
onValueChange={(value: string) => value && onStatusChange(value as EventStatusKey)}
|
||||||
>
|
>
|
||||||
<XStack space="$1.5">
|
<XStack space="$1.5">
|
||||||
{filters.map((filter) => {
|
{filters.map((filter) => {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { ADMIN_LOGIN_PATH } from '../constants';
|
|||||||
import { MobileCard } from './components/Primitives';
|
import { MobileCard } from './components/Primitives';
|
||||||
import { MobileField, MobileInput } from './components/FormControls';
|
import { MobileField, MobileInput } from './components/FormControls';
|
||||||
import { ADMIN_GRADIENTS, useAdminTheme } from './theme';
|
import { ADMIN_GRADIENTS, useAdminTheme } from './theme';
|
||||||
|
import { useDocumentTitle } from './hooks/useDocumentTitle';
|
||||||
|
|
||||||
type ResetResponse = {
|
type ResetResponse = {
|
||||||
status: string;
|
status: string;
|
||||||
@@ -51,6 +52,8 @@ export default function ForgotPasswordPage() {
|
|||||||
paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 16px)',
|
paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 16px)',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useDocumentTitle(t('login.forgot.title', 'Forgot your password?'));
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationKey: ['tenantAdminForgotPassword'],
|
mutationKey: ['tenantAdminForgotPassword'],
|
||||||
mutationFn: requestPasswordReset,
|
mutationFn: requestPasswordReset,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { Button } from '@tamagui/button';
|
|||||||
import { MobileCard } from './components/Primitives';
|
import { MobileCard } from './components/Primitives';
|
||||||
import { MobileField, MobileInput } from './components/FormControls';
|
import { MobileField, MobileInput } from './components/FormControls';
|
||||||
import { ADMIN_GRADIENTS, useAdminTheme } from './theme';
|
import { ADMIN_GRADIENTS, useAdminTheme } from './theme';
|
||||||
|
import { useDocumentTitle } from './hooks/useDocumentTitle';
|
||||||
|
|
||||||
type LoginResponse = {
|
type LoginResponse = {
|
||||||
token: string;
|
token: string;
|
||||||
@@ -66,6 +67,8 @@ export default function MobileLoginPage() {
|
|||||||
paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 16px)',
|
paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 16px)',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useDocumentTitle(t('login.pageTitle', 'Login'));
|
||||||
|
|
||||||
const searchParams = React.useMemo(() => new URLSearchParams(location.search), [location.search]);
|
const searchParams = React.useMemo(() => new URLSearchParams(location.search), [location.search]);
|
||||||
const rawReturnTo = searchParams.get('return_to');
|
const rawReturnTo = searchParams.get('return_to');
|
||||||
const oauthError = searchParams.get('error');
|
const oauthError = searchParams.get('error');
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { SizableText as Text } from '@tamagui/text';
|
|||||||
import { Spinner } from 'tamagui';
|
import { Spinner } from 'tamagui';
|
||||||
import { ADMIN_LOGIN_PATH } from '../constants';
|
import { ADMIN_LOGIN_PATH } from '../constants';
|
||||||
import { useAdminTheme } from './theme';
|
import { useAdminTheme } from './theme';
|
||||||
|
import { useDocumentTitle } from './hooks/useDocumentTitle';
|
||||||
|
|
||||||
export default function LoginStartPage(): React.ReactElement {
|
export default function LoginStartPage(): React.ReactElement {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -19,6 +20,8 @@ export default function LoginStartPage(): React.ReactElement {
|
|||||||
paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 16px)',
|
paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 16px)',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useDocumentTitle(t('redirecting', 'Redirecting to login …'));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams(location.search);
|
const params = new URLSearchParams(location.search);
|
||||||
const returnTo = params.get('return_to');
|
const returnTo = params.get('return_to');
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Card } from '@tamagui/card';
|
import { Card } from '@tamagui/card';
|
||||||
import { YStack } from '@tamagui/stacks';
|
import { YStack } from '@tamagui/stacks';
|
||||||
import { SizableText as Text } from '@tamagui/text';
|
import { SizableText as Text } from '@tamagui/text';
|
||||||
@@ -6,15 +7,19 @@ import { Spinner } from 'tamagui';
|
|||||||
import { useAuth } from '../auth/context';
|
import { useAuth } from '../auth/context';
|
||||||
import { ADMIN_PUBLIC_LANDING_PATH } from '../constants';
|
import { ADMIN_PUBLIC_LANDING_PATH } from '../constants';
|
||||||
import { useAdminTheme } from './theme';
|
import { useAdminTheme } from './theme';
|
||||||
|
import { useDocumentTitle } from './hooks/useDocumentTitle';
|
||||||
|
|
||||||
export default function LogoutPage() {
|
export default function LogoutPage() {
|
||||||
const { logout } = useAuth();
|
const { logout } = useAuth();
|
||||||
|
const { t } = useTranslation('auth');
|
||||||
const { textStrong, muted, border, surface, shadow, appBackground } = useAdminTheme();
|
const { textStrong, muted, border, surface, shadow, appBackground } = useAdminTheme();
|
||||||
const safeAreaStyle: React.CSSProperties = {
|
const safeAreaStyle: React.CSSProperties = {
|
||||||
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 16px)',
|
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 16px)',
|
||||||
paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 16px)',
|
paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 16px)',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useDocumentTitle(t('logout.title', 'Signing out …'));
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
logout({ redirect: ADMIN_PUBLIC_LANDING_PATH });
|
logout({ redirect: ADMIN_PUBLIC_LANDING_PATH });
|
||||||
}, [logout]);
|
}, [logout]);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import type { TFunction } from 'i18next';
|
||||||
import { Check, ChevronRight, ShieldCheck, Sparkles, X } from 'lucide-react';
|
import { Check, ChevronRight, ShieldCheck, Sparkles, X } from 'lucide-react';
|
||||||
import { YStack, XStack } from '@tamagui/stacks';
|
import { YStack, XStack } from '@tamagui/stacks';
|
||||||
import { SizableText as Text } from '@tamagui/text';
|
import { SizableText as Text } from '@tamagui/text';
|
||||||
@@ -395,7 +396,11 @@ function PackageShopCompareView({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return t(`shop.features.${row.featureKey}`, row.featureKey);
|
if (row.type === 'feature') {
|
||||||
|
return t(`shop.features.${row.featureKey}`, row.featureKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
return row.id;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatLimitValue = (value: number | null) => {
|
const formatLimitValue = (value: number | null) => {
|
||||||
@@ -562,7 +567,7 @@ function getPackageStatusLabel({
|
|||||||
isActive,
|
isActive,
|
||||||
owned,
|
owned,
|
||||||
}: {
|
}: {
|
||||||
t: (key: string, fallback?: string, options?: Record<string, unknown>) => string;
|
t: TFunction;
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
owned?: TenantPackageSummary;
|
owned?: TenantPackageSummary;
|
||||||
}): string | null {
|
}): string | null {
|
||||||
@@ -622,7 +627,7 @@ function CheckoutConfirmation({ pkg, onCancel }: { pkg: Package; onCancel: () =>
|
|||||||
id="agb"
|
id="agb"
|
||||||
size="$4"
|
size="$4"
|
||||||
checked={agbAccepted}
|
checked={agbAccepted}
|
||||||
onCheckedChange={(checked) => setAgbAccepted(!!checked)}
|
onCheckedChange={(checked: boolean) => setAgbAccepted(!!checked)}
|
||||||
>
|
>
|
||||||
<Checkbox.Indicator>
|
<Checkbox.Indicator>
|
||||||
<Check />
|
<Check />
|
||||||
@@ -638,7 +643,7 @@ function CheckoutConfirmation({ pkg, onCancel }: { pkg: Package; onCancel: () =>
|
|||||||
id="withdrawal"
|
id="withdrawal"
|
||||||
size="$4"
|
size="$4"
|
||||||
checked={withdrawalAccepted}
|
checked={withdrawalAccepted}
|
||||||
onCheckedChange={(checked) => setWithdrawalAccepted(!!checked)}
|
onCheckedChange={(checked: boolean) => setWithdrawalAccepted(!!checked)}
|
||||||
>
|
>
|
||||||
<Checkbox.Indicator>
|
<Checkbox.Indicator>
|
||||||
<Check />
|
<Check />
|
||||||
@@ -687,7 +692,7 @@ function aggregateOwnedEntries(entries: TenantPackageSummary[]): TenantPackageSu
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveIncludedTierLabel(
|
function resolveIncludedTierLabel(
|
||||||
t: (key: string, fallback?: string, options?: Record<string, unknown>) => string,
|
t: TFunction,
|
||||||
slug: string | null
|
slug: string | null
|
||||||
): string | null {
|
): string | null {
|
||||||
if (!slug) {
|
if (!slug) {
|
||||||
@@ -699,7 +704,7 @@ function resolveIncludedTierLabel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (slug === 'standard') {
|
if (slug === 'standard') {
|
||||||
return t('shop.partner.tiers.standard', 'Standard');
|
return t('shop.partner.tiers.standard', 'Classic');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (slug === 'pro') {
|
if (slug === 'pro') {
|
||||||
|
|||||||
@@ -3,16 +3,26 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { CheckCircle2, Lock, MailWarning, User } from 'lucide-react';
|
import { CheckCircle2, Lock, MailWarning, User } from 'lucide-react';
|
||||||
import { YStack, XStack } from '@tamagui/stacks';
|
import { YStack, XStack } from '@tamagui/stacks';
|
||||||
import { SizableText as Text } from '@tamagui/text';
|
import { SizableText as Text } from '@tamagui/text';
|
||||||
|
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import { MobileShell } from './components/MobileShell';
|
import { MobileShell } from './components/MobileShell';
|
||||||
import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
|
import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
|
||||||
import { MobileField, MobileInput, MobileSelect } from './components/FormControls';
|
import { MobileField, MobileInput, MobileSelect, MobileColorInput } from './components/FormControls';
|
||||||
import { fetchTenantProfile, updateTenantProfile, type TenantAccountProfile } from '../api';
|
import {
|
||||||
|
fetchTenantProfile,
|
||||||
|
updateTenantProfile,
|
||||||
|
getTenantPackagesOverview,
|
||||||
|
getTenantSettings,
|
||||||
|
updateTenantSettings,
|
||||||
|
type TenantAccountProfile,
|
||||||
|
} from '../api';
|
||||||
import { getApiErrorMessage, getApiValidationMessage } from '../lib/apiError';
|
import { getApiErrorMessage, getApiValidationMessage } from '../lib/apiError';
|
||||||
import { ADMIN_PROFILE_PATH } from '../constants';
|
import { ADMIN_PROFILE_PATH } from '../constants';
|
||||||
import { useBackNavigation } from './hooks/useBackNavigation';
|
import { useBackNavigation } from './hooks/useBackNavigation';
|
||||||
import { useAdminTheme } from './theme';
|
import { useAdminTheme } from './theme';
|
||||||
import i18n from '../i18n';
|
import i18n from '../i18n';
|
||||||
|
import { extractBrandingForm, type BrandingFormValues } from '../lib/brandingForm';
|
||||||
|
import { DEFAULT_EVENT_BRANDING } from '@/guest/context/EventBrandingContext';
|
||||||
|
|
||||||
type ProfileFormState = {
|
type ProfileFormState = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -29,12 +39,46 @@ const LOCALE_OPTIONS = [
|
|||||||
{ value: 'en', label: 'English' },
|
{ value: 'en', label: 'English' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
type TabKey = 'account' | 'branding';
|
||||||
|
|
||||||
|
const TENANT_BRANDING_DEFAULTS = {
|
||||||
|
primary: DEFAULT_EVENT_BRANDING.primaryColor,
|
||||||
|
accent: DEFAULT_EVENT_BRANDING.secondaryColor,
|
||||||
|
background: DEFAULT_EVENT_BRANDING.backgroundColor,
|
||||||
|
surface: DEFAULT_EVENT_BRANDING.palette?.surface ?? DEFAULT_EVENT_BRANDING.backgroundColor,
|
||||||
|
headingFont: DEFAULT_EVENT_BRANDING.typography?.heading ?? DEFAULT_EVENT_BRANDING.fontFamily ?? '',
|
||||||
|
bodyFont: DEFAULT_EVENT_BRANDING.typography?.body ?? DEFAULT_EVENT_BRANDING.fontFamily ?? '',
|
||||||
|
mode: DEFAULT_EVENT_BRANDING.mode ?? 'auto',
|
||||||
|
buttonStyle: DEFAULT_EVENT_BRANDING.buttons?.style ?? 'filled',
|
||||||
|
buttonRadius: DEFAULT_EVENT_BRANDING.buttons?.radius ?? 12,
|
||||||
|
buttonPrimary: DEFAULT_EVENT_BRANDING.buttons?.primary ?? DEFAULT_EVENT_BRANDING.primaryColor,
|
||||||
|
buttonSecondary: DEFAULT_EVENT_BRANDING.buttons?.secondary ?? DEFAULT_EVENT_BRANDING.secondaryColor,
|
||||||
|
linkColor: DEFAULT_EVENT_BRANDING.buttons?.linkColor ?? DEFAULT_EVENT_BRANDING.secondaryColor,
|
||||||
|
fontSize: DEFAULT_EVENT_BRANDING.typography?.sizePreset ?? 'm',
|
||||||
|
logoMode: DEFAULT_EVENT_BRANDING.logo?.mode ?? 'emoticon',
|
||||||
|
logoPosition: DEFAULT_EVENT_BRANDING.logo?.position ?? 'left',
|
||||||
|
logoSize: DEFAULT_EVENT_BRANDING.logo?.size ?? 'm',
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildTenantBrandingFormBase = (): BrandingFormValues => ({
|
||||||
|
...TENANT_BRANDING_DEFAULTS,
|
||||||
|
logoDataUrl: '',
|
||||||
|
logoValue: '',
|
||||||
|
});
|
||||||
|
|
||||||
export default function MobileProfileAccountPage() {
|
export default function MobileProfileAccountPage() {
|
||||||
const { t } = useTranslation('settings');
|
const { t } = useTranslation('settings');
|
||||||
const { text, muted, danger, subtle, primary, accentSoft } = useAdminTheme();
|
const { text, muted, danger, subtle, primary, accentSoft } = useAdminTheme();
|
||||||
const back = useBackNavigation(ADMIN_PROFILE_PATH);
|
const back = useBackNavigation(ADMIN_PROFILE_PATH);
|
||||||
|
|
||||||
const [profile, setProfile] = React.useState<TenantAccountProfile | null>(null);
|
const [profile, setProfile] = React.useState<TenantAccountProfile | null>(null);
|
||||||
|
const [activeTab, setActiveTab] = React.useState<TabKey>('account');
|
||||||
|
const [brandingAllowed, setBrandingAllowed] = React.useState(false);
|
||||||
|
const [brandingLoading, setBrandingLoading] = React.useState(true);
|
||||||
|
const [brandingSaving, setBrandingSaving] = React.useState(false);
|
||||||
|
const [brandingError, setBrandingError] = React.useState<string | null>(null);
|
||||||
|
const [tenantSettings, setTenantSettings] = React.useState<Record<string, unknown> | null>(null);
|
||||||
|
const [brandingForm, setBrandingForm] = React.useState<BrandingFormValues>(buildTenantBrandingFormBase);
|
||||||
const [form, setForm] = React.useState<ProfileFormState>({
|
const [form, setForm] = React.useState<ProfileFormState>({
|
||||||
name: '',
|
name: '',
|
||||||
email: '',
|
email: '',
|
||||||
@@ -48,6 +92,8 @@ export default function MobileProfileAccountPage() {
|
|||||||
const [savingPassword, setSavingPassword] = React.useState(false);
|
const [savingPassword, setSavingPassword] = React.useState(false);
|
||||||
const [error, setError] = React.useState<string | null>(null);
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
const loadErrorMessage = t('profile.errors.load', 'Profil konnte nicht geladen werden.');
|
const loadErrorMessage = t('profile.errors.load', 'Profil konnte nicht geladen werden.');
|
||||||
|
const brandingLoadError = t('profile.errors.brandingLoad', 'Standard-Branding konnte nicht geladen werden.');
|
||||||
|
const brandingSaveError = t('profile.errors.brandingSave', 'Standard-Branding konnte nicht gespeichert werden.');
|
||||||
|
|
||||||
const dateFormatter = React.useMemo(
|
const dateFormatter = React.useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -80,6 +126,52 @@ export default function MobileProfileAccountPage() {
|
|||||||
})();
|
})();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const overview = await getTenantPackagesOverview();
|
||||||
|
if (!active) return;
|
||||||
|
setBrandingAllowed(Boolean(overview.activePackage?.branding_allowed));
|
||||||
|
} catch {
|
||||||
|
if (active) {
|
||||||
|
setBrandingAllowed(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
(async () => {
|
||||||
|
setBrandingLoading(true);
|
||||||
|
try {
|
||||||
|
const payload = await getTenantSettings();
|
||||||
|
if (!active) return;
|
||||||
|
const settings = (payload.settings ?? {}) as Record<string, unknown>;
|
||||||
|
setTenantSettings(settings);
|
||||||
|
setBrandingForm(extractBrandingForm(settings, TENANT_BRANDING_DEFAULTS));
|
||||||
|
setBrandingError(null);
|
||||||
|
} catch (err) {
|
||||||
|
if (active) {
|
||||||
|
setBrandingError(getApiErrorMessage(err, brandingLoadError));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (active) {
|
||||||
|
setBrandingLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [brandingLoadError]);
|
||||||
|
|
||||||
const verifiedAt = profile?.email_verified_at ? new Date(profile.email_verified_at) : null;
|
const verifiedAt = profile?.email_verified_at ? new Date(profile.email_verified_at) : null;
|
||||||
const verifiedDate = verifiedAt ? dateFormatter.format(verifiedAt) : null;
|
const verifiedDate = verifiedAt ? dateFormatter.format(verifiedAt) : null;
|
||||||
const emailStatusLabel = profile?.email_verified
|
const emailStatusLabel = profile?.email_verified
|
||||||
@@ -88,6 +180,13 @@ export default function MobileProfileAccountPage() {
|
|||||||
const emailHint = profile?.email_verified
|
const emailHint = profile?.email_verified
|
||||||
? t('profile.status.verifiedHint', 'Bestätigt am {{date}}.', { date: verifiedDate ?? '' })
|
? t('profile.status.verifiedHint', 'Bestätigt am {{date}}.', { date: verifiedDate ?? '' })
|
||||||
: t('profile.status.unverifiedHint', 'Bei Änderung der E-Mail senden wir dir automatisch eine neue Bestätigung.');
|
: t('profile.status.unverifiedHint', 'Bei Änderung der E-Mail senden wir dir automatisch eine neue Bestätigung.');
|
||||||
|
const brandingTabEnabled = brandingAllowed;
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!brandingTabEnabled && activeTab === 'branding') {
|
||||||
|
setActiveTab('account');
|
||||||
|
}
|
||||||
|
}, [brandingTabEnabled, activeTab]);
|
||||||
|
|
||||||
const buildPayload = (includePassword: boolean) => ({
|
const buildPayload = (includePassword: boolean) => ({
|
||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
@@ -140,10 +239,65 @@ export default function MobileProfileAccountPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleBrandingSave = async () => {
|
||||||
|
if (!tenantSettings) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBrandingSaving(true);
|
||||||
|
try {
|
||||||
|
const existingBranding =
|
||||||
|
tenantSettings && typeof (tenantSettings as Record<string, unknown>).branding === 'object'
|
||||||
|
? ((tenantSettings as Record<string, unknown>).branding as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
const settings = {
|
||||||
|
...tenantSettings,
|
||||||
|
branding: {
|
||||||
|
...existingBranding,
|
||||||
|
primary_color: brandingForm.primary,
|
||||||
|
secondary_color: brandingForm.accent,
|
||||||
|
accent_color: brandingForm.accent,
|
||||||
|
background_color: brandingForm.background,
|
||||||
|
surface_color: brandingForm.surface,
|
||||||
|
font_family: brandingForm.bodyFont,
|
||||||
|
heading_font: brandingForm.headingFont,
|
||||||
|
body_font: brandingForm.bodyFont,
|
||||||
|
font_size: brandingForm.fontSize,
|
||||||
|
mode: brandingForm.mode,
|
||||||
|
typography: {
|
||||||
|
...(typeof existingBranding.typography === 'object' ? (existingBranding.typography as Record<string, unknown>) : {}),
|
||||||
|
heading: brandingForm.headingFont,
|
||||||
|
body: brandingForm.bodyFont,
|
||||||
|
size: brandingForm.fontSize,
|
||||||
|
},
|
||||||
|
palette: {
|
||||||
|
...(typeof existingBranding.palette === 'object' ? (existingBranding.palette as Record<string, unknown>) : {}),
|
||||||
|
primary: brandingForm.primary,
|
||||||
|
secondary: brandingForm.accent,
|
||||||
|
background: brandingForm.background,
|
||||||
|
surface: brandingForm.surface,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const updated = await updateTenantSettings(settings);
|
||||||
|
const nextSettings = (updated.settings ?? {}) as Record<string, unknown>;
|
||||||
|
setTenantSettings(nextSettings);
|
||||||
|
setBrandingForm(extractBrandingForm(nextSettings, TENANT_BRANDING_DEFAULTS));
|
||||||
|
setBrandingError(null);
|
||||||
|
toast.success(t('profile.branding.updated', 'Standard-Branding gespeichert.'));
|
||||||
|
} catch (err) {
|
||||||
|
const message = getApiErrorMessage(err, brandingSaveError);
|
||||||
|
setBrandingError(message);
|
||||||
|
toast.error(message);
|
||||||
|
} finally {
|
||||||
|
setBrandingSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const passwordReady =
|
const passwordReady =
|
||||||
form.currentPassword.trim().length > 0 &&
|
form.currentPassword.trim().length > 0 &&
|
||||||
form.password.trim().length > 0 &&
|
form.password.trim().length > 0 &&
|
||||||
form.passwordConfirmation.trim().length > 0;
|
form.passwordConfirmation.trim().length > 0;
|
||||||
|
const brandingDisabled = brandingLoading || brandingSaving;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MobileShell
|
<MobileShell
|
||||||
@@ -151,152 +305,340 @@ export default function MobileProfileAccountPage() {
|
|||||||
title={t('profile.title', 'Profil')}
|
title={t('profile.title', 'Profil')}
|
||||||
onBack={back}
|
onBack={back}
|
||||||
>
|
>
|
||||||
{error ? (
|
{brandingTabEnabled ? (
|
||||||
<MobileCard>
|
<XStack space="$2">
|
||||||
<Text fontWeight="700" color={danger}>
|
<TabButton
|
||||||
{error}
|
label={t('profile.tabs.account', 'Account')}
|
||||||
</Text>
|
active={activeTab === 'account'}
|
||||||
</MobileCard>
|
onPress={() => setActiveTab('account')}
|
||||||
|
/>
|
||||||
|
<TabButton
|
||||||
|
label={t('profile.tabs.branding', 'Standard-Branding')}
|
||||||
|
active={activeTab === 'branding'}
|
||||||
|
onPress={() => setActiveTab('branding')}
|
||||||
|
/>
|
||||||
|
</XStack>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<MobileCard space="$3">
|
{activeTab === 'branding' && brandingTabEnabled ? (
|
||||||
<XStack alignItems="center" space="$3">
|
<>
|
||||||
<XStack
|
{brandingError ? (
|
||||||
width={48}
|
<MobileCard>
|
||||||
height={48}
|
<Text fontWeight="700" color={danger}>
|
||||||
borderRadius={16}
|
{brandingError}
|
||||||
alignItems="center"
|
</Text>
|
||||||
justifyContent="center"
|
</MobileCard>
|
||||||
backgroundColor={accentSoft}
|
) : null}
|
||||||
>
|
|
||||||
<User size={20} color={primary} />
|
<MobileCard space="$3">
|
||||||
</XStack>
|
|
||||||
<YStack space="$1">
|
|
||||||
<Text fontSize="$md" fontWeight="800" color={text}>
|
<Text fontSize="$md" fontWeight="800" color={text}>
|
||||||
{form.name || profile?.email || t('profile.title', 'Profil')}
|
{t('profile.branding.title', 'Standard-Branding')}
|
||||||
</Text>
|
</Text>
|
||||||
<Text fontSize="$sm" color={muted}>
|
<Text fontSize="$sm" color={muted}>
|
||||||
{form.email || profile?.email || '—'}
|
{t('profile.branding.hint', 'Diese Werte dienen als Vorschlag für neue Events und beschleunigen die Einrichtung.')}
|
||||||
</Text>
|
</Text>
|
||||||
</YStack>
|
</MobileCard>
|
||||||
</XStack>
|
|
||||||
<XStack alignItems="center" space="$2" flexWrap="wrap">
|
|
||||||
{profile?.email_verified ? (
|
|
||||||
<CheckCircle2 size={14} color={subtle} />
|
|
||||||
) : (
|
|
||||||
<MailWarning size={14} color={subtle} />
|
|
||||||
)}
|
|
||||||
<PillBadge tone={profile?.email_verified ? 'success' : 'warning'}>
|
|
||||||
{emailStatusLabel}
|
|
||||||
</PillBadge>
|
|
||||||
<Text fontSize="$xs" color={muted}>
|
|
||||||
{emailHint}
|
|
||||||
</Text>
|
|
||||||
</XStack>
|
|
||||||
</MobileCard>
|
|
||||||
|
|
||||||
<MobileCard space="$3">
|
<MobileCard space="$3">
|
||||||
<XStack alignItems="center" space="$2">
|
<Text fontSize="$md" fontWeight="800" color={text}>
|
||||||
<User size={16} color={text} />
|
{t('profile.branding.theme', 'Theme')}
|
||||||
<Text fontSize="$md" fontWeight="800" color={text}>
|
</Text>
|
||||||
{t('profile.sections.account.heading', 'Account-Informationen')}
|
<YStack space="$3">
|
||||||
</Text>
|
<MobileField label={t('events.branding.mode', 'Theme')}>
|
||||||
</XStack>
|
<MobileSelect
|
||||||
<Text fontSize="$sm" color={muted}>
|
value={brandingForm.mode}
|
||||||
{t('profile.sections.account.description', 'Passe Anzeigename, E-Mail-Adresse und Sprache der Admin-Oberfläche an.')}
|
onChange={(event) => setBrandingForm((prev) => ({ ...prev, mode: event.target.value as BrandingFormValues['mode'] }))}
|
||||||
</Text>
|
disabled={brandingDisabled}
|
||||||
{loading ? (
|
>
|
||||||
<Text fontSize="$sm" color={muted}>
|
<option value="light">{t('events.branding.modeLight', 'Light')}</option>
|
||||||
{t('profile.loading', 'Lädt ...')}
|
<option value="auto">{t('events.branding.modeAuto', 'Auto')}</option>
|
||||||
</Text>
|
<option value="dark">{t('events.branding.modeDark', 'Dark')}</option>
|
||||||
) : (
|
</MobileSelect>
|
||||||
<YStack space="$3">
|
</MobileField>
|
||||||
<MobileField label={t('profile.fields.name', 'Anzeigename')}>
|
<MobileField label={t('events.branding.fontSize', 'Font Size')}>
|
||||||
<MobileInput
|
<MobileSelect
|
||||||
value={form.name}
|
value={brandingForm.fontSize}
|
||||||
onChange={(event) => setForm((prev) => ({ ...prev, name: event.target.value }))}
|
onChange={(event) => setBrandingForm((prev) => ({ ...prev, fontSize: event.target.value as BrandingFormValues['fontSize'] }))}
|
||||||
placeholder={t('profile.placeholders.name', 'z. B. Hochzeitsplanung Schmidt')}
|
disabled={brandingDisabled}
|
||||||
hasError={false}
|
>
|
||||||
/>
|
<option value="s">{t('events.branding.fontSizeSmall', 'S')}</option>
|
||||||
</MobileField>
|
<option value="m">{t('events.branding.fontSizeMedium', 'M')}</option>
|
||||||
<MobileField label={t('profile.fields.email', 'E-Mail-Adresse')}>
|
<option value="l">{t('events.branding.fontSizeLarge', 'L')}</option>
|
||||||
<MobileInput
|
</MobileSelect>
|
||||||
value={form.email}
|
</MobileField>
|
||||||
onChange={(event) => setForm((prev) => ({ ...prev, email: event.target.value }))}
|
</YStack>
|
||||||
placeholder="mail@beispiel.de"
|
</MobileCard>
|
||||||
type="email"
|
|
||||||
hasError={false}
|
<MobileCard space="$3">
|
||||||
/>
|
<Text fontSize="$md" fontWeight="800" color={text}>
|
||||||
</MobileField>
|
{t('events.branding.colors', 'Colors')}
|
||||||
<MobileField label={t('profile.fields.locale', 'Bevorzugte Sprache')}>
|
</Text>
|
||||||
<MobileSelect
|
<YStack space="$3">
|
||||||
value={form.preferredLocale}
|
<ColorField
|
||||||
onChange={(event) => setForm((prev) => ({ ...prev, preferredLocale: event.target.value }))}
|
label={t('events.branding.primary', 'Primary Color')}
|
||||||
>
|
value={brandingForm.primary}
|
||||||
{LOCALE_OPTIONS.map((option) => (
|
onChange={(value) => setBrandingForm((prev) => ({ ...prev, primary: value }))}
|
||||||
<option key={option.value} value={option.value}>
|
disabled={brandingDisabled}
|
||||||
{option.label ?? t(option.labelKey, option.fallback)}
|
/>
|
||||||
</option>
|
<ColorField
|
||||||
))}
|
label={t('events.branding.accent', 'Accent Color')}
|
||||||
</MobileSelect>
|
value={brandingForm.accent}
|
||||||
</MobileField>
|
onChange={(value) => setBrandingForm((prev) => ({ ...prev, accent: value }))}
|
||||||
<CTAButton
|
disabled={brandingDisabled}
|
||||||
label={t('profile.actions.save', 'Speichern')}
|
/>
|
||||||
onPress={handleAccountSave}
|
<ColorField
|
||||||
disabled={savingAccount || loading}
|
label={t('events.branding.backgroundColor', 'Background Color')}
|
||||||
loading={savingAccount}
|
value={brandingForm.background}
|
||||||
/>
|
onChange={(value) => setBrandingForm((prev) => ({ ...prev, background: value }))}
|
||||||
</YStack>
|
disabled={brandingDisabled}
|
||||||
)}
|
/>
|
||||||
</MobileCard>
|
<ColorField
|
||||||
|
label={t('events.branding.surfaceColor', 'Surface Color')}
|
||||||
|
value={brandingForm.surface}
|
||||||
|
onChange={(value) => setBrandingForm((prev) => ({ ...prev, surface: value }))}
|
||||||
|
disabled={brandingDisabled}
|
||||||
|
/>
|
||||||
|
</YStack>
|
||||||
|
</MobileCard>
|
||||||
|
|
||||||
|
<MobileCard space="$3">
|
||||||
|
<Text fontSize="$md" fontWeight="800" color={text}>
|
||||||
|
{t('events.branding.fonts', 'Fonts')}
|
||||||
|
</Text>
|
||||||
|
<YStack space="$3">
|
||||||
|
<MobileField label={t('events.branding.headingFont', 'Headline Font')}>
|
||||||
|
<MobileInput
|
||||||
|
value={brandingForm.headingFont}
|
||||||
|
onChange={(event) => setBrandingForm((prev) => ({ ...prev, headingFont: event.target.value }))}
|
||||||
|
placeholder={t('events.branding.headingFontPlaceholder', 'Playfair Display')}
|
||||||
|
hasError={false}
|
||||||
|
disabled={brandingDisabled}
|
||||||
|
/>
|
||||||
|
</MobileField>
|
||||||
|
<MobileField label={t('events.branding.bodyFont', 'Body Font')}>
|
||||||
|
<MobileInput
|
||||||
|
value={brandingForm.bodyFont}
|
||||||
|
onChange={(event) => setBrandingForm((prev) => ({ ...prev, bodyFont: event.target.value }))}
|
||||||
|
placeholder={t('events.branding.bodyFontPlaceholder', 'Montserrat')}
|
||||||
|
hasError={false}
|
||||||
|
disabled={brandingDisabled}
|
||||||
|
/>
|
||||||
|
</MobileField>
|
||||||
|
</YStack>
|
||||||
|
</MobileCard>
|
||||||
|
|
||||||
<MobileCard space="$3">
|
|
||||||
<XStack alignItems="center" space="$2">
|
|
||||||
<Lock size={16} color={text} />
|
|
||||||
<Text fontSize="$md" fontWeight="800" color={text}>
|
|
||||||
{t('profile.sections.password.heading', 'Passwort ändern')}
|
|
||||||
</Text>
|
|
||||||
</XStack>
|
|
||||||
<Text fontSize="$sm" color={muted}>
|
|
||||||
{t('profile.sections.password.description', 'Wähle ein sicheres Passwort, um deinen Admin-Zugang zu schützen.')}
|
|
||||||
</Text>
|
|
||||||
<YStack space="$3">
|
|
||||||
<MobileField label={t('profile.fields.currentPassword', 'Aktuelles Passwort')}>
|
|
||||||
<MobileInput
|
|
||||||
value={form.currentPassword}
|
|
||||||
onChange={(event) => setForm((prev) => ({ ...prev, currentPassword: event.target.value }))}
|
|
||||||
placeholder="••••••••"
|
|
||||||
type="password"
|
|
||||||
hasError={false}
|
|
||||||
/>
|
|
||||||
</MobileField>
|
|
||||||
<MobileField label={t('profile.fields.newPassword', 'Neues Passwort')} hint={t('profile.sections.password.hint', 'Nutze mindestens 8 Zeichen und kombiniere Buchstaben sowie Zahlen für mehr Sicherheit.')}>
|
|
||||||
<MobileInput
|
|
||||||
value={form.password}
|
|
||||||
onChange={(event) => setForm((prev) => ({ ...prev, password: event.target.value }))}
|
|
||||||
placeholder="••••••••"
|
|
||||||
type="password"
|
|
||||||
hasError={false}
|
|
||||||
/>
|
|
||||||
</MobileField>
|
|
||||||
<MobileField label={t('profile.fields.passwordConfirmation', 'Passwort bestätigen')}>
|
|
||||||
<MobileInput
|
|
||||||
value={form.passwordConfirmation}
|
|
||||||
onChange={(event) => setForm((prev) => ({ ...prev, passwordConfirmation: event.target.value }))}
|
|
||||||
placeholder="••••••••"
|
|
||||||
type="password"
|
|
||||||
hasError={false}
|
|
||||||
/>
|
|
||||||
</MobileField>
|
|
||||||
<CTAButton
|
<CTAButton
|
||||||
label={t('profile.actions.updatePassword', 'Passwort aktualisieren')}
|
label={brandingSaving ? t('profile.branding.saving', 'Saving...') : t('profile.branding.save', 'Save defaults')}
|
||||||
onPress={handlePasswordSave}
|
onPress={handleBrandingSave}
|
||||||
disabled={!passwordReady || savingPassword || loading}
|
disabled={brandingDisabled}
|
||||||
loading={savingPassword}
|
loading={brandingSaving}
|
||||||
tone="ghost"
|
|
||||||
/>
|
/>
|
||||||
</YStack>
|
</>
|
||||||
</MobileCard>
|
) : (
|
||||||
|
<>
|
||||||
|
{error ? (
|
||||||
|
<MobileCard>
|
||||||
|
<Text fontWeight="700" color={danger}>
|
||||||
|
{error}
|
||||||
|
</Text>
|
||||||
|
</MobileCard>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<MobileCard space="$3">
|
||||||
|
<XStack alignItems="center" space="$3">
|
||||||
|
<XStack
|
||||||
|
width={48}
|
||||||
|
height={48}
|
||||||
|
borderRadius={16}
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
backgroundColor={accentSoft}
|
||||||
|
>
|
||||||
|
<User size={20} color={primary} />
|
||||||
|
</XStack>
|
||||||
|
<YStack space="$1">
|
||||||
|
<Text fontSize="$md" fontWeight="800" color={text}>
|
||||||
|
{form.name || profile?.email || t('profile.title', 'Profil')}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="$sm" color={muted}>
|
||||||
|
{form.email || profile?.email || '—'}
|
||||||
|
</Text>
|
||||||
|
</YStack>
|
||||||
|
</XStack>
|
||||||
|
<XStack alignItems="center" space="$2" flexWrap="wrap">
|
||||||
|
{profile?.email_verified ? (
|
||||||
|
<CheckCircle2 size={14} color={subtle} />
|
||||||
|
) : (
|
||||||
|
<MailWarning size={14} color={subtle} />
|
||||||
|
)}
|
||||||
|
<PillBadge tone={profile?.email_verified ? 'success' : 'warning'}>
|
||||||
|
{emailStatusLabel}
|
||||||
|
</PillBadge>
|
||||||
|
<Text fontSize="$xs" color={muted}>
|
||||||
|
{emailHint}
|
||||||
|
</Text>
|
||||||
|
</XStack>
|
||||||
|
</MobileCard>
|
||||||
|
|
||||||
|
<MobileCard space="$3">
|
||||||
|
<XStack alignItems="center" space="$2">
|
||||||
|
<User size={16} color={text} />
|
||||||
|
<Text fontSize="$md" fontWeight="800" color={text}>
|
||||||
|
{t('profile.sections.account.heading', 'Account-Informationen')}
|
||||||
|
</Text>
|
||||||
|
</XStack>
|
||||||
|
<Text fontSize="$sm" color={muted}>
|
||||||
|
{t('profile.sections.account.description', 'Passe Anzeigename, E-Mail-Adresse und Sprache der Admin-Oberfläche an.')}
|
||||||
|
</Text>
|
||||||
|
{loading ? (
|
||||||
|
<Text fontSize="$sm" color={muted}>
|
||||||
|
{t('profile.loading', 'Lädt ...')}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<YStack space="$3">
|
||||||
|
<MobileField label={t('profile.fields.name', 'Anzeigename')}>
|
||||||
|
<MobileInput
|
||||||
|
value={form.name}
|
||||||
|
onChange={(event) => setForm((prev) => ({ ...prev, name: event.target.value }))}
|
||||||
|
placeholder={t('profile.placeholders.name', 'z. B. Hochzeitsplanung Schmidt')}
|
||||||
|
hasError={false}
|
||||||
|
/>
|
||||||
|
</MobileField>
|
||||||
|
<MobileField label={t('profile.fields.email', 'E-Mail-Adresse')}>
|
||||||
|
<MobileInput
|
||||||
|
value={form.email}
|
||||||
|
onChange={(event) => setForm((prev) => ({ ...prev, email: event.target.value }))}
|
||||||
|
placeholder="mail@beispiel.de"
|
||||||
|
type="email"
|
||||||
|
hasError={false}
|
||||||
|
/>
|
||||||
|
</MobileField>
|
||||||
|
<MobileField label={t('profile.fields.locale', 'Bevorzugte Sprache')}>
|
||||||
|
<MobileSelect
|
||||||
|
value={form.preferredLocale}
|
||||||
|
onChange={(event) => setForm((prev) => ({ ...prev, preferredLocale: event.target.value }))}
|
||||||
|
>
|
||||||
|
{LOCALE_OPTIONS.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label ?? t(option.labelKey, option.fallback)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</MobileSelect>
|
||||||
|
</MobileField>
|
||||||
|
<CTAButton
|
||||||
|
label={t('profile.actions.save', 'Speichern')}
|
||||||
|
onPress={handleAccountSave}
|
||||||
|
disabled={savingAccount || loading}
|
||||||
|
loading={savingAccount}
|
||||||
|
/>
|
||||||
|
</YStack>
|
||||||
|
)}
|
||||||
|
</MobileCard>
|
||||||
|
|
||||||
|
<MobileCard space="$3">
|
||||||
|
<XStack alignItems="center" space="$2">
|
||||||
|
<Lock size={16} color={text} />
|
||||||
|
<Text fontSize="$md" fontWeight="800" color={text}>
|
||||||
|
{t('profile.sections.password.heading', 'Passwort ändern')}
|
||||||
|
</Text>
|
||||||
|
</XStack>
|
||||||
|
<Text fontSize="$sm" color={muted}>
|
||||||
|
{t('profile.sections.password.description', 'Wähle ein sicheres Passwort, um deinen Admin-Zugang zu schützen.')}
|
||||||
|
</Text>
|
||||||
|
<YStack space="$3">
|
||||||
|
<MobileField label={t('profile.fields.currentPassword', 'Aktuelles Passwort')}>
|
||||||
|
<MobileInput
|
||||||
|
value={form.currentPassword}
|
||||||
|
onChange={(event) => setForm((prev) => ({ ...prev, currentPassword: event.target.value }))}
|
||||||
|
placeholder="••••••••"
|
||||||
|
type="password"
|
||||||
|
hasError={false}
|
||||||
|
/>
|
||||||
|
</MobileField>
|
||||||
|
<MobileField
|
||||||
|
label={t('profile.fields.newPassword', 'Neues Passwort')}
|
||||||
|
hint={t('profile.sections.password.hint', 'Nutze mindestens 8 Zeichen und kombiniere Buchstaben sowie Zahlen für mehr Sicherheit.')}
|
||||||
|
>
|
||||||
|
<MobileInput
|
||||||
|
value={form.password}
|
||||||
|
onChange={(event) => setForm((prev) => ({ ...prev, password: event.target.value }))}
|
||||||
|
placeholder="••••••••"
|
||||||
|
type="password"
|
||||||
|
hasError={false}
|
||||||
|
/>
|
||||||
|
</MobileField>
|
||||||
|
<MobileField label={t('profile.fields.passwordConfirmation', 'Passwort bestätigen')}>
|
||||||
|
<MobileInput
|
||||||
|
value={form.passwordConfirmation}
|
||||||
|
onChange={(event) => setForm((prev) => ({ ...prev, passwordConfirmation: event.target.value }))}
|
||||||
|
placeholder="••••••••"
|
||||||
|
type="password"
|
||||||
|
hasError={false}
|
||||||
|
/>
|
||||||
|
</MobileField>
|
||||||
|
<CTAButton
|
||||||
|
label={t('profile.actions.updatePassword', 'Passwort aktualisieren')}
|
||||||
|
onPress={handlePasswordSave}
|
||||||
|
disabled={!passwordReady || savingPassword || loading}
|
||||||
|
loading={savingPassword}
|
||||||
|
tone="ghost"
|
||||||
|
/>
|
||||||
|
</YStack>
|
||||||
|
</MobileCard>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</MobileShell>
|
</MobileShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function TabButton({ label, active, onPress }: { label: string; active: boolean; onPress: () => void }) {
|
||||||
|
const { primary, surfaceMuted, border, surface, text } = useAdminTheme();
|
||||||
|
return (
|
||||||
|
<Pressable onPress={onPress} style={{ flex: 1 }}>
|
||||||
|
<XStack
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
paddingVertical="$2.5"
|
||||||
|
borderRadius={12}
|
||||||
|
backgroundColor={active ? primary : surfaceMuted}
|
||||||
|
borderWidth={1}
|
||||||
|
borderColor={active ? primary : border}
|
||||||
|
>
|
||||||
|
<Text fontSize="$sm" color={active ? surface : text} fontWeight="700">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</XStack>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ColorField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (next: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
const { text, muted } = useAdminTheme();
|
||||||
|
return (
|
||||||
|
<YStack space="$2" opacity={disabled ? 0.6 : 1}>
|
||||||
|
<Text fontSize="$sm" fontWeight="700" color={text}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<XStack alignItems="center" space="$2">
|
||||||
|
<MobileColorInput
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<Text fontSize="$sm" color={muted}>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
</XStack>
|
||||||
|
</YStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Button } from '@tamagui/button';
|
|||||||
import { ADMIN_LOGIN_PATH } from '../constants';
|
import { ADMIN_LOGIN_PATH } from '../constants';
|
||||||
import { MobileCard, CTAButton } from './components/Primitives';
|
import { MobileCard, CTAButton } from './components/Primitives';
|
||||||
import { ADMIN_GRADIENTS, useAdminTheme } from './theme';
|
import { ADMIN_GRADIENTS, useAdminTheme } from './theme';
|
||||||
|
import { useDocumentTitle } from './hooks/useDocumentTitle';
|
||||||
|
|
||||||
type FaqItem = {
|
type FaqItem = {
|
||||||
question: string;
|
question: string;
|
||||||
@@ -44,6 +45,8 @@ export default function PublicHelpPage() {
|
|||||||
paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 16px)',
|
paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 16px)',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useDocumentTitle(t('login.help_title', 'Help for event admins'));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<YStack
|
<YStack
|
||||||
minHeight="100vh"
|
minHeight="100vh"
|
||||||
|
|||||||
@@ -234,6 +234,16 @@ export default function MobileQrPrintPage() {
|
|||||||
label={t('events.qr.createLink', 'Neuen QR-Link erstellen')}
|
label={t('events.qr.createLink', 'Neuen QR-Link erstellen')}
|
||||||
onPress={async () => {
|
onPress={async () => {
|
||||||
if (!slug) return;
|
if (!slug) return;
|
||||||
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
t(
|
||||||
|
'events.qr.createLinkConfirm',
|
||||||
|
'Neuen QR-Link erstellen? Dadurch werden alle bisherigen Ausdrucke ungültig und alle Personen mit dem alten Link verlieren den Zugang.'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const invite = await createQrInvite(slug, { label: t('events.qr.mobileLinkLabel', 'Mobile link') });
|
const invite = await createQrInvite(slug, { label: t('events.qr.mobileLinkLabel', 'Mobile link') });
|
||||||
setQrUrl(invite.url);
|
setQrUrl(invite.url);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { ADMIN_LOGIN_PATH } from '../constants';
|
|||||||
import { MobileCard } from './components/Primitives';
|
import { MobileCard } from './components/Primitives';
|
||||||
import { MobileField, MobileInput } from './components/FormControls';
|
import { MobileField, MobileInput } from './components/FormControls';
|
||||||
import { ADMIN_GRADIENTS, useAdminTheme } from './theme';
|
import { ADMIN_GRADIENTS, useAdminTheme } from './theme';
|
||||||
|
import { useDocumentTitle } from './hooks/useDocumentTitle';
|
||||||
|
|
||||||
type ResetResponse = {
|
type ResetResponse = {
|
||||||
status: string;
|
status: string;
|
||||||
@@ -68,6 +69,8 @@ export default function ResetPasswordPage() {
|
|||||||
paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 16px)',
|
paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 16px)',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useDocumentTitle(t('login.reset.title', 'Reset password'));
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationKey: ['tenantAdminResetPassword'],
|
mutationKey: ['tenantAdminResetPassword'],
|
||||||
mutationFn: resetPassword,
|
mutationFn: resetPassword,
|
||||||
|
|||||||
@@ -264,7 +264,7 @@ export default function MobileSettingsPage() {
|
|||||||
<Switch
|
<Switch
|
||||||
size="$4"
|
size="$4"
|
||||||
checked={pushState.subscribed}
|
checked={pushState.subscribed}
|
||||||
onCheckedChange={(value) => {
|
onCheckedChange={(value: boolean) => {
|
||||||
if (value) {
|
if (value) {
|
||||||
void pushState.enable();
|
void pushState.enable();
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -10,9 +10,11 @@ vi.mock('react-router-dom', () => ({
|
|||||||
useParams: () => ({ slug: 'demo-event' }),
|
useParams: () => ({ slug: 'demo-event' }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const tMock = (key: string, fallback?: string) => fallback ?? key;
|
||||||
|
|
||||||
vi.mock('react-i18next', () => ({
|
vi.mock('react-i18next', () => ({
|
||||||
useTranslation: () => ({
|
useTranslation: () => ({
|
||||||
t: (key: string, fallback?: string) => fallback ?? key,
|
t: tMock,
|
||||||
}),
|
}),
|
||||||
initReactI18next: {
|
initReactI18next: {
|
||||||
type: '3rdParty',
|
type: '3rdParty',
|
||||||
@@ -152,35 +154,18 @@ describe('MobileBrandingPage', () => {
|
|||||||
getTenantSettingsMock.mockResolvedValue({ settings: {} });
|
getTenantSettingsMock.mockResolvedValue({ settings: {} });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('hides custom branding controls when default branding is active', async () => {
|
it('shows branding controls when the event loads', async () => {
|
||||||
getEventMock.mockResolvedValueOnce({
|
getEventMock.mockResolvedValueOnce({
|
||||||
...baseEvent,
|
...baseEvent,
|
||||||
settings: { branding: { use_default_branding: true } },
|
settings: { branding: {} },
|
||||||
});
|
});
|
||||||
|
|
||||||
render(<MobileBrandingPage />);
|
render(<MobileBrandingPage />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Branding Source')).toBeInTheDocument();
|
expect(screen.getByText('Theme')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.queryByText('Theme')).toBeNull();
|
|
||||||
expect(screen.queryByText('Colors')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows custom branding controls when event branding is active', async () => {
|
|
||||||
getEventMock.mockResolvedValueOnce({
|
|
||||||
...baseEvent,
|
|
||||||
settings: { branding: { use_default_branding: false } },
|
|
||||||
});
|
|
||||||
|
|
||||||
render(<MobileBrandingPage />);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('Branding Source')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(screen.getByText('Theme')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Colors')).toBeInTheDocument();
|
expect(screen.getByText('Colors')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -210,7 +195,7 @@ describe('MobileBrandingPage', () => {
|
|||||||
render(<MobileBrandingPage />);
|
render(<MobileBrandingPage />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Branding Source')).toBeInTheDocument();
|
expect(screen.getByText('Theme')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
fireEvent.click(screen.getByText('Wasserzeichen'));
|
fireEvent.click(screen.getByText('Wasserzeichen'));
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React from 'react';
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { render, screen, waitFor, within } from '@testing-library/react';
|
import { render, screen, waitFor, within } from '@testing-library/react';
|
||||||
import { ADMIN_EVENTS_PATH } from '../../constants';
|
import { ADMIN_EVENTS_PATH } from '../../constants';
|
||||||
|
import type { TenantEvent } from '../../api';
|
||||||
|
|
||||||
const fixtures = vi.hoisted(() => ({
|
const fixtures = vi.hoisted(() => ({
|
||||||
event: {
|
event: {
|
||||||
@@ -9,18 +10,21 @@ const fixtures = vi.hoisted(() => ({
|
|||||||
name: 'Demo Wedding',
|
name: 'Demo Wedding',
|
||||||
slug: 'demo-event',
|
slug: 'demo-event',
|
||||||
event_date: '2026-02-19',
|
event_date: '2026-02-19',
|
||||||
status: 'published' as const,
|
event_type_id: null,
|
||||||
settings: { location: 'Berlin' },
|
event_type: null,
|
||||||
|
status: 'published',
|
||||||
|
engagement_mode: undefined,
|
||||||
|
settings: { location: 'Berlin', guest_upload_visibility: 'immediate' },
|
||||||
tasks_count: 4,
|
tasks_count: 4,
|
||||||
photo_count: 12,
|
photo_count: 12,
|
||||||
active_invites_count: 3,
|
active_invites_count: 3,
|
||||||
total_invites_count: 5,
|
total_invites_count: 5,
|
||||||
member_permissions: ['photos:moderate', 'tasks:manage', 'join-tokens:manage'],
|
member_permissions: ['photos:moderate', 'tasks:manage', 'join-tokens:manage'],
|
||||||
},
|
} as TenantEvent,
|
||||||
activePackage: {
|
activePackage: {
|
||||||
id: 1,
|
id: 1,
|
||||||
package_id: 1,
|
package_id: 1,
|
||||||
package_name: 'Standard',
|
package_name: 'Classic',
|
||||||
package_type: 'reseller',
|
package_type: 'reseller',
|
||||||
included_package_slug: null,
|
included_package_slug: null,
|
||||||
active: true,
|
active: true,
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ vi.mock('../../api', () => ({
|
|||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
package_id: 1,
|
package_id: 1,
|
||||||
package_name: 'Standard',
|
package_name: 'Classic',
|
||||||
package_type: 'endcustomer',
|
package_type: 'endcustomer',
|
||||||
included_package_slug: null,
|
included_package_slug: null,
|
||||||
active: true,
|
active: true,
|
||||||
@@ -84,7 +84,7 @@ vi.mock('../../api', () => ({
|
|||||||
activePackage: {
|
activePackage: {
|
||||||
id: 1,
|
id: 1,
|
||||||
package_id: 1,
|
package_id: 1,
|
||||||
package_name: 'Standard',
|
package_name: 'Classic',
|
||||||
package_type: 'endcustomer',
|
package_type: 'endcustomer',
|
||||||
included_package_slug: null,
|
included_package_slug: null,
|
||||||
active: true,
|
active: true,
|
||||||
@@ -138,7 +138,9 @@ vi.mock('../components/Primitives', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../components/FormControls', () => ({
|
vi.mock('../components/FormControls', () => ({
|
||||||
MobileInput: ({ compact: _compact, ...props }: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
|
MobileInput: ({ compact: _compact, ...props }: { compact?: boolean } & React.InputHTMLAttributes<HTMLInputElement>) => (
|
||||||
|
<input {...props} />
|
||||||
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@tamagui/card', () => ({
|
vi.mock('@tamagui/card', () => ({
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ vi.mock('../hooks/useBackNavigation', () => ({
|
|||||||
vi.mock('../../api', () => ({
|
vi.mock('../../api', () => ({
|
||||||
fetchTenantProfile: vi.fn(),
|
fetchTenantProfile: vi.fn(),
|
||||||
updateTenantProfile: vi.fn(),
|
updateTenantProfile: vi.fn(),
|
||||||
|
getTenantPackagesOverview: vi.fn(),
|
||||||
|
getTenantSettings: vi.fn(),
|
||||||
|
updateTenantSettings: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('react-hot-toast', () => ({
|
vi.mock('react-hot-toast', () => ({
|
||||||
@@ -47,6 +50,7 @@ vi.mock('../components/FormControls', () => ({
|
|||||||
MobileInput: ({ hasError, compact, ...props }: React.InputHTMLAttributes<HTMLInputElement> & { hasError?: boolean; compact?: boolean }) => (
|
MobileInput: ({ hasError, compact, ...props }: React.InputHTMLAttributes<HTMLInputElement> & { hasError?: boolean; compact?: boolean }) => (
|
||||||
<input {...props} />
|
<input {...props} />
|
||||||
),
|
),
|
||||||
|
MobileColorInput: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
|
||||||
MobileSelect: ({ children, ...props }: { children: React.ReactNode }) => <select {...props}>{children}</select>,
|
MobileSelect: ({ children, ...props }: { children: React.ReactNode }) => <select {...props}>{children}</select>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -59,6 +63,14 @@ vi.mock('@tamagui/text', () => ({
|
|||||||
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
|
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('@tamagui/react-native-web-lite', () => ({
|
||||||
|
Pressable: ({ children, ...props }: { children: React.ReactNode } & React.ButtonHTMLAttributes<HTMLButtonElement>) => (
|
||||||
|
<button type="button" {...props}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('../theme', () => ({
|
vi.mock('../theme', () => ({
|
||||||
useAdminTheme: () => ({
|
useAdminTheme: () => ({
|
||||||
text: '#111827',
|
text: '#111827',
|
||||||
@@ -77,7 +89,7 @@ vi.mock('../theme', () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { fetchTenantProfile, updateTenantProfile } from '../../api';
|
import { fetchTenantProfile, updateTenantProfile, getTenantPackagesOverview, getTenantSettings } from '../../api';
|
||||||
import MobileProfileAccountPage from '../ProfileAccountPage';
|
import MobileProfileAccountPage from '../ProfileAccountPage';
|
||||||
|
|
||||||
const profileFixture = {
|
const profileFixture = {
|
||||||
@@ -90,6 +102,14 @@ const profileFixture = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe('MobileProfileAccountPage', () => {
|
describe('MobileProfileAccountPage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(getTenantPackagesOverview).mockResolvedValue({
|
||||||
|
packages: [],
|
||||||
|
activePackage: { branding_allowed: false },
|
||||||
|
});
|
||||||
|
vi.mocked(getTenantSettings).mockResolvedValue({ id: 1, settings: {}, updated_at: null });
|
||||||
|
});
|
||||||
|
|
||||||
it('submits account updates with name, email, and locale', async () => {
|
it('submits account updates with name, email, and locale', async () => {
|
||||||
vi.mocked(fetchTenantProfile).mockResolvedValue(profileFixture);
|
vi.mocked(fetchTenantProfile).mockResolvedValue(profileFixture);
|
||||||
vi.mocked(updateTenantProfile).mockResolvedValue(profileFixture);
|
vi.mocked(updateTenantProfile).mockResolvedValue(profileFixture);
|
||||||
|
|||||||
@@ -13,18 +13,36 @@ const fixtures = vi.hoisted(() => ({
|
|||||||
invites: [
|
invites: [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
|
token: 'invite-token',
|
||||||
url: 'https://example.test/guest/demo-event',
|
url: 'https://example.test/guest/demo-event',
|
||||||
qr_code_data_url: '',
|
label: null,
|
||||||
|
qr_code_data_url: null,
|
||||||
|
usage_limit: null,
|
||||||
|
usage_count: 0,
|
||||||
|
expires_at: null,
|
||||||
|
revoked_at: null,
|
||||||
|
is_active: true,
|
||||||
|
created_at: '2026-01-15T12:00:00Z',
|
||||||
|
metadata: {},
|
||||||
layouts: [
|
layouts: [
|
||||||
{
|
{
|
||||||
id: 'layout-1',
|
id: 'layout-1',
|
||||||
name: 'Poster Layout',
|
name: 'Poster Layout',
|
||||||
description: 'Layout description',
|
description: 'Layout description',
|
||||||
|
subtitle: 'Layout subtitle',
|
||||||
paper: 'a4',
|
paper: 'a4',
|
||||||
orientation: 'portrait',
|
orientation: 'portrait',
|
||||||
panel_mode: 'single',
|
panel_mode: 'single',
|
||||||
|
preview: {
|
||||||
|
background: null,
|
||||||
|
background_gradient: null,
|
||||||
|
accent: null,
|
||||||
|
text: null,
|
||||||
|
},
|
||||||
|
formats: [],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
layouts_url: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
|
||||||
const fixtures = vi.hoisted(() => ({
|
const fixtures = vi.hoisted(() => ({
|
||||||
event: {
|
event: {
|
||||||
@@ -74,7 +75,11 @@ vi.mock('../components/MobileShell', () => ({
|
|||||||
|
|
||||||
vi.mock('../components/Primitives', () => ({
|
vi.mock('../components/Primitives', () => ({
|
||||||
MobileCard: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
MobileCard: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
CTAButton: ({ label }: { label: string }) => <button type="button">{label}</button>,
|
CTAButton: ({ label, onPress, disabled }: { label: string; onPress?: () => void; disabled?: boolean }) => (
|
||||||
|
<button type="button" onClick={onPress} disabled={disabled}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
PillBadge: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
PillBadge: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -116,6 +121,7 @@ vi.mock('../theme', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import MobileQrPrintPage from '../QrPrintPage';
|
import MobileQrPrintPage from '../QrPrintPage';
|
||||||
|
import { createQrInvite } from '../../api';
|
||||||
|
|
||||||
describe('MobileQrPrintPage', () => {
|
describe('MobileQrPrintPage', () => {
|
||||||
it('renders QR overview content', async () => {
|
it('renders QR overview content', async () => {
|
||||||
@@ -125,4 +131,19 @@ describe('MobileQrPrintPage', () => {
|
|||||||
expect(screen.getByText('Schritt 1: Format wählen')).toBeInTheDocument();
|
expect(screen.getByText('Schritt 1: Format wählen')).toBeInTheDocument();
|
||||||
expect(screen.getAllByText('Neuen QR-Link erstellen').length).toBeGreaterThan(0);
|
expect(screen.getAllByText('Neuen QR-Link erstellen').length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('requires confirmation before creating a new QR link', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const confirmSpy = vi.fn().mockReturnValue(false);
|
||||||
|
window.confirm = confirmSpy;
|
||||||
|
|
||||||
|
render(<MobileQrPrintPage />);
|
||||||
|
|
||||||
|
const createButton = await screen.findByRole('button', { name: 'Neuen QR-Link erstellen' });
|
||||||
|
await user.click(createButton);
|
||||||
|
|
||||||
|
expect(confirmSpy).toHaveBeenCalled();
|
||||||
|
expect(confirmSpy).toHaveBeenCalledWith(expect.stringContaining('Ausdrucke'));
|
||||||
|
expect(createQrInvite).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ describe('selectRecommendedPackageId', () => {
|
|||||||
] as any;
|
] as any;
|
||||||
|
|
||||||
it('returns null when no feature is requested', () => {
|
it('returns null when no feature is requested', () => {
|
||||||
expect(selectRecommendedPackageId(packages, null, 100)).toBeNull();
|
expect(selectRecommendedPackageId(packages, null, null)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('selects the cheapest upgrade with the feature', () => {
|
it('selects the cheapest upgrade with the feature', () => {
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ export const MobileInput = React.forwardRef<HTMLInputElement, React.ComponentPro
|
|||||||
{...props}
|
{...props}
|
||||||
{...({ type } as any)}
|
{...({ type } as any)}
|
||||||
secureTextEntry={isPassword}
|
secureTextEntry={isPassword}
|
||||||
onChangeText={(value) => {
|
onChangeText={(value: string) => {
|
||||||
onChange?.({ target: { value } } as React.ChangeEvent<HTMLInputElement>);
|
onChange?.({ target: { value } } as React.ChangeEvent<HTMLInputElement>);
|
||||||
}}
|
}}
|
||||||
size={compact ? '$3' : '$4'}
|
size={compact ? '$3' : '$4'}
|
||||||
@@ -222,7 +222,7 @@ export const MobileTextArea = React.forwardRef<
|
|||||||
ref={ref as React.Ref<any>}
|
ref={ref as React.Ref<any>}
|
||||||
{...props}
|
{...props}
|
||||||
{...({ minHeight: compact ? 72 : 96 } as any)}
|
{...({ minHeight: compact ? 72 : 96 } as any)}
|
||||||
onChangeText={(value) => {
|
onChangeText={(value: string) => {
|
||||||
onChange?.({ target: { value } } as React.ChangeEvent<HTMLTextAreaElement>);
|
onChange?.({ target: { value } } as React.ChangeEvent<HTMLTextAreaElement>);
|
||||||
}}
|
}}
|
||||||
size={compact ? '$3' : '$4'}
|
size={compact ? '$3' : '$4'}
|
||||||
@@ -292,7 +292,7 @@ export function MobileSelect({
|
|||||||
<Select
|
<Select
|
||||||
value={selectValue}
|
value={selectValue}
|
||||||
defaultValue={selectValue === undefined ? selectDefault : undefined}
|
defaultValue={selectValue === undefined ? selectDefault : undefined}
|
||||||
onValueChange={(next) => {
|
onValueChange={(next: string) => {
|
||||||
props.onChange?.({ target: { value: next } } as React.ChangeEvent<HTMLSelectElement>);
|
props.onChange?.({ target: { value: next } } as React.ChangeEvent<HTMLSelectElement>);
|
||||||
}}
|
}}
|
||||||
size={compact ? '$3' : '$4'}
|
size={compact ? '$3' : '$4'}
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export function LegalConsentSheet({
|
|||||||
id="legal-terms"
|
id="legal-terms"
|
||||||
size="$4"
|
size="$4"
|
||||||
checked={acceptedTerms}
|
checked={acceptedTerms}
|
||||||
onCheckedChange={(checked) => setAcceptedTerms(Boolean(checked))}
|
onCheckedChange={(checked: boolean) => setAcceptedTerms(Boolean(checked))}
|
||||||
borderWidth={1}
|
borderWidth={1}
|
||||||
borderColor={border}
|
borderColor={border}
|
||||||
backgroundColor={surface}
|
backgroundColor={surface}
|
||||||
@@ -135,7 +135,7 @@ export function LegalConsentSheet({
|
|||||||
id="legal-waiver"
|
id="legal-waiver"
|
||||||
size="$4"
|
size="$4"
|
||||||
checked={acceptedWaiver}
|
checked={acceptedWaiver}
|
||||||
onCheckedChange={(checked) => setAcceptedWaiver(Boolean(checked))}
|
onCheckedChange={(checked: boolean) => setAcceptedWaiver(Boolean(checked))}
|
||||||
borderWidth={1}
|
borderWidth={1}
|
||||||
borderColor={border}
|
borderColor={border}
|
||||||
backgroundColor={surface}
|
backgroundColor={surface}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { TenantEvent, getEvents } from '../../api';
|
|||||||
import { setTabHistory } from '../lib/tabHistory';
|
import { setTabHistory } from '../lib/tabHistory';
|
||||||
import { loadPhotoQueue } from '../lib/photoModerationQueue';
|
import { loadPhotoQueue } from '../lib/photoModerationQueue';
|
||||||
import { countQueuedPhotoActions } from '../lib/queueStatus';
|
import { countQueuedPhotoActions } from '../lib/queueStatus';
|
||||||
|
import { useDocumentTitle } from '../hooks/useDocumentTitle';
|
||||||
import { useAdminTheme } from '../theme';
|
import { useAdminTheme } from '../theme';
|
||||||
import { useAuth } from '../../auth/context';
|
import { useAuth } from '../../auth/context';
|
||||||
import { EventSwitcherSheet } from './EventSwitcherSheet';
|
import { EventSwitcherSheet } from './EventSwitcherSheet';
|
||||||
@@ -25,13 +26,14 @@ import { UserMenuSheet } from './UserMenuSheet';
|
|||||||
|
|
||||||
type MobileShellProps = {
|
type MobileShellProps = {
|
||||||
title?: string;
|
title?: string;
|
||||||
|
subtitle?: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
activeTab: NavKey;
|
activeTab: NavKey;
|
||||||
onBack?: () => void;
|
onBack?: () => void;
|
||||||
headerActions?: React.ReactNode;
|
headerActions?: React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function MobileShell({ title, children, activeTab, onBack, headerActions }: MobileShellProps) {
|
export function MobileShell({ title, subtitle, children, activeTab, onBack, headerActions }: MobileShellProps) {
|
||||||
const { events, activeEvent, selectEvent } = useEventContext();
|
const { events, activeEvent, selectEvent } = useEventContext();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { go } = useMobileNav(activeEvent?.slug, activeTab);
|
const { go } = useMobileNav(activeEvent?.slug, activeTab);
|
||||||
@@ -41,6 +43,8 @@ export function MobileShell({ title, children, activeTab, onBack, headerActions
|
|||||||
const { count: notificationCount } = useNotificationsBadge();
|
const { count: notificationCount } = useNotificationsBadge();
|
||||||
const online = useOnlineStatus();
|
const online = useOnlineStatus();
|
||||||
|
|
||||||
|
useDocumentTitle(title);
|
||||||
|
|
||||||
const theme = useAdminTheme();
|
const theme = useAdminTheme();
|
||||||
|
|
||||||
const backgroundColor = theme.background;
|
const backgroundColor = theme.background;
|
||||||
@@ -360,6 +364,18 @@ export function MobileShell({ title, children, activeTab, onBack, headerActions
|
|||||||
) : null}
|
) : null}
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
) : null}
|
) : null}
|
||||||
|
{subtitle ? (
|
||||||
|
<YStack space="$1">
|
||||||
|
{title ? (
|
||||||
|
<Text fontSize="$lg" fontWeight="800" color={theme.textStrong}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<Text fontSize="$sm" color={theme.muted}>
|
||||||
|
{subtitle}
|
||||||
|
</Text>
|
||||||
|
</YStack>
|
||||||
|
) : null}
|
||||||
{children}
|
{children}
|
||||||
</YStack>
|
</YStack>
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { SizableText as Text } from '@tamagui/text';
|
|||||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useAdminTheme } from '../theme';
|
import { useAdminTheme } from '../theme';
|
||||||
|
import { useDocumentTitle } from '../hooks/useDocumentTitle';
|
||||||
|
|
||||||
type OnboardingShellProps = {
|
type OnboardingShellProps = {
|
||||||
eyebrow?: string;
|
eyebrow?: string;
|
||||||
@@ -34,6 +35,8 @@ export function OnboardingShell({
|
|||||||
const resolvedBackLabel = backLabel ?? t('layout.back', 'Back');
|
const resolvedBackLabel = backLabel ?? t('layout.back', 'Back');
|
||||||
const resolvedSkipLabel = skipLabel ?? t('layout.skip', 'Skip');
|
const resolvedSkipLabel = skipLabel ?? t('layout.skip', 'Skip');
|
||||||
|
|
||||||
|
useDocumentTitle(title);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<YStack minHeight="100vh" backgroundColor={background} alignItems="center">
|
<YStack minHeight="100vh" backgroundColor={background} alignItems="center">
|
||||||
<YStack
|
<YStack
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ export function CTAButton({
|
|||||||
iconRight,
|
iconRight,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
onPress: () => void;
|
onPress?: () => void;
|
||||||
tone?: 'primary' | 'ghost' | 'danger';
|
tone?: 'primary' | 'ghost' | 'danger';
|
||||||
fullWidth?: boolean;
|
fullWidth?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
@@ -110,7 +110,7 @@ export function CTAButton({
|
|||||||
const { primary, surface, border, text, danger, glassSurfaceStrong } = useAdminTheme();
|
const { primary, surface, border, text, danger, glassSurfaceStrong } = useAdminTheme();
|
||||||
const isPrimary = tone === 'primary';
|
const isPrimary = tone === 'primary';
|
||||||
const isDanger = tone === 'danger';
|
const isDanger = tone === 'danger';
|
||||||
const isDisabled = disabled || loading;
|
const isDisabled = disabled || loading || !onPress;
|
||||||
const backgroundColor = isDanger ? danger : isPrimary ? primary : glassSurfaceStrong ?? surface;
|
const backgroundColor = isDanger ? danger : isPrimary ? primary : glassSurfaceStrong ?? surface;
|
||||||
const borderColor = isPrimary || isDanger ? 'transparent' : border;
|
const borderColor = isPrimary || isDanger ? 'transparent' : border;
|
||||||
const labelColor = isPrimary || isDanger ? 'white' : text;
|
const labelColor = isPrimary || isDanger ? 'white' : text;
|
||||||
|
|||||||
@@ -66,8 +66,7 @@ export function SetupChecklist({
|
|||||||
height={6}
|
height={6}
|
||||||
>
|
>
|
||||||
<Progress.Indicator
|
<Progress.Indicator
|
||||||
backgroundColor={isAllComplete ? theme.success : theme.primary}
|
backgroundColor={isAllComplete ? theme.successText : theme.primary}
|
||||||
animation="bouncy"
|
|
||||||
/>
|
/>
|
||||||
</Progress>
|
</Progress>
|
||||||
</YStack>
|
</YStack>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { MobileSelect } from './FormControls';
|
|||||||
type UserMenuSheetProps = {
|
type UserMenuSheetProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
user?: { name?: string | null; email?: string | null; avatar_url?: string | null };
|
user?: { name?: string | null; email?: string | null; avatar_url?: string | null } | null;
|
||||||
isMember: boolean;
|
isMember: boolean;
|
||||||
navigate: (path: string) => void;
|
navigate: (path: string) => void;
|
||||||
};
|
};
|
||||||
@@ -245,7 +245,7 @@ export function UserMenuSheet({ open, onClose, user, isMember, navigate }: UserM
|
|||||||
<Switch
|
<Switch
|
||||||
size="$2"
|
size="$2"
|
||||||
checked={isDark}
|
checked={isDark}
|
||||||
onCheckedChange={(next) => updateAppearance(next ? 'dark' : 'light')}
|
onCheckedChange={(next: boolean) => updateAppearance(next ? 'dark' : 'light')}
|
||||||
aria-label={t('mobileProfile.theme', 'Dark Mode')}
|
aria-label={t('mobileProfile.theme', 'Dark Mode')}
|
||||||
>
|
>
|
||||||
<Switch.Thumb />
|
<Switch.Thumb />
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import React from 'react';
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { act, render, screen } from '@testing-library/react';
|
import { act, render, screen } from '@testing-library/react';
|
||||||
import { MemoryRouter } from 'react-router-dom';
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
import type { EventContextValue } from '../../../context/EventContext';
|
||||||
|
import type { TenantEvent } from '../../../api';
|
||||||
|
|
||||||
vi.mock('react-i18next', () => ({
|
vi.mock('react-i18next', () => ({
|
||||||
useTranslation: () => ({ t: (key: string, fallback?: string) => fallback ?? key, i18n: { language: 'en-GB' } }),
|
useTranslation: () => ({ t: (key: string, fallback?: string) => fallback ?? key, i18n: { language: 'en-GB' } }),
|
||||||
@@ -55,12 +57,27 @@ vi.mock('../UserMenuSheet', () => ({
|
|||||||
UserMenuSheet: () => <div data-testid="user-menu-sheet" />,
|
UserMenuSheet: () => <div data-testid="user-menu-sheet" />,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const eventContext = {
|
const baseEvent: TenantEvent = {
|
||||||
|
id: 1,
|
||||||
|
name: 'Test Event',
|
||||||
|
slug: 'event-1',
|
||||||
|
event_date: '2024-01-01',
|
||||||
|
event_type_id: null,
|
||||||
|
event_type: null,
|
||||||
|
status: 'published',
|
||||||
|
settings: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const eventContext: EventContextValue = {
|
||||||
events: [],
|
events: [],
|
||||||
activeEvent: { slug: 'event-1', name: 'Test Event', event_date: '2024-01-01', status: 'active', settings: {} },
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
activeEvent: baseEvent,
|
||||||
|
activeSlug: baseEvent.slug,
|
||||||
hasMultipleEvents: false,
|
hasMultipleEvents: false,
|
||||||
hasEvents: true,
|
hasEvents: true,
|
||||||
selectEvent: vi.fn(),
|
selectEvent: vi.fn(),
|
||||||
|
refetch: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mock('../../../context/EventContext', () => ({
|
vi.mock('../../../context/EventContext', () => ({
|
||||||
@@ -129,9 +146,25 @@ describe('MobileShell', () => {
|
|||||||
addEventListener: vi.fn(),
|
addEventListener: vi.fn(),
|
||||||
removeEventListener: vi.fn(),
|
removeEventListener: vi.fn(),
|
||||||
});
|
});
|
||||||
|
document.title = '';
|
||||||
eventContext.events = [];
|
eventContext.events = [];
|
||||||
eventContext.hasMultipleEvents = false;
|
eventContext.hasMultipleEvents = false;
|
||||||
eventContext.activeEvent = { slug: 'event-1', name: 'Test Event', event_date: '2024-01-01', status: 'active', settings: {} };
|
eventContext.activeEvent = { ...baseEvent };
|
||||||
|
eventContext.activeSlug = baseEvent.slug;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets the document title with the app prefix', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<MobileShell activeTab="home" title="Dashboard">
|
||||||
|
<div>Body</div>
|
||||||
|
</MobileShell>
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(document.title).toBe('Fotospiel.App Event Admin · Dashboard');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders quick QR as icon-only button', async () => {
|
it('renders quick QR as icon-only button', async () => {
|
||||||
@@ -171,8 +204,14 @@ describe('MobileShell', () => {
|
|||||||
|
|
||||||
it('shows the event switcher when multiple events are available', async () => {
|
it('shows the event switcher when multiple events are available', async () => {
|
||||||
eventContext.events = [
|
eventContext.events = [
|
||||||
{ slug: 'event-1', name: 'Test Event', event_date: '2024-01-01', status: 'active', settings: {} },
|
{ ...baseEvent },
|
||||||
{ slug: 'event-2', name: 'Second Event', event_date: '2024-02-01', status: 'active', settings: {} },
|
{
|
||||||
|
...baseEvent,
|
||||||
|
id: 2,
|
||||||
|
slug: 'event-2',
|
||||||
|
name: 'Second Event',
|
||||||
|
event_date: '2024-02-01',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -190,8 +229,14 @@ describe('MobileShell', () => {
|
|||||||
|
|
||||||
it('hides the event switcher on the events list page', async () => {
|
it('hides the event switcher on the events list page', async () => {
|
||||||
eventContext.events = [
|
eventContext.events = [
|
||||||
{ slug: 'event-1', name: 'Test Event', event_date: '2024-01-01', status: 'active', settings: {} },
|
{ ...baseEvent },
|
||||||
{ slug: 'event-2', name: 'Second Event', event_date: '2024-02-01', status: 'active', settings: {} },
|
{
|
||||||
|
...baseEvent,
|
||||||
|
id: 2,
|
||||||
|
slug: 'event-2',
|
||||||
|
name: 'Second Event',
|
||||||
|
event_date: '2024-02-01',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
|
|||||||
19
resources/js/admin/mobile/hooks/useDocumentTitle.ts
Normal file
19
resources/js/admin/mobile/hooks/useDocumentTitle.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
const TITLE_SEPARATOR = ' · ';
|
||||||
|
|
||||||
|
export function useDocumentTitle(title?: string | null) {
|
||||||
|
const { t, i18n } = useTranslation('mobile');
|
||||||
|
const language = i18n?.language;
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (typeof document === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseTitle = t('header.documentTitle', 'Fotospiel.App Event Admin');
|
||||||
|
const resolvedTitle = typeof title === 'string' ? title.trim() : '';
|
||||||
|
document.title = resolvedTitle ? `${baseTitle}${TITLE_SEPARATOR}${resolvedTitle}` : baseTitle;
|
||||||
|
}, [language, t, title]);
|
||||||
|
}
|
||||||
@@ -106,7 +106,7 @@ export function classifyPackageChange(pkg: Package, active: Package | null): Pac
|
|||||||
const hasFeatureUpgrade = Array.from(candidateFeatures).some((feature) => !featureSatisfied(feature, activeFeatures));
|
const hasFeatureUpgrade = Array.from(candidateFeatures).some((feature) => !featureSatisfied(feature, activeFeatures));
|
||||||
const hasFeatureDowngrade = Array.from(activeFeatures).some((feature) => !featureSatisfied(feature, candidateFeatures));
|
const hasFeatureDowngrade = Array.from(activeFeatures).some((feature) => !featureSatisfied(feature, candidateFeatures));
|
||||||
|
|
||||||
const limitKeys: Array<keyof Package> = ['max_photos', 'max_guests', 'gallery_days'];
|
const limitKeys: Array<'max_photos' | 'max_guests' | 'gallery_days'> = ['max_photos', 'max_guests', 'gallery_days'];
|
||||||
let hasLimitUpgrade = false;
|
let hasLimitUpgrade = false;
|
||||||
let hasLimitDowngrade = false;
|
let hasLimitDowngrade = false;
|
||||||
|
|
||||||
|
|||||||
@@ -86,11 +86,12 @@ export default function BottomNav() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`guest-bottom-nav fixed inset-x-0 bottom-0 z-30 border-t border-white/20 bg-gradient-to-t from-black/70 via-black/45 to-black/10 px-4 shadow-xl backdrop-blur-2xl transition-all duration-200 dark:border-white/10 dark:from-gray-950/90 dark:via-gray-900/70 dark:to-gray-900/35 ${
|
className={`guest-bottom-nav fixed inset-x-0 bottom-0 z-30 border-t border-white/15 bg-gradient-to-t from-black/55 via-black/30 to-black/5 px-4 shadow-2xl backdrop-blur-xl transition-all duration-200 dark:border-white/10 dark:from-gray-950/85 dark:via-gray-900/55 dark:to-gray-900/20 ${
|
||||||
compact ? 'pt-1' : 'pt-2 pb-1'
|
compact ? 'pt-1' : 'pt-2 pb-1'
|
||||||
}`}
|
}`}
|
||||||
style={{ paddingBottom: navPaddingBottom }}
|
style={{ paddingBottom: navPaddingBottom }}
|
||||||
>
|
>
|
||||||
|
<div className="pointer-events-none absolute -top-7 inset-x-0 h-7 bg-gradient-to-b from-black/0 via-black/30 to-black/60 dark:via-black/40 dark:to-black/70" aria-hidden />
|
||||||
<div className="mx-auto flex max-w-lg items-center gap-3">
|
<div className="mx-auto flex max-w-lg items-center gap-3">
|
||||||
<div className="flex flex-1 justify-evenly gap-2">
|
<div className="flex flex-1 justify-evenly gap-2">
|
||||||
<TabLink
|
<TabLink
|
||||||
|
|||||||
@@ -139,19 +139,24 @@ export default function EmotionPicker({
|
|||||||
key={emotion.id}
|
key={emotion.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleEmotionSelect(emotion)}
|
onClick={() => handleEmotionSelect(emotion)}
|
||||||
className="group flex flex-col gap-2 rounded-2xl border border-muted/40 bg-white/80 px-4 py-3 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-foreground/10 hover:shadow-md dark:border-gray-700/60 dark:bg-gray-900/70"
|
className="group relative flex flex-col gap-2 rounded-2xl p-[1px] text-left shadow-sm transition hover:-translate-y-0.5 hover:shadow-lg active:scale-[0.98]"
|
||||||
|
style={{
|
||||||
|
backgroundImage: 'linear-gradient(135deg, color-mix(in oklch, var(--guest-primary) 45%, white), color-mix(in oklch, var(--guest-secondary) 40%, white))',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="relative flex flex-col gap-2 rounded-[0.95rem] border border-white/50 bg-white/80 px-4 py-3 shadow-sm backdrop-blur-xl dark:border-white/10 dark:bg-gray-900/70">
|
||||||
<span className="text-2xl" aria-hidden>
|
<div className="flex items-center gap-3">
|
||||||
{emotion.emoji}
|
<span className="text-2xl" aria-hidden>
|
||||||
</span>
|
{emotion.emoji}
|
||||||
<div className="flex-1 min-w-0">
|
</span>
|
||||||
<div className="font-medium text-sm text-foreground dark:text-white">{localizedName}</div>
|
<div className="flex-1 min-w-0">
|
||||||
{localizedDescription && (
|
<div className="text-sm font-medium text-foreground dark:text-white">{localizedName}</div>
|
||||||
<div className="text-xs text-muted-foreground line-clamp-2 dark:text-white/60">{localizedDescription}</div>
|
{localizedDescription && (
|
||||||
)}
|
<div className="text-xs text-muted-foreground line-clamp-2 dark:text-white/60">{localizedDescription}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="h-4 w-4 text-muted-foreground opacity-0 transition group-hover:opacity-100 dark:text-white/60" />
|
||||||
</div>
|
</div>
|
||||||
<ChevronRight className="h-4 w-4 text-muted-foreground opacity-0 transition group-hover:opacity-100 dark:text-white/60" />
|
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Sparkles, Flame, UserRound, Camera } from 'lucide-react';
|
import { Sparkles, Flame, UserRound, Camera } from 'lucide-react';
|
||||||
|
import type { LucideIcon } from 'lucide-react';
|
||||||
import { useTranslation } from '../i18n/useTranslation';
|
import { useTranslation } from '../i18n/useTranslation';
|
||||||
|
|
||||||
export type GalleryFilter = 'latest' | 'popular' | 'mine' | 'photobooth';
|
export type GalleryFilter = 'latest' | 'popular' | 'mine' | 'photobooth';
|
||||||
|
|
||||||
type FilterConfig = Array<{ value: GalleryFilter; labelKey: string; icon: React.ReactNode }>;
|
type FilterConfig = Array<{ value: GalleryFilter; labelKey: string; icon: LucideIcon }>;
|
||||||
|
|
||||||
const baseFilters: FilterConfig = [
|
const baseFilters: FilterConfig = [
|
||||||
{ value: 'latest', labelKey: 'galleryPage.filters.latest', icon: <Sparkles className="h-4 w-4" aria-hidden /> },
|
{ value: 'latest', labelKey: 'galleryPage.filters.latest', icon: Sparkles },
|
||||||
{ value: 'popular', labelKey: 'galleryPage.filters.popular', icon: <Flame className="h-4 w-4" aria-hidden /> },
|
{ value: 'popular', labelKey: 'galleryPage.filters.popular', icon: Flame },
|
||||||
{ value: 'mine', labelKey: 'galleryPage.filters.mine', icon: <UserRound className="h-4 w-4" aria-hidden /> },
|
{ value: 'mine', labelKey: 'galleryPage.filters.mine', icon: UserRound },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function FiltersBar({
|
export default function FiltersBar({
|
||||||
@@ -29,7 +30,7 @@ export default function FiltersBar({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const filters: FilterConfig = React.useMemo(
|
const filters: FilterConfig = React.useMemo(
|
||||||
() => (showPhotobooth
|
() => (showPhotobooth
|
||||||
? [...baseFilters, { value: 'photobooth', labelKey: 'galleryPage.filters.photobooth', icon: <Camera className="h-4 w-4" aria-hidden /> }]
|
? [...baseFilters, { value: 'photobooth', labelKey: 'galleryPage.filters.photobooth', icon: Camera }]
|
||||||
: baseFilters),
|
: baseFilters),
|
||||||
[showPhotobooth],
|
[showPhotobooth],
|
||||||
);
|
);
|
||||||
@@ -45,6 +46,7 @@ export default function FiltersBar({
|
|||||||
<div className="inline-flex items-center rounded-full border border-border/70 bg-white/80 p-1 shadow-sm backdrop-blur dark:border-white/10 dark:bg-slate-950/70">
|
<div className="inline-flex items-center rounded-full border border-border/70 bg-white/80 p-1 shadow-sm backdrop-blur dark:border-white/10 dark:bg-slate-950/70">
|
||||||
{filters.map((filter, index) => {
|
{filters.map((filter, index) => {
|
||||||
const isActive = value === filter.value;
|
const isActive = value === filter.value;
|
||||||
|
const Icon = filter.icon;
|
||||||
return (
|
return (
|
||||||
<div key={filter.value} className="flex items-center">
|
<div key={filter.value} className="flex items-center">
|
||||||
<button
|
<button
|
||||||
@@ -57,7 +59,7 @@ export default function FiltersBar({
|
|||||||
: 'text-muted-foreground hover:bg-pink-50 hover:text-pink-600 dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white',
|
: 'text-muted-foreground hover:bg-pink-50 hover:text-pink-600 dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{React.cloneElement(filter.icon as React.ReactElement, { className: 'h-3.5 w-3.5' })}
|
<Icon className="h-3.5 w-3.5" aria-hidden />
|
||||||
<span className="whitespace-nowrap">{t(filter.labelKey)}</span>
|
<span className="whitespace-nowrap">{t(filter.labelKey)}</span>
|
||||||
</button>
|
</button>
|
||||||
{index < filters.length - 1 && (
|
{index < filters.length - 1 && (
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Heart } from 'lucide-react';
|
|||||||
import { useTranslation } from '../i18n/useTranslation';
|
import { useTranslation } from '../i18n/useTranslation';
|
||||||
import { useEventBranding } from '../context/EventBrandingContext';
|
import { useEventBranding } from '../context/EventBrandingContext';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
|
||||||
type Props = { token: string };
|
type Props = { token: string };
|
||||||
|
|
||||||
@@ -97,14 +98,16 @@ export default function GalleryPreview({ token }: Props) {
|
|||||||
style={{ borderRadius: radius, fontFamily: bodyFont }}
|
style={{ borderRadius: radius, fontFamily: bodyFont }}
|
||||||
>
|
>
|
||||||
<CardContent className="space-y-3 p-3">
|
<CardContent className="space-y-3 p-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs uppercase tracking-wide text-muted-foreground" style={headingFont ? { fontFamily: headingFont } : undefined}>Live-Galerie</p>
|
<div className="mb-1 inline-flex items-center rounded-full border border-white/50 bg-white/80 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.3em] text-muted-foreground shadow-sm backdrop-blur dark:border-white/10 dark:bg-slate-950/70" style={headingFont ? { fontFamily: headingFont } : undefined}>
|
||||||
|
Live-Galerie
|
||||||
|
</div>
|
||||||
<h3 className="text-lg font-semibold text-foreground" style={headingFont ? { fontFamily: headingFont } : undefined}>Alle Uploads auf einen Blick</h3>
|
<h3 className="text-lg font-semibold text-foreground" style={headingFont ? { fontFamily: headingFont } : undefined}>Alle Uploads auf einen Blick</h3>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
to={`/e/${encodeURIComponent(token)}/gallery?mode=${mode}`}
|
to={`/e/${encodeURIComponent(token)}/gallery?mode=${mode}`}
|
||||||
className="text-sm font-semibold transition"
|
className="rounded-full border border-white/40 bg-white/70 px-3 py-1 text-sm font-semibold shadow-sm backdrop-blur transition hover:bg-white/90 dark:border-white/10 dark:bg-slate-950/70 dark:hover:bg-slate-950"
|
||||||
style={{ color: linkColor }}
|
style={{ color: linkColor }}
|
||||||
>
|
>
|
||||||
Alle ansehen →
|
Alle ansehen →
|
||||||
@@ -112,28 +115,31 @@ export default function GalleryPreview({ token }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex overflow-x-auto pb-1 text-xs font-semibold [-ms-overflow-style:none] [scrollbar-width:none]">
|
<div className="flex overflow-x-auto pb-1 text-xs font-semibold [-ms-overflow-style:none] [scrollbar-width:none]">
|
||||||
<div className="inline-flex items-center rounded-full border border-border/70 bg-white/80 p-1 shadow-sm backdrop-blur dark:border-white/10 dark:bg-slate-950/70">
|
<div className="inline-flex items-center gap-1 rounded-full border border-border/70 bg-white/80 p-1 shadow-sm backdrop-blur dark:border-white/10 dark:bg-slate-950/70">
|
||||||
{filters.map((filter, index) => {
|
{filters.map((filter) => {
|
||||||
const isActive = mode === filter.value;
|
const isActive = mode === filter.value;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={filter.value} className="flex items-center">
|
<button
|
||||||
<button
|
key={filter.value}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setMode(filter.value)}
|
onClick={() => setMode(filter.value)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'inline-flex items-center rounded-full px-3 py-1.5 transition',
|
'relative inline-flex items-center rounded-full px-3 py-1.5 transition',
|
||||||
isActive
|
isActive
|
||||||
? 'bg-pink-500 text-white shadow'
|
? 'text-white'
|
||||||
: 'text-muted-foreground hover:bg-pink-50 hover:text-pink-600 dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white',
|
: 'text-muted-foreground hover:text-pink-600 dark:text-white/70 dark:hover:text-white',
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="whitespace-nowrap">{filter.label}</span>
|
|
||||||
</button>
|
|
||||||
{index < filters.length - 1 && (
|
|
||||||
<span className="mx-1 h-4 w-px bg-border/60 dark:bg-white/10" aria-hidden />
|
|
||||||
)}
|
)}
|
||||||
</div>
|
>
|
||||||
|
{isActive && (
|
||||||
|
<motion.span
|
||||||
|
layoutId="gallery-filter-pill"
|
||||||
|
className="absolute inset-0 rounded-full bg-gradient-to-r from-pink-500 to-rose-500 shadow"
|
||||||
|
transition={{ type: 'spring', stiffness: 380, damping: 30 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="relative z-10 whitespace-nowrap">{filter.label}</span>
|
||||||
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import AppearanceToggleDropdown from '@/components/appearance-dropdown';
|
import AppearanceToggleDropdown from '@/components/appearance-dropdown';
|
||||||
import {
|
import {
|
||||||
User,
|
User,
|
||||||
@@ -22,13 +22,12 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useEventData } from '../hooks/useEventData';
|
import { useEventData } from '../hooks/useEventData';
|
||||||
import { useOptionalEventStats } from '../context/EventStatsContext';
|
import { useOptionalEventStats } from '../context/EventStatsContext';
|
||||||
import { useOptionalGuestIdentity } from '../context/GuestIdentityContext';
|
|
||||||
import { SettingsSheet } from './settings-sheet';
|
import { SettingsSheet } from './settings-sheet';
|
||||||
import { useTranslation, type TranslateFn } from '../i18n/useTranslation';
|
import { useTranslation, type TranslateFn } from '../i18n/useTranslation';
|
||||||
import { DEFAULT_EVENT_BRANDING, useOptionalEventBranding } from '../context/EventBrandingContext';
|
import { DEFAULT_EVENT_BRANDING, useOptionalEventBranding } from '../context/EventBrandingContext';
|
||||||
import { useOptionalNotificationCenter, type NotificationCenterValue } from '../context/NotificationCenterContext';
|
import { useOptionalNotificationCenter, type NotificationCenterValue } from '../context/NotificationCenterContext';
|
||||||
import { usePushSubscription } from '../hooks/usePushSubscription';
|
import { usePushSubscription } from '../hooks/usePushSubscription';
|
||||||
import { getContrastingTextColor, relativeLuminance } from '../lib/color';
|
import { getContrastingTextColor, relativeLuminance, hexToRgb } from '../lib/color';
|
||||||
import { isTaskModeEnabled } from '../lib/engagement';
|
import { isTaskModeEnabled } from '../lib/engagement';
|
||||||
|
|
||||||
const EVENT_ICON_COMPONENTS: Record<string, React.ComponentType<{ className?: string }>> = {
|
const EVENT_ICON_COMPONENTS: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||||
@@ -81,6 +80,14 @@ function getInitials(name: string): string {
|
|||||||
return name.substring(0, 2).toUpperCase();
|
return name.substring(0, 2).toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toRgba(value: string, alpha: number): string {
|
||||||
|
const rgb = hexToRgb(value);
|
||||||
|
if (!rgb) {
|
||||||
|
return `rgba(255, 255, 255, ${alpha})`;
|
||||||
|
}
|
||||||
|
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${alpha})`;
|
||||||
|
}
|
||||||
|
|
||||||
function EventAvatar({
|
function EventAvatar({
|
||||||
name,
|
name,
|
||||||
icon,
|
icon,
|
||||||
@@ -167,9 +174,7 @@ function EventAvatar({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Header({ eventToken, title = '' }: { eventToken?: string; title?: string }) {
|
export default function Header({ eventToken, title = '' }: { eventToken?: string; title?: string }) {
|
||||||
const location = useLocation();
|
|
||||||
const statsContext = useOptionalEventStats();
|
const statsContext = useOptionalEventStats();
|
||||||
const identity = useOptionalGuestIdentity();
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const brandingContext = useOptionalEventBranding();
|
const brandingContext = useOptionalEventBranding();
|
||||||
const branding = brandingContext?.branding ?? DEFAULT_EVENT_BRANDING;
|
const branding = brandingContext?.branding ?? DEFAULT_EVENT_BRANDING;
|
||||||
@@ -206,23 +211,6 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
|
|||||||
return () => document.removeEventListener('mousedown', handler);
|
return () => document.removeEventListener('mousedown', handler);
|
||||||
}, [notificationsOpen]);
|
}, [notificationsOpen]);
|
||||||
|
|
||||||
if (!eventToken) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="guest-header z-20 flex items-center justify-between border-b bg-white/70 px-4 py-2 dark:bg-black/40"
|
|
||||||
style={branding.fontFamily ? { fontFamily: branding.fontFamily } : undefined}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<div className="font-semibold">{title}</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<AppearanceToggleDropdown />
|
|
||||||
<SettingsSheet />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const headerFont = branding.typography?.heading ?? branding.fontFamily ?? undefined;
|
const headerFont = branding.typography?.heading ?? branding.fontFamily ?? undefined;
|
||||||
const bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined;
|
const bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined;
|
||||||
const logoPosition = branding.logo?.position ?? 'left';
|
const logoPosition = branding.logo?.position ?? 'left';
|
||||||
@@ -231,16 +219,48 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
|
|||||||
color: headerTextColor,
|
color: headerTextColor,
|
||||||
fontFamily: headerFont,
|
fontFamily: headerFont,
|
||||||
};
|
};
|
||||||
|
const headerGlowPrimary = toRgba(branding.primaryColor, 0.35);
|
||||||
|
const headerGlowSecondary = toRgba(branding.secondaryColor, 0.35);
|
||||||
|
const headerShimmer = `linear-gradient(120deg, ${toRgba(branding.primaryColor, 0.28)}, transparent 45%, ${toRgba(branding.secondaryColor, 0.32)})`;
|
||||||
|
const headerHairline = `linear-gradient(90deg, transparent, ${toRgba(headerTextColor, 0.4)}, transparent)`;
|
||||||
|
|
||||||
|
if (!eventToken) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="guest-header sticky top-0 z-30 relative overflow-hidden border-b border-white/20 bg-white/70 px-4 py-2 shadow-[0_14px_40px_-30px_rgba(15,23,42,0.6)] backdrop-blur-2xl dark:border-white/10 dark:bg-black/40"
|
||||||
|
style={branding.fontFamily ? { fontFamily: branding.fontFamily } : undefined}
|
||||||
|
>
|
||||||
|
<div className="pointer-events-none absolute inset-0 opacity-70 guest-aurora-soft" style={{ backgroundImage: headerShimmer }} aria-hidden />
|
||||||
|
<div className="pointer-events-none absolute -top-8 right-0 h-24 w-24 rounded-full bg-white/60 blur-3xl dark:bg-white/10" aria-hidden />
|
||||||
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-px bg-gradient-to-r from-transparent via-white/40 to-transparent dark:via-white/15" aria-hidden />
|
||||||
|
<div className="relative z-10 flex w-full items-center gap-3 flex-nowrap">
|
||||||
|
<div className="flex min-w-0 flex-col">
|
||||||
|
<div className="font-semibold">{title}</div>
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto flex items-center justify-end gap-2">
|
||||||
|
<AppearanceToggleDropdown />
|
||||||
|
<SettingsSheet />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const accentColor = branding.secondaryColor;
|
const accentColor = branding.secondaryColor;
|
||||||
|
|
||||||
if (status === 'loading') {
|
if (status === 'loading') {
|
||||||
return (
|
return (
|
||||||
<div className="guest-header z-20 flex items-center justify-between border-b border-white/10 px-4 py-2 text-white shadow-sm backdrop-blur" style={headerStyle}>
|
<div className="guest-header sticky top-0 z-30 relative overflow-hidden border-b border-white/20 px-4 py-2 shadow-[0_18px_45px_-30px_rgba(15,23,42,0.65)] backdrop-blur-2xl" style={headerStyle}>
|
||||||
<div className="font-semibold" style={branding.fontFamily ? { fontFamily: branding.fontFamily } : undefined}>{t('header.loading')}</div>
|
<div className="pointer-events-none absolute inset-0 opacity-70 guest-aurora" style={{ backgroundImage: headerShimmer }} aria-hidden />
|
||||||
<div className="flex items-center gap-2">
|
<div className="pointer-events-none absolute -top-10 right-[-32px] h-28 w-28 rounded-full blur-3xl" style={{ background: headerGlowSecondary }} aria-hidden />
|
||||||
<AppearanceToggleDropdown />
|
<div className="pointer-events-none absolute -bottom-8 left-1/3 h-20 w-40 -translate-x-1/2 rounded-full blur-3xl" style={{ background: headerGlowPrimary }} aria-hidden />
|
||||||
<SettingsSheet />
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-px" style={{ background: headerHairline }} aria-hidden />
|
||||||
|
<div className="relative z-10 flex w-full items-center gap-3 flex-nowrap">
|
||||||
|
<div className="font-semibold" style={branding.fontFamily ? { fontFamily: branding.fontFamily } : undefined}>{t('header.loading')}</div>
|
||||||
|
<div className="ml-auto flex items-center justify-end gap-2">
|
||||||
|
<AppearanceToggleDropdown />
|
||||||
|
<SettingsSheet />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -254,16 +274,20 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
|
|||||||
statsContext && statsContext.eventKey === eventToken ? statsContext : undefined;
|
statsContext && statsContext.eventKey === eventToken ? statsContext : undefined;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="guest-header z-20 flex items-center justify-between border-b border-white/10 px-4 py-3 text-white shadow-sm backdrop-blur"
|
className="guest-header sticky top-0 z-30 relative flex flex-nowrap items-center gap-3 overflow-hidden border-b border-white/20 px-4 py-2 shadow-[0_18px_45px_-30px_rgba(15,23,42,0.65)] backdrop-blur-2xl"
|
||||||
style={headerStyle}
|
style={headerStyle}
|
||||||
>
|
>
|
||||||
|
<div className="pointer-events-none absolute inset-0 opacity-70 guest-aurora" style={{ backgroundImage: headerShimmer }} aria-hidden />
|
||||||
|
<div className="pointer-events-none absolute -top-12 right-[-40px] h-32 w-32 rounded-full blur-3xl" style={{ background: headerGlowSecondary }} aria-hidden />
|
||||||
|
<div className="pointer-events-none absolute -bottom-10 left-1/3 h-24 w-44 -translate-x-1/2 rounded-full blur-3xl" style={{ background: headerGlowPrimary }} aria-hidden />
|
||||||
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-px" style={{ background: headerHairline }} aria-hidden />
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
logoPosition === 'center'
|
`relative z-10 flex min-w-0 flex-1 ${logoPosition === 'center'
|
||||||
? 'flex flex-col items-center gap-2 text-center'
|
? 'flex-col items-center gap-1 text-center'
|
||||||
: logoPosition === 'right'
|
: logoPosition === 'right'
|
||||||
? 'flex flex-row-reverse items-center gap-3'
|
? 'flex-row-reverse items-center gap-3'
|
||||||
: 'flex items-center gap-3'
|
: 'items-center gap-3'}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<EventAvatar
|
<EventAvatar
|
||||||
@@ -277,7 +301,7 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
|
|||||||
className={`flex flex-col${logoPosition === 'center' ? ' items-center text-center' : ''}`}
|
className={`flex flex-col${logoPosition === 'center' ? ' items-center text-center' : ''}`}
|
||||||
style={headerFont ? { fontFamily: headerFont } : undefined}
|
style={headerFont ? { fontFamily: headerFont } : undefined}
|
||||||
>
|
>
|
||||||
<div className="font-semibold text-lg">{event.name}</div>
|
<div className="truncate text-base font-semibold sm:text-lg">{event.name}</div>
|
||||||
<div className="flex items-center gap-2 text-xs opacity-70" style={bodyFont ? { fontFamily: bodyFont } : undefined}>
|
<div className="flex items-center gap-2 text-xs opacity-70" style={bodyFont ? { fontFamily: bodyFont } : undefined}>
|
||||||
{stats && tasksEnabled && (
|
{stats && tasksEnabled && (
|
||||||
<>
|
<>
|
||||||
@@ -295,7 +319,7 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="relative z-10 ml-auto flex shrink-0 items-center justify-end gap-2">
|
||||||
{notificationCenter && eventToken && (
|
{notificationCenter && eventToken && (
|
||||||
<NotificationButton
|
<NotificationButton
|
||||||
eventToken={eventToken}
|
eventToken={eventToken}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { ExpirationPlugin } from 'workbox-expiration';
|
|||||||
import { precacheAndRoute, cleanupOutdatedCaches } from 'workbox-precaching';
|
import { precacheAndRoute, cleanupOutdatedCaches } from 'workbox-precaching';
|
||||||
import { registerRoute } from 'workbox-routing';
|
import { registerRoute } from 'workbox-routing';
|
||||||
import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies';
|
import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies';
|
||||||
|
import { shouldCacheResponse } from './lib/cachePolicy';
|
||||||
|
|
||||||
declare const self: ServiceWorkerGlobalScope & {
|
declare const self: ServiceWorkerGlobalScope & {
|
||||||
__WB_MANIFEST: Array<any>;
|
__WB_MANIFEST: Array<any>;
|
||||||
@@ -63,6 +64,9 @@ registerRoute(
|
|||||||
cacheName: 'guest-api',
|
cacheName: 'guest-api',
|
||||||
networkTimeoutSeconds: 6,
|
networkTimeoutSeconds: 6,
|
||||||
plugins: [
|
plugins: [
|
||||||
|
{
|
||||||
|
cacheWillUpdate: async ({ response }) => (shouldCacheResponse(response) ? response : null),
|
||||||
|
},
|
||||||
new CacheableResponsePlugin({ statuses: [0, 200] }),
|
new CacheableResponsePlugin({ statuses: [0, 200] }),
|
||||||
new ExpirationPlugin({ maxEntries: 80, maxAgeSeconds: 60 * 60 * 24 }),
|
new ExpirationPlugin({ maxEntries: 80, maxAgeSeconds: 60 * 60 * 24 }),
|
||||||
],
|
],
|
||||||
|
|||||||
24
resources/js/guest/lib/__tests__/cachePolicy.test.ts
Normal file
24
resources/js/guest/lib/__tests__/cachePolicy.test.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { shouldCacheResponse } from '../cachePolicy';
|
||||||
|
|
||||||
|
describe('shouldCacheResponse', () => {
|
||||||
|
it('returns false when Cache-Control is no-store', () => {
|
||||||
|
const response = new Response('ok', { headers: { 'Cache-Control': 'no-store' } });
|
||||||
|
expect(shouldCacheResponse(response)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when Cache-Control is private', () => {
|
||||||
|
const response = new Response('ok', { headers: { 'Cache-Control': 'private, max-age=0' } });
|
||||||
|
expect(shouldCacheResponse(response)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when Pragma is no-cache', () => {
|
||||||
|
const response = new Response('ok', { headers: { Pragma: 'no-cache' } });
|
||||||
|
expect(shouldCacheResponse(response)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true for cacheable responses', () => {
|
||||||
|
const response = new Response('ok', { headers: { 'Cache-Control': 'public, max-age=60' } });
|
||||||
|
expect(shouldCacheResponse(response)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
36
resources/js/guest/lib/__tests__/csrf.test.ts
Normal file
36
resources/js/guest/lib/__tests__/csrf.test.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { buildCsrfHeaders } from '../csrf';
|
||||||
|
|
||||||
|
describe('buildCsrfHeaders', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.setItem('device-id', 'device-123');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
document.head.querySelectorAll('meta[name="csrf-token"]').forEach((node) => node.remove());
|
||||||
|
document.cookie = 'XSRF-TOKEN=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads token from meta tag', () => {
|
||||||
|
const meta = document.createElement('meta');
|
||||||
|
meta.setAttribute('name', 'csrf-token');
|
||||||
|
meta.setAttribute('content', 'meta-token');
|
||||||
|
document.head.appendChild(meta);
|
||||||
|
|
||||||
|
const headers = buildCsrfHeaders('device-xyz');
|
||||||
|
expect(headers['X-CSRF-TOKEN']).toBe('meta-token');
|
||||||
|
expect(headers['X-XSRF-TOKEN']).toBe('meta-token');
|
||||||
|
expect(headers['X-Device-Id']).toBe('device-xyz');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to cookie token', () => {
|
||||||
|
const raw = btoa('cookie-token');
|
||||||
|
document.cookie = `XSRF-TOKEN=${raw}; path=/`;
|
||||||
|
|
||||||
|
const headers = buildCsrfHeaders();
|
||||||
|
expect(headers['X-CSRF-TOKEN']).toBe('cookie-token');
|
||||||
|
expect(headers['X-XSRF-TOKEN']).toBe('cookie-token');
|
||||||
|
expect(headers['X-Device-Id']).toBe('device-123');
|
||||||
|
});
|
||||||
|
});
|
||||||
20
resources/js/guest/lib/cachePolicy.ts
Normal file
20
resources/js/guest/lib/cachePolicy.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
export function shouldCacheResponse(response: Response | null): boolean {
|
||||||
|
if (!response) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheControl = response.headers.get('Cache-Control') ?? '';
|
||||||
|
const pragma = response.headers.get('Pragma') ?? '';
|
||||||
|
const normalizedCacheControl = cacheControl.toLowerCase();
|
||||||
|
const normalizedPragma = pragma.toLowerCase();
|
||||||
|
|
||||||
|
if (normalizedCacheControl.includes('no-store') || normalizedCacheControl.includes('private')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedPragma.includes('no-cache')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
49
resources/js/guest/lib/csrf.ts
Normal file
49
resources/js/guest/lib/csrf.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { getDeviceId } from './device';
|
||||||
|
|
||||||
|
function getCsrfToken(): string | null {
|
||||||
|
if (typeof document === 'undefined') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const metaToken = document.querySelector('meta[name="csrf-token"]');
|
||||||
|
if (metaToken instanceof HTMLMetaElement) {
|
||||||
|
return metaToken.getAttribute('content') || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = 'XSRF-TOKEN=';
|
||||||
|
const decodedCookie = decodeURIComponent(document.cookie ?? '');
|
||||||
|
const parts = decodedCookie.split(';');
|
||||||
|
for (const part of parts) {
|
||||||
|
const trimmed = part.trimStart();
|
||||||
|
if (!trimmed.startsWith(name)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const token = trimmed.substring(name.length);
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(atob(token));
|
||||||
|
} catch {
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCsrfHeaders(deviceId?: string): Record<string, string> {
|
||||||
|
const token = getCsrfToken();
|
||||||
|
const resolvedDeviceId = deviceId ?? (typeof window !== 'undefined' ? getDeviceId() : undefined);
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
Accept: 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (resolvedDeviceId) {
|
||||||
|
headers['X-Device-Id'] = resolvedDeviceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
headers['X-CSRF-TOKEN'] = token;
|
||||||
|
headers['X-XSRF-TOKEN'] = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
@@ -23,11 +23,20 @@ import 'swiper/css';
|
|||||||
import 'swiper/css/effect-cards';
|
import 'swiper/css/effect-cards';
|
||||||
import { getEmotionIcon, getEmotionTheme, type EmotionIdentity } from '../lib/emotionTheme';
|
import { getEmotionIcon, getEmotionTheme, type EmotionIdentity } from '../lib/emotionTheme';
|
||||||
import { getDeviceId } from '../lib/device';
|
import { getDeviceId } from '../lib/device';
|
||||||
|
import { hexToRgb } from '../lib/color';
|
||||||
import { useDirectUpload } from '../hooks/useDirectUpload';
|
import { useDirectUpload } from '../hooks/useDirectUpload';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { isTaskModeEnabled } from '../lib/engagement';
|
import { isTaskModeEnabled } from '../lib/engagement';
|
||||||
import { FADE_SCALE, FADE_UP, STAGGER_FAST, getMotionContainerProps, getMotionItemProps, prefersReducedMotion } from '../lib/motion';
|
import { FADE_SCALE, FADE_UP, STAGGER_FAST, getMotionContainerProps, getMotionItemProps, prefersReducedMotion } from '../lib/motion';
|
||||||
|
|
||||||
|
function toRgba(value: string, alpha: number): string {
|
||||||
|
const rgb = hexToRgb(value);
|
||||||
|
if (!rgb) {
|
||||||
|
return `rgba(255, 255, 255, ${alpha})`;
|
||||||
|
}
|
||||||
|
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${alpha})`;
|
||||||
|
}
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const { token } = useParams<{ token: string }>();
|
const { token } = useParams<{ token: string }>();
|
||||||
const { name, hydrated } = useGuestIdentity();
|
const { name, hydrated } = useGuestIdentity();
|
||||||
@@ -56,6 +65,14 @@ export default function HomePage() {
|
|||||||
}
|
}
|
||||||
}, [heroStorageKey]);
|
}, [heroStorageKey]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (typeof document === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.body.classList.remove('guest-immersive');
|
||||||
|
document.body.classList.remove('guest-nav-visible');
|
||||||
|
}, []);
|
||||||
|
|
||||||
const dismissHero = React.useCallback(() => {
|
const dismissHero = React.useCallback(() => {
|
||||||
setHeroVisible(false);
|
setHeroVisible(false);
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
@@ -73,6 +90,13 @@ export default function HomePage() {
|
|||||||
const eventNameDisplay = event?.name ?? t('home.hero.defaultEventName');
|
const eventNameDisplay = event?.name ?? t('home.hero.defaultEventName');
|
||||||
const accentColor = branding.primaryColor;
|
const accentColor = branding.primaryColor;
|
||||||
const secondaryAccent = branding.secondaryColor;
|
const secondaryAccent = branding.secondaryColor;
|
||||||
|
const glowPrimary = toRgba(accentColor, 0.35);
|
||||||
|
const glowSecondary = toRgba(secondaryAccent, 0.32);
|
||||||
|
const shimmerGradient = `linear-gradient(120deg, ${toRgba(accentColor, 0.22)}, transparent 45%, ${toRgba(secondaryAccent, 0.32)})`;
|
||||||
|
const welcomePanelStyle = React.useMemo(() => ({
|
||||||
|
background: `linear-gradient(135deg, color-mix(in oklch, ${accentColor} 18%, var(--background)), color-mix(in oklch, ${secondaryAccent} 16%, var(--background)))`,
|
||||||
|
borderColor: toRgba(accentColor, 0.25),
|
||||||
|
}), [accentColor, secondaryAccent]);
|
||||||
const uploadsRequireApproval =
|
const uploadsRequireApproval =
|
||||||
(event?.guest_upload_visibility as 'immediate' | 'review' | undefined) !== 'immediate';
|
(event?.guest_upload_visibility as 'immediate' | 'review' | undefined) !== 'immediate';
|
||||||
const tasksEnabled = isTaskModeEnabled(event);
|
const tasksEnabled = isTaskModeEnabled(event);
|
||||||
@@ -80,6 +104,31 @@ export default function HomePage() {
|
|||||||
const containerMotion = getMotionContainerProps(motionEnabled, STAGGER_FAST);
|
const containerMotion = getMotionContainerProps(motionEnabled, STAGGER_FAST);
|
||||||
const fadeUpMotion = getMotionItemProps(motionEnabled, FADE_UP);
|
const fadeUpMotion = getMotionItemProps(motionEnabled, FADE_UP);
|
||||||
const fadeScaleMotion = getMotionItemProps(motionEnabled, FADE_SCALE);
|
const fadeScaleMotion = getMotionItemProps(motionEnabled, FADE_SCALE);
|
||||||
|
const backdropStyle = React.useMemo(() => ({
|
||||||
|
'--guest-glow-primary': glowPrimary,
|
||||||
|
'--guest-glow-secondary': glowSecondary,
|
||||||
|
}) as React.CSSProperties & Record<string, string>, [glowPrimary, glowSecondary]);
|
||||||
|
|
||||||
|
const renderWithBackdrop = (content: React.ReactNode) => (
|
||||||
|
<div className="relative -mx-4 px-4">
|
||||||
|
<div className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-[420px] overflow-hidden" style={backdropStyle} aria-hidden>
|
||||||
|
<div
|
||||||
|
className="absolute -top-24 left-1/2 h-[320px] w-[320px] -translate-x-1/2 rounded-full blur-3xl"
|
||||||
|
style={{ background: 'var(--guest-glow-primary)' }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute -top-10 right-[-70px] h-[260px] w-[260px] rounded-full blur-3xl"
|
||||||
|
style={{ background: 'var(--guest-glow-secondary)' }}
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 opacity-70 guest-aurora" style={{ backgroundImage: shimmerGradient }} />
|
||||||
|
<div className="absolute inset-0 bg-[radial-gradient(120%_100%_at_50%_0%,rgba(255,255,255,0.7),transparent_70%)] opacity-70 dark:opacity-30" />
|
||||||
|
<div className="absolute inset-x-0 bottom-0 h-20 bg-gradient-to-b from-transparent to-[var(--background)]" />
|
||||||
|
</div>
|
||||||
|
<div className="relative z-10">
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
const [missionDeck, setMissionDeck] = React.useState<MissionPreview[]>([]);
|
const [missionDeck, setMissionDeck] = React.useState<MissionPreview[]>([]);
|
||||||
const [missionPool, setMissionPool] = React.useState<MissionPreview[]>([]);
|
const [missionPool, setMissionPool] = React.useState<MissionPreview[]>([]);
|
||||||
@@ -293,17 +342,24 @@ export default function HomePage() {
|
|||||||
const introMessage = introMessageRef.current;
|
const introMessage = introMessageRef.current;
|
||||||
|
|
||||||
if (!tasksEnabled) {
|
if (!tasksEnabled) {
|
||||||
return (
|
return renderWithBackdrop(
|
||||||
<motion.div className="space-y-3 pb-24" style={bodyFont ? { fontFamily: bodyFont } : undefined} {...containerMotion}>
|
<motion.div className="space-y-3 pb-28" style={bodyFont ? { fontFamily: bodyFont } : undefined} {...containerMotion}>
|
||||||
<motion.section
|
<motion.section
|
||||||
className="space-y-1 px-4"
|
className="px-4"
|
||||||
style={headingFont ? { fontFamily: headingFont } : undefined}
|
style={headingFont ? { fontFamily: headingFont } : undefined}
|
||||||
{...fadeUpMotion}
|
{...fadeUpMotion}
|
||||||
>
|
>
|
||||||
<p className="text-sm font-semibold text-foreground">
|
<div
|
||||||
{t('home.welcomeLine').replace('{name}', displayName)}
|
className="rounded-2xl border bg-white/70 px-4 py-3 shadow-lg backdrop-blur-xl dark:border-white/10 dark:bg-slate-950/70"
|
||||||
</p>
|
style={welcomePanelStyle}
|
||||||
{introMessage && <p className="text-xs text-muted-foreground">{introMessage}</p>}
|
>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-semibold text-foreground">
|
||||||
|
{t('home.welcomeLine').replace('{name}', displayName)}
|
||||||
|
</p>
|
||||||
|
{introMessage && <p className="text-xs text-muted-foreground">{introMessage}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</motion.section>
|
</motion.section>
|
||||||
|
|
||||||
<motion.section className="space-y-2 px-4" {...fadeUpMotion}>
|
<motion.section className="space-y-2 px-4" {...fadeUpMotion}>
|
||||||
@@ -328,21 +384,28 @@ export default function HomePage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return renderWithBackdrop(
|
||||||
<motion.div className="space-y-0.5 pb-24" style={bodyFont ? { fontFamily: bodyFont } : undefined} {...containerMotion}>
|
<motion.div className="space-y-0.5 pb-28" style={bodyFont ? { fontFamily: bodyFont } : undefined} {...containerMotion}>
|
||||||
<motion.section
|
<motion.section
|
||||||
className="space-y-1 px-4"
|
className="px-4"
|
||||||
style={headingFont ? { fontFamily: headingFont } : undefined}
|
style={headingFont ? { fontFamily: headingFont } : undefined}
|
||||||
{...fadeUpMotion}
|
{...fadeUpMotion}
|
||||||
>
|
>
|
||||||
<p className="text-sm font-semibold text-foreground">
|
<div
|
||||||
{t('home.welcomeLine').replace('{name}', displayName)}
|
className="rounded-2xl border bg-white/70 px-4 py-3 shadow-lg backdrop-blur-xl dark:border-white/10 dark:bg-slate-950/70"
|
||||||
</p>
|
style={welcomePanelStyle}
|
||||||
{introMessage && (
|
>
|
||||||
<p className="text-xs text-muted-foreground">
|
<div className="space-y-1">
|
||||||
{introMessage}
|
<p className="text-sm font-semibold text-foreground">
|
||||||
</p>
|
{t('home.welcomeLine').replace('{name}', displayName)}
|
||||||
)}
|
</p>
|
||||||
|
{introMessage && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{introMessage}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</motion.section>
|
</motion.section>
|
||||||
|
|
||||||
{heroVisible && (
|
{heroVisible && (
|
||||||
@@ -507,6 +570,7 @@ export function MissionActionCard({
|
|||||||
const bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined;
|
const bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined;
|
||||||
const cards = mission ? [mission, ...stack] : stack;
|
const cards = mission ? [mission, ...stack] : stack;
|
||||||
const shellRadius = `${radius + 10}px`;
|
const shellRadius = `${radius + 10}px`;
|
||||||
|
const motionEnabled = !prefersReducedMotion();
|
||||||
const normalizeText = (value: string | undefined | null) =>
|
const normalizeText = (value: string | undefined | null) =>
|
||||||
(value ?? '').trim().toLowerCase().replace(/\s+/g, ' ');
|
(value ?? '').trim().toLowerCase().replace(/\s+/g, ' ');
|
||||||
const [expandedTaskId, setExpandedTaskId] = React.useState<number | null>(null);
|
const [expandedTaskId, setExpandedTaskId] = React.useState<number | null>(null);
|
||||||
@@ -516,8 +580,31 @@ export function MissionActionCard({
|
|||||||
const [showSwipeHint, setShowSwipeHint] = React.useState(false);
|
const [showSwipeHint, setShowSwipeHint] = React.useState(false);
|
||||||
const hintTimeoutRef = React.useRef<number | null>(null);
|
const hintTimeoutRef = React.useRef<number | null>(null);
|
||||||
const hintStorageKey = token ? `guestMissionSwipeHintSeen_${token}` : 'guestMissionSwipeHintSeen';
|
const hintStorageKey = token ? `guestMissionSwipeHintSeen_${token}` : 'guestMissionSwipeHintSeen';
|
||||||
const motionEnabled = !prefersReducedMotion();
|
|
||||||
const shouldShowHint = motionEnabled && cards.length > 1 && Boolean(swipeHintLabel);
|
const shouldShowHint = motionEnabled && cards.length > 1 && Boolean(swipeHintLabel);
|
||||||
|
const ctaPulseKey = token ? `guestMissionCtaPulse_${token}` : 'guestMissionCtaPulse';
|
||||||
|
const [ctaPulse, setCtaPulse] = React.useState(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!motionEnabled || typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (window.sessionStorage.getItem(ctaPulseKey)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore storage exceptions
|
||||||
|
}
|
||||||
|
|
||||||
|
setCtaPulse(true);
|
||||||
|
const timeout = window.setTimeout(() => setCtaPulse(false), 4800);
|
||||||
|
try {
|
||||||
|
window.sessionStorage.setItem(ctaPulseKey, '1');
|
||||||
|
} catch {
|
||||||
|
// ignore storage exceptions
|
||||||
|
}
|
||||||
|
return () => window.clearTimeout(timeout);
|
||||||
|
}, [ctaPulseKey, motionEnabled]);
|
||||||
|
|
||||||
const dismissSwipeHint = React.useCallback((persist = true) => {
|
const dismissSwipeHint = React.useCallback((persist = true) => {
|
||||||
if (!showSwipeHint) {
|
if (!showSwipeHint) {
|
||||||
@@ -622,6 +709,13 @@ export function MissionActionCard({
|
|||||||
const titleClamp = isExpanded ? '' : 'line-clamp-2 sm:line-clamp-3';
|
const titleClamp = isExpanded ? '' : 'line-clamp-2 sm:line-clamp-3';
|
||||||
const titleClasses = `text-xl font-semibold leading-snug text-slate-900 dark:text-white sm:text-2xl break-words py-1 min-h-[3.75rem] sm:min-h-[4.5rem] ${titleClamp}`;
|
const titleClasses = `text-xl font-semibold leading-snug text-slate-900 dark:text-white sm:text-2xl break-words py-1 min-h-[3.75rem] sm:min-h-[4.5rem] ${titleClamp}`;
|
||||||
const titleId = card ? `task-title-${card.id}` : undefined;
|
const titleId = card ? `task-title-${card.id}` : undefined;
|
||||||
|
const ctaStyles = {
|
||||||
|
borderRadius: `${radius}px`,
|
||||||
|
background: `linear-gradient(120deg, ${primary}, ${secondary})`,
|
||||||
|
boxShadow: `0 12px 28px ${primary}25`,
|
||||||
|
'--cta-glow': toRgba(primary, 0.35),
|
||||||
|
'--cta-ring': toRgba(primary, 0.16),
|
||||||
|
} as React.CSSProperties & Record<string, string>;
|
||||||
const toggleExpanded = () => {
|
const toggleExpanded = () => {
|
||||||
if (!card) return;
|
if (!card) return;
|
||||||
setExpandedTaskId((prev) => (prev === card.id ? null : card.id));
|
setExpandedTaskId((prev) => (prev === card.id ? null : card.id));
|
||||||
@@ -748,12 +842,8 @@ export function MissionActionCard({
|
|||||||
<div className="mt-4 grid grid-cols-[2fr_1fr] gap-2">
|
<div className="mt-4 grid grid-cols-[2fr_1fr] gap-2">
|
||||||
<Button
|
<Button
|
||||||
asChild
|
asChild
|
||||||
className="w-full text-white shadow-lg"
|
className={`w-full text-white shadow-lg ${ctaPulse ? 'guest-cta-pulse' : ''}`}
|
||||||
style={{
|
style={ctaStyles}
|
||||||
borderRadius: `${radius}px`,
|
|
||||||
background: `linear-gradient(120deg, ${primary}, ${secondary})`,
|
|
||||||
boxShadow: `0 12px 28px ${primary}25`,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
to={
|
to={
|
||||||
@@ -769,8 +859,8 @@ export function MissionActionCard({
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="secondary"
|
variant="ghost"
|
||||||
className="w-full border border-slate-200 bg-white/80 text-slate-800 shadow-sm backdrop-blur dark:border-white/10 dark:bg-slate-950/70 dark:text-white/80"
|
className="w-full border border-white/40 bg-white/60 text-slate-600 shadow-sm backdrop-blur transition hover:bg-white/80 hover:text-slate-800 dark:border-white/10 dark:bg-slate-950/60 dark:text-white/70 dark:hover:bg-slate-950/80 dark:hover:text-white"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
dismissSwipeHint();
|
dismissSwipeHint();
|
||||||
onAdvance();
|
onAdvance();
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ export default function TaskPickerPage() {
|
|||||||
|
|
||||||
if (!response.ok) throw new Error('Aufgaben konnten nicht geladen werden.');
|
if (!response.ok) throw new Error('Aufgaben konnten nicht geladen werden.');
|
||||||
const payload = await response.json();
|
const payload = await response.json();
|
||||||
const taskList = Array.isArray(payload)
|
const taskList: Task[] = Array.isArray(payload)
|
||||||
? payload
|
? payload
|
||||||
: Array.isArray(payload?.data)
|
: Array.isArray(payload?.data)
|
||||||
? payload.data
|
? payload.data
|
||||||
|
|||||||
12
resources/js/guest/queue/__tests__/queueUrl.test.ts
Normal file
12
resources/js/guest/queue/__tests__/queueUrl.test.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { buildQueueUploadUrl } from '../queue';
|
||||||
|
|
||||||
|
describe('buildQueueUploadUrl', () => {
|
||||||
|
it('builds the guest upload endpoint', () => {
|
||||||
|
expect(buildQueueUploadUrl('demo-token')).toBe('/api/v1/events/demo-token/upload');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('encodes event tokens safely', () => {
|
||||||
|
expect(buildQueueUploadUrl('token/with space')).toBe('/api/v1/events/token%2Fwith%20space/upload');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,6 +5,9 @@ import { createUpload } from './xhr';
|
|||||||
import { notify } from './notify';
|
import { notify } from './notify';
|
||||||
type SyncManager = { register(tag: string): Promise<void>; };
|
type SyncManager = { register(tag: string): Promise<void>; };
|
||||||
|
|
||||||
|
export const buildQueueUploadUrl = (eventToken: string) =>
|
||||||
|
`/api/v1/events/${encodeURIComponent(eventToken)}/upload`;
|
||||||
|
|
||||||
export type QueueItem = {
|
export type QueueItem = {
|
||||||
id?: number;
|
id?: number;
|
||||||
eventToken: string;
|
eventToken: string;
|
||||||
@@ -81,7 +84,7 @@ async function attemptUpload(it: QueueItem): Promise<boolean> {
|
|||||||
if (!navigator.onLine) return false;
|
if (!navigator.onLine) return false;
|
||||||
try {
|
try {
|
||||||
const json = await createUpload(
|
const json = await createUpload(
|
||||||
`/api/v1/events/${encodeURIComponent(it.eventToken)}/photos`,
|
buildQueueUploadUrl(it.eventToken),
|
||||||
it,
|
it,
|
||||||
getDeviceId(),
|
getDeviceId(),
|
||||||
(pct) => {
|
(pct) => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { QueueItem } from './queue';
|
import type { QueueItem } from './queue';
|
||||||
|
import { buildCsrfHeaders } from '../lib/csrf';
|
||||||
|
|
||||||
export async function createUpload(
|
export async function createUpload(
|
||||||
url: string,
|
url: string,
|
||||||
@@ -9,7 +10,10 @@ export async function createUpload(
|
|||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const xhr = new XMLHttpRequest();
|
const xhr = new XMLHttpRequest();
|
||||||
xhr.open('POST', url, true);
|
xhr.open('POST', url, true);
|
||||||
xhr.setRequestHeader('X-Device-Id', deviceId);
|
const headers = buildCsrfHeaders(deviceId);
|
||||||
|
Object.entries(headers).forEach(([key, value]) => {
|
||||||
|
xhr.setRequestHeader(key, value);
|
||||||
|
});
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append('photo', it.blob, it.fileName);
|
form.append('photo', it.blob, it.fileName);
|
||||||
if (it.emotion_id) form.append('emotion_id', String(it.emotion_id));
|
if (it.emotion_id) form.append('emotion_id', String(it.emotion_id));
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { getDeviceId } from '../lib/device';
|
import { getDeviceId } from '../lib/device';
|
||||||
|
import { buildCsrfHeaders } from '../lib/csrf';
|
||||||
|
|
||||||
export type UploadError = Error & {
|
export type UploadError = Error & {
|
||||||
code?: string;
|
code?: string;
|
||||||
@@ -7,51 +8,8 @@ export type UploadError = Error & {
|
|||||||
meta?: Record<string, unknown>;
|
meta?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function getCsrfToken(): string | null {
|
|
||||||
// Method 1: Meta tag (preferred for SPA)
|
|
||||||
const metaToken = document.querySelector('meta[name="csrf-token"]');
|
|
||||||
if (metaToken) {
|
|
||||||
return (metaToken as HTMLMetaElement).getAttribute('content') || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Method 2: XSRF-TOKEN cookie (Sanctum fallback)
|
|
||||||
const name = 'XSRF-TOKEN=';
|
|
||||||
const decodedCookie = decodeURIComponent(document.cookie);
|
|
||||||
const ca = decodedCookie.split(';');
|
|
||||||
for (let i = 0; i < ca.length; i++) {
|
|
||||||
const c = ca[i].trimStart();
|
|
||||||
if (c.startsWith(name)) {
|
|
||||||
const token = c.substring(name.length);
|
|
||||||
try {
|
|
||||||
// Decode base64 if needed
|
|
||||||
return decodeURIComponent(atob(token));
|
|
||||||
} catch {
|
|
||||||
return token;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.warn('No CSRF token found - API requests may fail');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCsrfHeaders(): Record<string, string> {
|
|
||||||
const token = getCsrfToken();
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
'X-Device-Id': getDeviceId(),
|
|
||||||
'Accept': 'application/json',
|
|
||||||
};
|
|
||||||
|
|
||||||
if (token) {
|
|
||||||
headers['X-CSRF-TOKEN'] = token;
|
|
||||||
headers['X-XSRF-TOKEN'] = token;
|
|
||||||
}
|
|
||||||
|
|
||||||
return headers;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function likePhoto(id: number): Promise<number> {
|
export async function likePhoto(id: number): Promise<number> {
|
||||||
const headers = getCsrfHeaders();
|
const headers = buildCsrfHeaders();
|
||||||
|
|
||||||
const res = await fetch(`/api/v1/photos/${id}/like`, {
|
const res = await fetch(`/api/v1/photos/${id}/like`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -122,7 +80,7 @@ export async function uploadPhoto(
|
|||||||
|
|
||||||
const maxRetries = options.maxRetries ?? 2;
|
const maxRetries = options.maxRetries ?? 2;
|
||||||
const url = `/api/v1/events/${encodeURIComponent(eventToken)}/upload`;
|
const url = `/api/v1/events/${encodeURIComponent(eventToken)}/upload`;
|
||||||
const headers = getCsrfHeaders();
|
const headers = buildCsrfHeaders();
|
||||||
|
|
||||||
const attemptUpload = (): Promise<Record<string, unknown>> =>
|
const attemptUpload = (): Promise<Record<string, unknown>> =>
|
||||||
new Promise((resolve, reject) => {
|
new Promise((resolve, reject) => {
|
||||||
|
|||||||
@@ -252,7 +252,7 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div className="md:col-span-1">
|
<div className="md:col-span-1">
|
||||||
<label htmlFor="first_name" className="block text-sm font-medium text-gray-700 mb-1">
|
<label htmlFor="first_name" className="block text-sm font-medium text-gray-700 dark:text-gray-100 mb-1">
|
||||||
{t('register.first_name')} {t('common:required')}
|
{t('register.first_name')} {t('common:required')}
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -269,7 +269,7 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale
|
|||||||
clearErrors('first_name');
|
clearErrors('first_name');
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={`block w-full pl-10 pr-3 py-3 border rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#FFB6C1] focus:border-[#FFB6C1] sm:text-sm ${errors.first_name ? 'border-red-500' : 'border-gray-300'}`}
|
className={`block w-full pl-10 pr-3 py-3 border rounded-md shadow-sm placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-[#FFB6C1] focus:border-[#FFB6C1] sm:text-sm dark:bg-gray-900 dark:text-gray-100 ${errors.first_name ? 'border-red-500' : 'border-gray-300 dark:border-gray-700'}`}
|
||||||
placeholder={t('register.first_name_placeholder')}
|
placeholder={t('register.first_name_placeholder')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -277,7 +277,7 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="md:col-span-1">
|
<div className="md:col-span-1">
|
||||||
<label htmlFor="last_name" className="block text-sm font-medium text-gray-700 mb-1">
|
<label htmlFor="last_name" className="block text-sm font-medium text-gray-700 dark:text-gray-100 mb-1">
|
||||||
{t('register.last_name')} {t('common:required')}
|
{t('register.last_name')} {t('common:required')}
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -294,7 +294,7 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale
|
|||||||
clearErrors('last_name');
|
clearErrors('last_name');
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={`block w-full pl-10 pr-3 py-3 border rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#FFB6C1] focus:border-[#FFB6C1] sm:text-sm ${errors.last_name ? 'border-red-500' : 'border-gray-300'}`}
|
className={`block w-full pl-10 pr-3 py-3 border rounded-md shadow-sm placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-[#FFB6C1] focus:border-[#FFB6C1] sm:text-sm dark:bg-gray-900 dark:text-gray-100 ${errors.last_name ? 'border-red-500' : 'border-gray-300 dark:border-gray-700'}`}
|
||||||
placeholder={t('register.last_name_placeholder')}
|
placeholder={t('register.last_name_placeholder')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -302,7 +302,7 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
|
<label htmlFor="email" className="block text-sm font-medium text-gray-700 dark:text-gray-100 mb-1">
|
||||||
{t('register.email')} {t('common:required')}
|
{t('register.email')} {t('common:required')}
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -319,7 +319,7 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale
|
|||||||
clearErrors('email');
|
clearErrors('email');
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={`block w-full pl-10 pr-3 py-3 border rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#FFB6C1] focus:border-[#FFB6C1] sm:text-sm ${errors.email ? 'border-red-500' : 'border-gray-300'}`}
|
className={`block w-full pl-10 pr-3 py-3 border rounded-md shadow-sm placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-[#FFB6C1] focus:border-[#FFB6C1] sm:text-sm dark:bg-gray-900 dark:text-gray-100 ${errors.email ? 'border-red-500' : 'border-gray-300 dark:border-gray-700'}`}
|
||||||
placeholder={t('register.email_placeholder')}
|
placeholder={t('register.email_placeholder')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -327,7 +327,7 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="md:col-span-1">
|
<div className="md:col-span-1">
|
||||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
|
<label htmlFor="password" className="block text-sm font-medium text-gray-700 dark:text-gray-100 mb-1">
|
||||||
{t('register.password')} {t('common:required')}
|
{t('register.password')} {t('common:required')}
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -347,7 +347,7 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale
|
|||||||
clearErrors('password_confirmation');
|
clearErrors('password_confirmation');
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={`block w-full pl-10 pr-3 py-3 border rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#FFB6C1] focus:border-[#FFB6C1] sm:text-sm ${errors.password ? 'border-red-500' : 'border-gray-300'}`}
|
className={`block w-full pl-10 pr-3 py-3 border rounded-md shadow-sm placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-[#FFB6C1] focus:border-[#FFB6C1] sm:text-sm dark:bg-gray-900 dark:text-gray-100 ${errors.password ? 'border-red-500' : 'border-gray-300 dark:border-gray-700'}`}
|
||||||
placeholder={t('register.password_placeholder')}
|
placeholder={t('register.password_placeholder')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -355,7 +355,7 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="md:col-span-1">
|
<div className="md:col-span-1">
|
||||||
<label htmlFor="password_confirmation" className="block text-sm font-medium text-gray-700 mb-1">
|
<label htmlFor="password_confirmation" className="block text-sm font-medium text-gray-700 dark:text-gray-100 mb-1">
|
||||||
{t('register.password_confirmation')} {t('common:required')}
|
{t('register.password_confirmation')} {t('common:required')}
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -375,7 +375,7 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale
|
|||||||
clearErrors('password_confirmation');
|
clearErrors('password_confirmation');
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={`block w-full pl-10 pr-3 py-3 border rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#FFB6C1] focus:border-[#FFB6C1] sm:text-sm ${errors.password_confirmation ? 'border-red-500' : 'border-gray-300'}`}
|
className={`block w-full pl-10 pr-3 py-3 border rounded-md shadow-sm placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-[#FFB6C1] focus:border-[#FFB6C1] sm:text-sm dark:bg-gray-900 dark:text-gray-100 ${errors.password_confirmation ? 'border-red-500' : 'border-gray-300 dark:border-gray-700'}`}
|
||||||
placeholder={t('register.password_confirmation_placeholder')}
|
placeholder={t('register.password_confirmation_placeholder')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -399,14 +399,14 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale
|
|||||||
clearErrors('terms');
|
clearErrors('terms');
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="h-4 w-4 text-[#FFB6C1] focus:ring-[#FFB6C1] border-gray-300 rounded"
|
className="h-4 w-4 text-[#FFB6C1] focus:ring-[#FFB6C1] border-gray-300 dark:border-gray-600 rounded"
|
||||||
/>
|
/>
|
||||||
<label htmlFor="privacy_consent" className="ml-2 block text-sm text-gray-900">
|
<label htmlFor="privacy_consent" className="ml-2 block text-sm text-gray-900 dark:text-gray-100">
|
||||||
{t('register.privacy_consent')}{' '}
|
{t('register.privacy_consent')}{' '}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setPrivacyOpen(true)}
|
onClick={() => setPrivacyOpen(true)}
|
||||||
className="text-[#FFB6C1] hover:underline inline bg-transparent border-none cursor-pointer p-0 font-medium"
|
className="text-[#FFB6C1] hover:underline inline bg-transparent border-none cursor-pointer p-0 font-medium dark:text-pink-300"
|
||||||
>
|
>
|
||||||
{t('register.privacy_policy_link')}
|
{t('register.privacy_policy_link')}
|
||||||
</button>.
|
</button>.
|
||||||
|
|||||||
@@ -1,373 +0,0 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import { useForm } from '@inertiajs/react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { LoaderCircle, User, Mail, Phone, Lock, MapPin } from 'lucide-react';
|
|
||||||
import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
|
||||||
|
|
||||||
interface RegisterProps {
|
|
||||||
package?: {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
price: number;
|
|
||||||
} | null;
|
|
||||||
privacyHtml?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
import MarketingLayout from '@/layouts/mainWebsite';
|
|
||||||
|
|
||||||
export default function Register({ package: initialPackage, privacyHtml }: RegisterProps) {
|
|
||||||
const [privacyOpen, setPrivacyOpen] = useState(false);
|
|
||||||
const [hasTriedSubmit, setHasTriedSubmit] = useState(false);
|
|
||||||
const { t } = useTranslation(['auth', 'common']);
|
|
||||||
|
|
||||||
const { data, setData, post, processing, errors, clearErrors } = useForm({
|
|
||||||
username: '',
|
|
||||||
email: '',
|
|
||||||
password: '',
|
|
||||||
password_confirmation: '',
|
|
||||||
first_name: '',
|
|
||||||
last_name: '',
|
|
||||||
address: '',
|
|
||||||
phone: '',
|
|
||||||
privacy_consent: false,
|
|
||||||
package_id: initialPackage?.id || null,
|
|
||||||
});
|
|
||||||
|
|
||||||
const submit = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setHasTriedSubmit(true);
|
|
||||||
post('/register', {
|
|
||||||
preserveScroll: true,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!hasTriedSubmit) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const errorKeys = Object.keys(errors);
|
|
||||||
if (errorKeys.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const firstError = errorKeys[0];
|
|
||||||
const field = document.querySelector<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>(`[name="${firstError}"]`);
|
|
||||||
|
|
||||||
if (field) {
|
|
||||||
field.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
||||||
field.focus();
|
|
||||||
}
|
|
||||||
}, [errors, hasTriedSubmit]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MarketingLayout title={t('register.title')}>
|
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
|
||||||
<div className="max-w-4xl w-full space-y-8">
|
|
||||||
<div className="bg-white rounded-lg shadow-md p-8">
|
|
||||||
<div>
|
|
||||||
<h2 className="mt-6 text-center text-3xl font-bold text-gray-900 font-display">
|
|
||||||
{t('register.welcome')}
|
|
||||||
</h2>
|
|
||||||
<p className="mt-4 text-center text-gray-600 font-sans-marketing">
|
|
||||||
{t('register.description')}
|
|
||||||
</p>
|
|
||||||
{initialPackage && (
|
|
||||||
<div className="mt-4 p-4 bg-blue-50 border border-blue-200 rounded-md">
|
|
||||||
<h3 className="text-lg font-semibold text-blue-900 mb-2">{initialPackage.name}</h3>
|
|
||||||
<p className="text-blue-800 mb-2">{initialPackage.description}</p>
|
|
||||||
<p className="text-sm text-blue-700">
|
|
||||||
{initialPackage.price === 0 ? t('register.package_price_free') : t('register.package_price', { price: initialPackage.price })}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<form onSubmit={submit} className="mt-8 space-y-6">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
<div className="md:col-span-1">
|
|
||||||
<label htmlFor="first_name" className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
{t('register.first_name')} {t('common:required')}
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-5 h-5" />
|
|
||||||
<input
|
|
||||||
id="first_name"
|
|
||||||
name="first_name"
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
value={data.first_name}
|
|
||||||
onChange={(e) => {
|
|
||||||
setData('first_name', e.target.value);
|
|
||||||
if (e.target.value.trim() && errors.first_name) {
|
|
||||||
clearErrors('first_name');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={`block w-full pl-10 pr-3 py-3 border rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#FFB6C1] focus:border-[#FFB6C1] sm:text-sm ${errors.first_name ? 'border-red-500' : 'border-gray-300'}`}
|
|
||||||
placeholder={t('register.first_name_placeholder')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{errors.first_name && <p key={`error-first_name`} className="text-sm text-red-600 mt-1">{errors.first_name}</p>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="md:col-span-1">
|
|
||||||
<label htmlFor="last_name" className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
{t('register.last_name')} {t('common:required')}
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-5 h-5" />
|
|
||||||
<input
|
|
||||||
id="last_name"
|
|
||||||
name="last_name"
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
value={data.last_name}
|
|
||||||
onChange={(e) => {
|
|
||||||
setData('last_name', e.target.value);
|
|
||||||
if (e.target.value.trim() && errors.last_name) {
|
|
||||||
clearErrors('last_name');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={`block w-full pl-10 pr-3 py-3 border rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#FFB6C1] focus:border-[#FFB6C1] sm:text-sm ${errors.last_name ? 'border-red-500' : 'border-gray-300'}`}
|
|
||||||
placeholder={t('register.last_name_placeholder')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{errors.last_name && <p key={`error-last_name`} className="text-sm text-red-600 mt-1">{errors.last_name}</p>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="md:col-span-2">
|
|
||||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
{t('register.email')} {t('common:required')}
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-5 h-5" />
|
|
||||||
<input
|
|
||||||
id="email"
|
|
||||||
name="email"
|
|
||||||
type="email"
|
|
||||||
required
|
|
||||||
value={data.email}
|
|
||||||
onChange={(e) => {
|
|
||||||
setData('email', e.target.value);
|
|
||||||
if (e.target.value.trim() && errors.email) {
|
|
||||||
clearErrors('email');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={`block w-full pl-10 pr-3 py-3 border rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#FFB6C1] focus:border-[#FFB6C1] sm:text-sm ${errors.email ? 'border-red-500' : 'border-gray-300'}`}
|
|
||||||
placeholder={t('register.email_placeholder')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{errors.email && <p key={`error-email`} className="text-sm text-red-600 mt-1">{errors.email}</p>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="md:col-span-2">
|
|
||||||
<label htmlFor="address" className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
{t('register.address')} {t('common:required')}
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<MapPin className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-5 h-5" />
|
|
||||||
<input
|
|
||||||
id="address"
|
|
||||||
name="address"
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
value={data.address}
|
|
||||||
onChange={(e) => {
|
|
||||||
setData('address', e.target.value);
|
|
||||||
if (e.target.value.trim() && errors.address) {
|
|
||||||
clearErrors('address');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={`block w-full pl-10 pr-3 py-3 border rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#FFB6C1] focus:border-[#FFB6C1] sm:text-sm ${errors.address ? 'border-red-500' : 'border-gray-300'}`}
|
|
||||||
placeholder={t('register.address_placeholder')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{errors.address && <p key={`error-address`} className="text-sm text-red-600 mt-1">{errors.address}</p>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="md:col-span-1">
|
|
||||||
<label htmlFor="phone" className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
{t('register.phone')} {t('common:required')}
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-5 h-5" />
|
|
||||||
<input
|
|
||||||
id="phone"
|
|
||||||
name="phone"
|
|
||||||
type="tel"
|
|
||||||
required
|
|
||||||
value={data.phone}
|
|
||||||
onChange={(e) => {
|
|
||||||
setData('phone', e.target.value);
|
|
||||||
if (e.target.value.trim() && errors.phone) {
|
|
||||||
clearErrors('phone');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={`block w-full pl-10 pr-3 py-3 border rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#FFB6C1] focus:border-[#FFB6C1] sm:text-sm ${errors.phone ? 'border-red-500' : 'border-gray-300'}`}
|
|
||||||
placeholder={t('register.phone_placeholder')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{errors.phone && <p key={`error-phone`} className="text-sm text-red-600 mt-1">{errors.phone}</p>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="md:col-span-1">
|
|
||||||
<label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
{t('register.username')} {t('common:required')}
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-5 h-5" />
|
|
||||||
<input
|
|
||||||
id="username"
|
|
||||||
name="username"
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
value={data.username}
|
|
||||||
onChange={(e) => {
|
|
||||||
setData('username', e.target.value);
|
|
||||||
if (e.target.value.trim() && errors.username) {
|
|
||||||
clearErrors('username');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={`block w-full pl-10 pr-3 py-3 border rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#FFB6C1] focus:border-[#FFB6C1] sm:text-sm ${errors.username ? 'border-red-500' : 'border-gray-300'}`}
|
|
||||||
placeholder={t('register.username_placeholder')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{errors.username && <p key={`error-username`} className="text-sm text-red-600 mt-1">{errors.username}</p>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="md:col-span-1">
|
|
||||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
{t('register.password')} {t('common:required')}
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-5 h-5" />
|
|
||||||
<input
|
|
||||||
id="password"
|
|
||||||
name="password"
|
|
||||||
type="password"
|
|
||||||
required
|
|
||||||
value={data.password}
|
|
||||||
onChange={(e) => {
|
|
||||||
setData('password', e.target.value);
|
|
||||||
if (e.target.value.trim() && errors.password) {
|
|
||||||
clearErrors('password');
|
|
||||||
}
|
|
||||||
if (data.password_confirmation && e.target.value === data.password_confirmation) {
|
|
||||||
clearErrors('password_confirmation');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={`block w-full pl-10 pr-3 py-3 border rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#FFB6C1] focus:border-[#FFB6C1] sm:text-sm ${errors.password ? 'border-red-500' : 'border-gray-300'}`}
|
|
||||||
placeholder={t('register.password_placeholder')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{errors.password && <p key={`error-password`} className="text-sm text-red-600 mt-1">{errors.password}</p>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="md:col-span-1">
|
|
||||||
<label htmlFor="password_confirmation" className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
{t('register.password_confirmation')} {t('common:required')}
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-5 h-5" />
|
|
||||||
<input
|
|
||||||
id="password_confirmation"
|
|
||||||
name="password_confirmation"
|
|
||||||
type="password"
|
|
||||||
required
|
|
||||||
value={data.password_confirmation}
|
|
||||||
onChange={(e) => {
|
|
||||||
setData('password_confirmation', e.target.value);
|
|
||||||
if (e.target.value.trim() && errors.password_confirmation) {
|
|
||||||
clearErrors('password_confirmation');
|
|
||||||
}
|
|
||||||
if (data.password && e.target.value === data.password) {
|
|
||||||
clearErrors('password_confirmation');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={`block w-full pl-10 pr-3 py-3 border rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#FFB6C1] focus:border-[#FFB6C1] sm:text-sm ${errors.password_confirmation ? 'border-red-500' : 'border-gray-300'}`}
|
|
||||||
placeholder={t('register.password_confirmation_placeholder')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{errors.password_confirmation && <p key={`error-password_confirmation`} className="text-sm text-red-600 mt-1">{errors.password_confirmation}</p>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="md:col-span-2 flex items-start">
|
|
||||||
<input
|
|
||||||
id="privacy_consent"
|
|
||||||
name="privacy_consent"
|
|
||||||
type="checkbox"
|
|
||||||
required
|
|
||||||
checked={data.privacy_consent}
|
|
||||||
onChange={(e) => {
|
|
||||||
setData('privacy_consent', e.target.checked);
|
|
||||||
if (e.target.checked && errors.privacy_consent) {
|
|
||||||
clearErrors('privacy_consent');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="h-4 w-4 text-[#FFB6C1] focus:ring-[#FFB6C1] border-gray-300 rounded"
|
|
||||||
/>
|
|
||||||
<label htmlFor="privacy_consent" className="ml-2 block text-sm text-gray-900">
|
|
||||||
{t('register.privacy_consent')}{' '}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setPrivacyOpen(true)}
|
|
||||||
className="text-[#FFB6C1] hover:underline inline bg-transparent border-none cursor-pointer p-0 font-medium"
|
|
||||||
>
|
|
||||||
{t('register.privacy_policy_link')}
|
|
||||||
</button>.
|
|
||||||
</label>
|
|
||||||
{errors.privacy_consent && <p className="mt-2 text-sm text-red-600">{errors.privacy_consent}</p>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{Object.keys(errors).length > 0 && (
|
|
||||||
<div key={`general-errors-${Object.keys(errors).join('-')}`} className="p-4 bg-red-50 border border-red-200 rounded-md mb-6">
|
|
||||||
<h4 className="text-sm font-medium text-red-800 mb-2">{t('register.errors_title')}</h4>
|
|
||||||
<ul className="text-sm text-red-800 space-y-1">
|
|
||||||
{Object.entries(errors).map(([key, value]) => (
|
|
||||||
<li key={key} className="flex items-start">
|
|
||||||
<span className="font-medium">{t(`register.errors.${key}`)}:</span> {value}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={processing}
|
|
||||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-[#FFB6C1] hover:bg-[#FF69B4] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#FFB6C1] transition duration-300 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{processing && <LoaderCircle className="h-4 w-4 animate-spin mr-2" />}
|
|
||||||
{t('register.submit')}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-sm text-gray-600">
|
|
||||||
{t('register.has_account')}{' '}
|
|
||||||
<a href="/login" className="font-medium text-[#FFB6C1] hover:text-[#FF69B4]">
|
|
||||||
{t('register.login')}
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Dialog open={privacyOpen} onOpenChange={setPrivacyOpen}>
|
|
||||||
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto p-0">
|
|
||||||
<DialogTitle className="sr-only">Datenschutzerklärung</DialogTitle>
|
|
||||||
<DialogDescription className="sr-only">Lesen Sie unsere Datenschutzerklärung.</DialogDescription>
|
|
||||||
<div className="p-6">
|
|
||||||
{privacyHtml ? (
|
|
||||||
<div dangerouslySetInnerHTML={{ __html: privacyHtml }} />
|
|
||||||
) : (
|
|
||||||
<p>Datenschutzerklärung wird geladen...</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
</MarketingLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -664,7 +664,7 @@ const resolveServiceTierLabel = (slug: string | null | undefined): string => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (slug === 'standard') {
|
if (slug === 'standard') {
|
||||||
return 'Standard';
|
return 'Classic';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (slug === 'pro') {
|
if (slug === 'pro') {
|
||||||
@@ -784,7 +784,7 @@ function PackageCard({
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
const displayFeatures = buildDisplayFeatures(pkg, variant);
|
const displayFeatures = buildDisplayFeatures(pkg, variant);
|
||||||
const visibleFeatures = compact ? displayFeatures.slice(0, 3) : displayFeatures.slice(0, 5);
|
const visibleFeatures = displayFeatures.slice(0, 5);
|
||||||
const metrics = resolvePackageMetrics(pkg, variant, t, tCommon);
|
const metrics = resolvePackageMetrics(pkg, variant, t, tCommon);
|
||||||
|
|
||||||
const metricList = (
|
const metricList = (
|
||||||
@@ -1200,18 +1200,6 @@ const PackageDetailGrid: React.FC<PackageDetailGridProps> = ({
|
|||||||
{t('packages.hero_description')}
|
{t('packages.hero_description')}
|
||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
<motion.div className="flex justify-center md:justify-end" variants={revealUp}>
|
|
||||||
<Link
|
|
||||||
href={localizedPath(locale === 'en' ? '/gift-card' : '/gutschein')}
|
|
||||||
className="group inline-flex items-center gap-3 rounded-full border border-white/50 bg-white/70 px-4 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-gray-700 shadow-sm backdrop-blur transition hover:bg-white/90 dark:border-gray-800 dark:bg-gray-900/70 dark:text-gray-100"
|
|
||||||
>
|
|
||||||
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-pink-500/15 text-pink-600 dark:text-pink-300">
|
|
||||||
<Gift className="h-4 w-4" aria-hidden />
|
|
||||||
</span>
|
|
||||||
<span>{t('packages.gift_cta')}</span>
|
|
||||||
<ArrowRight className="h-3.5 w-3.5 text-gray-400 transition group-hover:translate-x-1" aria-hidden />
|
|
||||||
</Link>
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -1238,26 +1226,24 @@ const PackageDetailGrid: React.FC<PackageDetailGridProps> = ({
|
|||||||
<TabsContent value="endcustomer" className="space-y-8 pt-8">
|
<TabsContent value="endcustomer" className="space-y-8 pt-8">
|
||||||
<div className="md:hidden">
|
<div className="md:hidden">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="pointer-events-none absolute inset-y-0 left-0 w-6 bg-gradient-to-r from-white to-transparent dark:from-gray-950" />
|
<div className="pointer-events-none absolute inset-y-0 left-0 w-4 bg-gradient-to-r from-white/70 to-transparent dark:from-gray-950/70" />
|
||||||
<div className="pointer-events-none absolute inset-y-0 right-0 w-6 bg-gradient-to-l from-white to-transparent dark:from-gray-950" />
|
<div className="pointer-events-none absolute inset-y-0 right-0 w-4 bg-gradient-to-l from-white/70 to-transparent dark:from-gray-950/70" />
|
||||||
<div
|
<div
|
||||||
ref={mobileEndcustomerRef}
|
ref={mobileEndcustomerRef}
|
||||||
className="flex snap-x snap-mandatory gap-4 overflow-x-auto pb-6"
|
className="flex snap-x snap-mandatory gap-4 overflow-x-auto pb-6"
|
||||||
style={{ scrollPaddingLeft: '16px', scrollBehavior: 'smooth' }}
|
style={{ scrollPaddingLeft: '16px', scrollPaddingRight: '16px', scrollBehavior: 'smooth' }}
|
||||||
>
|
>
|
||||||
{orderedEndcustomerPackages.map((pkg) => (
|
{orderedEndcustomerPackages.map((pkg) => (
|
||||||
<div key={pkg.id} className="snap-start basis-[72vw] shrink-0 sm:basis-[60vw]">
|
<div key={pkg.id} className="snap-center basis-[64vw] shrink-0 sm:basis-[56vw]">
|
||||||
<motion.div variants={revealUp} initial="hidden" whileInView="visible" viewport={viewportOnce}>
|
<PackageCard
|
||||||
<PackageCard
|
pkg={pkg}
|
||||||
pkg={pkg}
|
variant="endcustomer"
|
||||||
variant="endcustomer"
|
highlight={pkg.id === highlightEndcustomerId}
|
||||||
highlight={pkg.id === highlightEndcustomerId}
|
onSelect={(selected) => handleCardClick(selected, 'endcustomer')}
|
||||||
onSelect={(selected) => handleCardClick(selected, 'endcustomer')}
|
onCtaClick={handleCtaClick}
|
||||||
onCtaClick={handleCtaClick}
|
className="h-full"
|
||||||
className="h-full"
|
compact
|
||||||
compact
|
/>
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -1319,26 +1305,24 @@ const PackageDetailGrid: React.FC<PackageDetailGridProps> = ({
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
<div className="md:hidden">
|
<div className="md:hidden">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="pointer-events-none absolute inset-y-0 left-0 w-6 bg-gradient-to-r from-white to-transparent dark:from-gray-950" />
|
<div className="pointer-events-none absolute inset-y-0 left-0 w-4 bg-gradient-to-r from-white/70 to-transparent dark:from-gray-950/70" />
|
||||||
<div className="pointer-events-none absolute inset-y-0 right-0 w-6 bg-gradient-to-l from-white to-transparent dark:from-gray-950" />
|
<div className="pointer-events-none absolute inset-y-0 right-0 w-4 bg-gradient-to-l from-white/70 to-transparent dark:from-gray-950/70" />
|
||||||
<div
|
<div
|
||||||
ref={mobileResellerRef}
|
ref={mobileResellerRef}
|
||||||
className="flex snap-x snap-mandatory gap-4 overflow-x-auto pb-6"
|
className="flex snap-x snap-mandatory gap-4 overflow-x-auto pb-6"
|
||||||
style={{ scrollPaddingLeft: '16px', scrollBehavior: 'smooth' }}
|
style={{ scrollPaddingLeft: '16px', scrollPaddingRight: '16px', scrollBehavior: 'smooth' }}
|
||||||
>
|
>
|
||||||
{orderedResellerPackages.map((pkg) => (
|
{orderedResellerPackages.map((pkg) => (
|
||||||
<div key={pkg.id} className="snap-start basis-[72vw] shrink-0 sm:basis-[60vw]">
|
<div key={pkg.id} className="snap-center basis-[64vw] shrink-0 sm:basis-[56vw]">
|
||||||
<motion.div variants={revealUp} initial="hidden" whileInView="visible" viewport={viewportOnce}>
|
<PackageCard
|
||||||
<PackageCard
|
pkg={pkg}
|
||||||
pkg={pkg}
|
variant="reseller"
|
||||||
variant="reseller"
|
highlight={pkg.id === highlightResellerId}
|
||||||
highlight={pkg.id === highlightResellerId}
|
onSelect={(selected) => handleCardClick(selected, 'reseller')}
|
||||||
onSelect={(selected) => handleCardClick(selected, 'reseller')}
|
onCtaClick={handleCtaClick}
|
||||||
onCtaClick={handleCtaClick}
|
className="h-full"
|
||||||
className="h-full"
|
compact
|
||||||
compact
|
/>
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -1386,6 +1370,19 @@ const PackageDetailGrid: React.FC<PackageDetailGridProps> = ({
|
|||||||
<PackageComparison packages={orderedResellerPackages} variant="reseller" serviceTierNames={serviceTierNames} />
|
<PackageComparison packages={orderedResellerPackages} variant="reseller" serviceTierNames={serviceTierNames} />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<div className="flex justify-center pt-2 md:pt-6">
|
||||||
|
<Link
|
||||||
|
href={localizedPath(locale === 'en' ? '/gift-card' : '/gutschein')}
|
||||||
|
className="group inline-flex items-center gap-3 rounded-full border border-gray-200 bg-white px-4 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-gray-700 shadow-sm transition hover:bg-gray-50 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-100 dark:hover:bg-gray-900/80"
|
||||||
|
>
|
||||||
|
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-pink-500/15 text-pink-600 dark:text-pink-300">
|
||||||
|
<Gift className="h-4 w-4" aria-hidden />
|
||||||
|
</span>
|
||||||
|
<span>{t('packages.gift_cta')}</span>
|
||||||
|
<ArrowRight className="h-3.5 w-3.5 text-gray-400 transition group-hover:translate-x-1" aria-hidden />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ describe('Home', () => {
|
|||||||
<Home
|
<Home
|
||||||
packages={[
|
packages={[
|
||||||
{ id: 1, name: 'Starter', description: 'Desc', price: 29 },
|
{ id: 1, name: 'Starter', description: 'Desc', price: 29 },
|
||||||
{ id: 2, name: 'Standard', description: 'Desc', price: 59 },
|
{ id: 2, name: 'Classic', description: 'Desc', price: 59 },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ describe('Packages', () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
name: 'Standard',
|
name: 'Classic',
|
||||||
slug: 'standard',
|
slug: 'standard',
|
||||||
description: 'Desc',
|
description: 'Desc',
|
||||||
description_breakdown: [],
|
description_breakdown: [],
|
||||||
|
|||||||
70
resources/js/types/shims.d.ts
vendored
70
resources/js/types/shims.d.ts
vendored
@@ -15,15 +15,85 @@ declare module '@tamagui/button' {
|
|||||||
export { Button };
|
export { Button };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare module '@tamagui/card' {
|
||||||
|
export const Card: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@tamagui/group' {
|
||||||
|
export const XGroup: any;
|
||||||
|
export const YGroup: any;
|
||||||
|
}
|
||||||
|
|
||||||
declare module '@tamagui/list-item' {
|
declare module '@tamagui/list-item' {
|
||||||
export const ListItem: any;
|
export const ListItem: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare module '@tamagui/checkbox' {
|
||||||
|
export const Checkbox: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@tamagui/switch' {
|
||||||
|
export const Switch: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@tamagui/accordion' {
|
||||||
|
export const Accordion: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@tamagui/scroll-view' {
|
||||||
|
export const ScrollView: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@tamagui/toggle-group' {
|
||||||
|
export const ToggleGroup: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@tamagui/alert-dialog' {
|
||||||
|
export const AlertDialog: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@tamagui/select' {
|
||||||
|
export const Select: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@tamagui/sheet' {
|
||||||
|
export const Sheet: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@tamagui/avatar' {
|
||||||
|
export const Avatar: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@tamagui/radio-group' {
|
||||||
|
export const RadioGroup: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@tamagui/progress' {
|
||||||
|
export const Progress: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@tamagui/slider' {
|
||||||
|
export const Slider: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@tamagui/image' {
|
||||||
|
export const Image: any;
|
||||||
|
}
|
||||||
|
|
||||||
declare module '@tamagui/react-native-web-lite' {
|
declare module '@tamagui/react-native-web-lite' {
|
||||||
export const Pressable: any;
|
export const Pressable: any;
|
||||||
export * from 'react-native';
|
export * from 'react-native';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare module 'tamagui' {
|
||||||
|
export const Tabs: any;
|
||||||
|
export const Separator: any;
|
||||||
|
export const Slider: any;
|
||||||
|
export const Input: any;
|
||||||
|
export const TextArea: any;
|
||||||
|
export const Spinner: any;
|
||||||
|
}
|
||||||
|
|
||||||
declare module '@/actions/*' {
|
declare module '@/actions/*' {
|
||||||
const mod: any;
|
const mod: any;
|
||||||
export = mod;
|
export = mod;
|
||||||
|
|||||||
@@ -44,12 +44,12 @@
|
|||||||
"packages": {
|
"packages": {
|
||||||
"title": "Unsere Packages",
|
"title": "Unsere Packages",
|
||||||
"hero_kicker": "Pakete, die mit eurem Event mitwachsen",
|
"hero_kicker": "Pakete, die mit eurem Event mitwachsen",
|
||||||
"hero_title": "Entdecken Sie unsere flexiblen Packages",
|
"hero_title": "Entdecken Sie unsere flexiblen Event-Pakete",
|
||||||
"hero_description": "Von kostenlosem Einstieg bis Premium-Features: Passen Sie Ihr Event-Paket an Ihre Bedürfnisse an. Einfach, sicher und skalierbar.",
|
"hero_description": "Von kostenlosem Einstieg bis Premium-Features: Passen Sie Ihr Event-Paket an Ihre Bedürfnisse an. Einfach, sicher und skalierbar.",
|
||||||
"cta_explore": "Pakete entdecken",
|
"cta_explore": "Pakete entdecken",
|
||||||
"gift_cta": "Paket verschenken",
|
"gift_cta": "Paket verschenken",
|
||||||
"tab_endcustomer": "Einzel-Events",
|
"tab_endcustomer": "Einzel-Events",
|
||||||
"tab_reseller": "Partner / Agentur",
|
"tab_reseller": "mehrere Events",
|
||||||
"section_endcustomer": "Packages für Endkunden (Einmalkauf pro Event)",
|
"section_endcustomer": "Packages für Endkunden (Einmalkauf pro Event)",
|
||||||
"section_reseller": "Packages für Partner / Agenturen (Event-Kontingent)",
|
"section_reseller": "Packages für Partner / Agenturen (Event-Kontingent)",
|
||||||
"bundles_title": "Partner & Agentur Bundles",
|
"bundles_title": "Partner & Agentur Bundles",
|
||||||
@@ -100,7 +100,7 @@
|
|||||||
"feature_watermark_custom": "Eigenes Wasserzeichen",
|
"feature_watermark_custom": "Eigenes Wasserzeichen",
|
||||||
"feature_branding": "Branding",
|
"feature_branding": "Branding",
|
||||||
"feature_support": "Support",
|
"feature_support": "Support",
|
||||||
"feature_basic_uploads": "Basis-Uploads",
|
"feature_basic_uploads": "Download aller Fotos",
|
||||||
"feature_unlimited_sharing": "Unbegrenztes Teilen",
|
"feature_unlimited_sharing": "Unbegrenztes Teilen",
|
||||||
"feature_no_watermark": "Fotospiel-Wasserzeichen entfernen",
|
"feature_no_watermark": "Fotospiel-Wasserzeichen entfernen",
|
||||||
"feature_custom_tasks": "Benutzerdefinierte Tasks",
|
"feature_custom_tasks": "Benutzerdefinierte Tasks",
|
||||||
@@ -164,7 +164,7 @@
|
|||||||
"standard": [
|
"standard": [
|
||||||
{
|
{
|
||||||
"name": "Lena & Jonas",
|
"name": "Lena & Jonas",
|
||||||
"text": "Standard fühlt sich wie das Allround-Paket an: genug Gäste und Fotos plus ein Jahr Galerie."
|
"text": "Classic fühlt sich wie das Allround-Paket an: genug Gäste und Fotos plus ein Jahr Galerie."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Marco P.",
|
"name": "Marco P.",
|
||||||
@@ -206,7 +206,7 @@
|
|||||||
"m-medium-reseller": [
|
"m-medium-reseller": [
|
||||||
{
|
{
|
||||||
"name": "Eventbüro Lenz",
|
"name": "Eventbüro Lenz",
|
||||||
"text": "15 Events decken unsere Saison meistens ab. Standard-Level ist für viele Kunden passend."
|
"text": "15 Events decken unsere Saison meistens ab. Classic-Level ist für viele Kunden passend."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Jasmin & Co.",
|
"name": "Jasmin & Co.",
|
||||||
@@ -256,7 +256,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Agentur Süd",
|
"name": "Agentur Süd",
|
||||||
"text": "Standard-Level ist ein guter Mittelweg für verschiedene Event-Typen."
|
"text": "Classic-Level ist ein guter Mittelweg für verschiedene Event-Typen."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,11 @@
|
|||||||
return [
|
return [
|
||||||
'packages' => [
|
'packages' => [
|
||||||
'title' => 'Unsere Packages – Wählen Sie Ihr Event-Paket',
|
'title' => 'Unsere Packages – Wählen Sie Ihr Event-Paket',
|
||||||
'hero_title' => 'Entdecken Sie unsere flexiblen Packages',
|
'hero_title' => 'Entdecken Sie unsere flexiblen Event-Pakete',
|
||||||
'hero_description' => 'Von kostenlosem Einstieg bis Premium-Features: Passen Sie Ihr Event-Paket an Ihre Bedürfnisse an. Einfach, sicher und skalierbar.',
|
'hero_description' => 'Von kostenlosem Einstieg bis Premium-Features: Passen Sie Ihr Event-Paket an Ihre Bedürfnisse an. Einfach, sicher und skalierbar.',
|
||||||
'cta_explore' => 'Packages entdecken',
|
'cta_explore' => 'Packages entdecken',
|
||||||
'tab_endcustomer' => 'Einzel-Events',
|
'tab_endcustomer' => 'Einzel-Events',
|
||||||
'tab_reseller' => 'Partner / Agenturen',
|
'tab_reseller' => 'mehrere Events',
|
||||||
'section_endcustomer' => 'Packages für Endkunden (Einmalkauf pro Event)',
|
'section_endcustomer' => 'Packages für Endkunden (Einmalkauf pro Event)',
|
||||||
'section_reseller' => 'Packages für Partner / Agenturen (Event-Kontingent)',
|
'section_reseller' => 'Packages für Partner / Agenturen (Event-Kontingent)',
|
||||||
'free' => 'Kostenlos',
|
'free' => 'Kostenlos',
|
||||||
@@ -40,7 +40,7 @@ return [
|
|||||||
'feature_watermark_custom' => 'Eigenes Wasserzeichen',
|
'feature_watermark_custom' => 'Eigenes Wasserzeichen',
|
||||||
'feature_branding' => 'Branding',
|
'feature_branding' => 'Branding',
|
||||||
'feature_support' => 'Support',
|
'feature_support' => 'Support',
|
||||||
'feature_basic_uploads' => 'Grundlegende Uploads',
|
'feature_basic_uploads' => 'Download aller Fotos',
|
||||||
'feature_unlimited_sharing' => 'Unbegrenztes Teilen',
|
'feature_unlimited_sharing' => 'Unbegrenztes Teilen',
|
||||||
'feature_no_watermark' => 'Fotospiel-Wasserzeichen entfernen',
|
'feature_no_watermark' => 'Fotospiel-Wasserzeichen entfernen',
|
||||||
'feature_custom_tasks' => 'Benutzerdefinierte Tasks',
|
'feature_custom_tasks' => 'Benutzerdefinierte Tasks',
|
||||||
@@ -85,9 +85,9 @@ return [
|
|||||||
'family' => 'Familienfeiern',
|
'family' => 'Familienfeiern',
|
||||||
],
|
],
|
||||||
'blog' => 'Blog',
|
'blog' => 'Blog',
|
||||||
'packages' => 'Packages',
|
'packages' => 'Pakete',
|
||||||
'contact' => 'Kontakt',
|
'contact' => 'Kontakt',
|
||||||
'discover_packages' => 'Packages entdecken',
|
'discover_packages' => 'Pakete entdecken',
|
||||||
'language' => 'Sprache',
|
'language' => 'Sprache',
|
||||||
'open_menu' => 'Menü öffnen',
|
'open_menu' => 'Menü öffnen',
|
||||||
'close_menu' => 'Menü schließen',
|
'close_menu' => 'Menü schließen',
|
||||||
|
|||||||
@@ -49,7 +49,7 @@
|
|||||||
"cta_explore": "Discover Packages",
|
"cta_explore": "Discover Packages",
|
||||||
"gift_cta": "Gift a package",
|
"gift_cta": "Gift a package",
|
||||||
"tab_endcustomer": "End Customers",
|
"tab_endcustomer": "End Customers",
|
||||||
"tab_reseller": "Partner / Agency",
|
"tab_reseller": "Bundles",
|
||||||
"section_endcustomer": "Packages for End Customers (One-time purchase per Event)",
|
"section_endcustomer": "Packages for End Customers (One-time purchase per Event)",
|
||||||
"section_reseller": "Packages for Partner / Agencies (Event-Bundle)",
|
"section_reseller": "Packages for Partner / Agencies (Event-Bundle)",
|
||||||
"bundles_title": "Partner & Agency Bundles",
|
"bundles_title": "Partner & Agency Bundles",
|
||||||
@@ -165,7 +165,7 @@
|
|||||||
"standard": [
|
"standard": [
|
||||||
{
|
{
|
||||||
"name": "Lena & Jonas",
|
"name": "Lena & Jonas",
|
||||||
"text": "Standard feels like the all-round package: plenty of guests and photos plus a full year of gallery."
|
"text": "Classic feels like the all-round package: plenty of guests and photos plus a full year of gallery."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Marco P.",
|
"name": "Marco P.",
|
||||||
@@ -207,7 +207,7 @@
|
|||||||
"m-medium-reseller": [
|
"m-medium-reseller": [
|
||||||
{
|
{
|
||||||
"name": "Event Bureau Lenz",
|
"name": "Event Bureau Lenz",
|
||||||
"text": "Fifteen events usually cover our season. Standard level fits most clients."
|
"text": "Fifteen events usually cover our season. Classic level fits most clients."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Jasmin & Co.",
|
"name": "Jasmin & Co.",
|
||||||
@@ -257,7 +257,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Agency South",
|
"name": "Agency South",
|
||||||
"text": "Standard level is a solid middle ground for varied event types."
|
"text": "Classic level is a solid middle ground for varied event types."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ return [
|
|||||||
'hero_description' => 'From free entry to premium features: Tailor your event package to your needs. Simple, secure and scalable.',
|
'hero_description' => 'From free entry to premium features: Tailor your event package to your needs. Simple, secure and scalable.',
|
||||||
'cta_explore' => 'Discover Packages',
|
'cta_explore' => 'Discover Packages',
|
||||||
'tab_endcustomer' => 'End Customers',
|
'tab_endcustomer' => 'End Customers',
|
||||||
'tab_reseller' => 'Partner / Agencies',
|
'tab_reseller' => 'Bundles',
|
||||||
'section_endcustomer' => 'Packages for End Customers (One-time purchase per Event)',
|
'section_endcustomer' => 'Packages for End Customers (One-time purchase per Event)',
|
||||||
'section_reseller' => 'Packages for Partner / Agencies (Event bundle)',
|
'section_reseller' => 'Packages for Partner / Agencies (Event bundle)',
|
||||||
'free' => 'Free',
|
'free' => 'Free',
|
||||||
|
|||||||
@@ -1,168 +0,0 @@
|
|||||||
@extends('layouts.marketing')
|
|
||||||
|
|
||||||
@section('title', __('auth.register'))
|
|
||||||
|
|
||||||
@section('content')
|
|
||||||
<div class="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
|
||||||
<div class="max-w-md w-full space-y-8">
|
|
||||||
<div>
|
|
||||||
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
|
||||||
{{ __('auth.register') }}
|
|
||||||
</h2>
|
|
||||||
@if($package ?? false)
|
|
||||||
<div class="mt-4 p-4 bg-blue-50 border border-blue-200 rounded-md">
|
|
||||||
<h3 class="text-lg font-semibold text-blue-900 mb-2">{{ $package->name }}</h3>
|
|
||||||
<p class="text-blue-800 mb-2">{{ $package->description }}</p>
|
|
||||||
<p class="text-sm text-blue-700">
|
|
||||||
{{ $package->price == 0 ? __('marketing.register.free') : $package->price . ' ' . __('currency.euro') }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
<form class="mt-8 space-y-6" action="{{ route('register.store') }}" method="POST">
|
|
||||||
@csrf
|
|
||||||
@if($package ?? false)
|
|
||||||
<input type="hidden" name="package_id" value="{{ $package->id }}">
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<!-- Username Field -->
|
|
||||||
<div>
|
|
||||||
<label for="username" class="block text-sm font-medium text-gray-700">
|
|
||||||
{{ __('auth.username') }}
|
|
||||||
</label>
|
|
||||||
<input id="username" name="username" type="text" required
|
|
||||||
value="{{ old('username') }}"
|
|
||||||
class="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm @error('username') border-red-500 @enderror"
|
|
||||||
placeholder="{{ __('auth.username_placeholder') }}">
|
|
||||||
@error('username')
|
|
||||||
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
|
|
||||||
@enderror
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Email Field -->
|
|
||||||
<div>
|
|
||||||
<label for="email" class="block text-sm font-medium text-gray-700">
|
|
||||||
{{ __('auth.email') }}
|
|
||||||
</label>
|
|
||||||
<input id="email" name="email" type="email" required
|
|
||||||
value="{{ old('email') }}"
|
|
||||||
class="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm @error('email') border-red-500 @enderror"
|
|
||||||
placeholder="{{ __('auth.email_placeholder') }}">
|
|
||||||
@error('email')
|
|
||||||
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
|
|
||||||
@enderror
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Password Field -->
|
|
||||||
<div>
|
|
||||||
<label for="password" class="block text-sm font-medium text-gray-700">
|
|
||||||
{{ __('auth.password') }}
|
|
||||||
</label>
|
|
||||||
<input id="password" name="password" type="password" required
|
|
||||||
class="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm @error('password') border-red-500 @enderror"
|
|
||||||
placeholder="{{ __('auth.password_placeholder') }}">
|
|
||||||
@error('password')
|
|
||||||
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
|
|
||||||
@enderror
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Confirm Password Field -->
|
|
||||||
<div>
|
|
||||||
<label for="password_confirmation" class="block text-sm font-medium text-gray-700">
|
|
||||||
{{ __('auth.confirm_password') }}
|
|
||||||
</label>
|
|
||||||
<input id="password_confirmation" name="password_confirmation" type="password" required
|
|
||||||
class="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
|
||||||
placeholder="{{ __('auth.confirm_password_placeholder') }}">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- First Name Field -->
|
|
||||||
<div>
|
|
||||||
<label for="first_name" class="block text-sm font-medium text-gray-700">
|
|
||||||
{{ __('profile.first_name') }}
|
|
||||||
</label>
|
|
||||||
<input id="first_name" name="first_name" type="text" required
|
|
||||||
value="{{ old('first_name') }}"
|
|
||||||
class="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm @error('first_name') border-red-500 @enderror"
|
|
||||||
placeholder="{{ __('profile.first_name_placeholder') }}">
|
|
||||||
@error('first_name')
|
|
||||||
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
|
|
||||||
@enderror
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Last Name Field -->
|
|
||||||
<div>
|
|
||||||
<label for="last_name" class="block text-sm font-medium text-gray-700">
|
|
||||||
{{ __('profile.last_name') }}
|
|
||||||
</label>
|
|
||||||
<input id="last_name" name="last_name" type="text" required
|
|
||||||
value="{{ old('last_name') }}"
|
|
||||||
class="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm @error('last_name') border-red-500 @enderror"
|
|
||||||
placeholder="{{ __('profile.last_name_placeholder') }}">
|
|
||||||
@error('last_name')
|
|
||||||
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
|
|
||||||
@enderror
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Address Field -->
|
|
||||||
<div>
|
|
||||||
<label for="address" class="block text-sm font-medium text-gray-700">
|
|
||||||
{{ __('profile.address') }}
|
|
||||||
</label>
|
|
||||||
<textarea id="address" name="address" required rows="3"
|
|
||||||
class="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm @error('address') border-red-500 @enderror">{{ old('address') }}</textarea>
|
|
||||||
@error('address')
|
|
||||||
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
|
|
||||||
@enderror
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Phone Field -->
|
|
||||||
<div>
|
|
||||||
<label for="phone" class="block text-sm font-medium text-gray-700">
|
|
||||||
{{ __('profile.phone') }}
|
|
||||||
</label>
|
|
||||||
<input id="phone" name="phone" type="tel" required
|
|
||||||
value="{{ old('phone') }}"
|
|
||||||
class="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm @error('phone') border-red-500 @enderror"
|
|
||||||
placeholder="{{ __('profile.phone_placeholder') }}">
|
|
||||||
@error('phone')
|
|
||||||
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
|
|
||||||
@enderror
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Privacy Consent -->
|
|
||||||
<div class="flex items-start">
|
|
||||||
<div class="flex items-center h-5">
|
|
||||||
<input id="privacy_consent" name="privacy_consent" type="checkbox" required
|
|
||||||
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded @error('privacy_consent') border-red-500 @enderror">
|
|
||||||
</div>
|
|
||||||
<div class="ml-3 text-sm">
|
|
||||||
<label for="privacy_consent" class="font-medium text-gray-700">
|
|
||||||
{{ __('auth.privacy_consent') }}
|
|
||||||
<a href="{{ route('datenschutz') }}" class="text-blue-600 hover:text-blue-500">{{ __('auth.privacy_policy') }}</a>.
|
|
||||||
</label>
|
|
||||||
@error('privacy_consent')
|
|
||||||
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
|
|
||||||
@enderror
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<button type="submit"
|
|
||||||
class="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition duration-300">
|
|
||||||
{{ __('auth.register') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="text-center">
|
|
||||||
<p class="text-sm text-gray-600">
|
|
||||||
{{ __('auth.have_account') }}
|
|
||||||
<a href="{{ route('login') }}" class="font-medium text-blue-600 hover:text-blue-500">
|
|
||||||
{{ __('auth.login') }}
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@endsection
|
|
||||||
@@ -322,12 +322,24 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
|||||||
Route::prefix('photos')->group(function () {
|
Route::prefix('photos')->group(function () {
|
||||||
Route::get('/', [PhotoController::class, 'index'])->name('tenant.events.photos.index');
|
Route::get('/', [PhotoController::class, 'index'])->name('tenant.events.photos.index');
|
||||||
Route::post('/', [PhotoController::class, 'store'])->name('tenant.events.photos.store');
|
Route::post('/', [PhotoController::class, 'store'])->name('tenant.events.photos.store');
|
||||||
Route::get('{photo}', [PhotoController::class, 'show'])->name('tenant.events.photos.show');
|
Route::get('{photo}', [PhotoController::class, 'show'])
|
||||||
Route::patch('{photo}', [PhotoController::class, 'update'])->name('tenant.events.photos.update');
|
->whereNumber('photo')
|
||||||
Route::delete('{photo}', [PhotoController::class, 'destroy'])->name('tenant.events.photos.destroy');
|
->name('tenant.events.photos.show');
|
||||||
Route::post('{photo}/feature', [PhotoController::class, 'feature'])->name('tenant.events.photos.feature');
|
Route::patch('{photo}', [PhotoController::class, 'update'])
|
||||||
Route::post('{photo}/unfeature', [PhotoController::class, 'unfeature'])->name('tenant.events.photos.unfeature');
|
->whereNumber('photo')
|
||||||
Route::post('{photo}/visibility', [PhotoController::class, 'visibility'])->name('tenant.events.photos.visibility');
|
->name('tenant.events.photos.update');
|
||||||
|
Route::delete('{photo}', [PhotoController::class, 'destroy'])
|
||||||
|
->whereNumber('photo')
|
||||||
|
->name('tenant.events.photos.destroy');
|
||||||
|
Route::post('{photo}/feature', [PhotoController::class, 'feature'])
|
||||||
|
->whereNumber('photo')
|
||||||
|
->name('tenant.events.photos.feature');
|
||||||
|
Route::post('{photo}/unfeature', [PhotoController::class, 'unfeature'])
|
||||||
|
->whereNumber('photo')
|
||||||
|
->name('tenant.events.photos.unfeature');
|
||||||
|
Route::post('{photo}/visibility', [PhotoController::class, 'visibility'])
|
||||||
|
->whereNumber('photo')
|
||||||
|
->name('tenant.events.photos.visibility');
|
||||||
Route::post('bulk-approve', [PhotoController::class, 'bulkApprove'])->name('tenant.events.photos.bulk-approve');
|
Route::post('bulk-approve', [PhotoController::class, 'bulkApprove'])->name('tenant.events.photos.bulk-approve');
|
||||||
Route::post('bulk-reject', [PhotoController::class, 'bulkReject'])->name('tenant.events.photos.bulk-reject');
|
Route::post('bulk-reject', [PhotoController::class, 'bulkReject'])->name('tenant.events.photos.bulk-reject');
|
||||||
Route::get('moderation', [PhotoController::class, 'forModeration'])->name('tenant.events.photos.for-moderation');
|
Route::get('moderation', [PhotoController::class, 'forModeration'])->name('tenant.events.photos.for-moderation');
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ class EventBrandingResponseTest extends TestCase
|
|||||||
$response->assertJsonPath('branding.mode', 'dark');
|
$response->assertJsonPath('branding.mode', 'dark');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_it_uses_tenant_branding_when_use_default_flag_is_enabled(): void
|
public function test_it_does_not_override_event_branding_with_tenant_defaults(): void
|
||||||
{
|
{
|
||||||
$package = Package::factory()->create([
|
$package = Package::factory()->create([
|
||||||
'branding_allowed' => true,
|
'branding_allowed' => true,
|
||||||
@@ -129,9 +129,8 @@ class EventBrandingResponseTest extends TestCase
|
|||||||
|
|
||||||
$response->assertOk();
|
$response->assertOk();
|
||||||
$response->assertJsonPath('branding.use_default_branding', true);
|
$response->assertJsonPath('branding.use_default_branding', true);
|
||||||
$response->assertJsonPath('branding.primary_color', '#abcdef');
|
$response->assertJsonPath('branding.primary_color', '#000000');
|
||||||
$response->assertJsonPath('branding.secondary_color', '#fedcba');
|
$response->assertJsonPath('branding.secondary_color', '#111111');
|
||||||
$response->assertJsonPath('branding.buttons.radius', 8);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_branding_asset_uses_public_disk(): void
|
public function test_branding_asset_uses_public_disk(): void
|
||||||
|
|||||||
@@ -184,6 +184,30 @@ class LoginTest extends TestCase
|
|||||||
$response->assertRedirect('/event-admin/dashboard');
|
$response->assertRedirect('/event-admin/dashboard');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_tenant_admin_login_rejects_lookalike_return_host(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create([
|
||||||
|
'email' => 'hostcheck@example.com',
|
||||||
|
'role' => 'tenant_admin',
|
||||||
|
'password' => bcrypt('password'),
|
||||||
|
'email_verified_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$appHost = parse_url(config('app.url'), PHP_URL_HOST) ?? 'localhost';
|
||||||
|
$targetHost = 'evil-'.$appHost;
|
||||||
|
$target = "https://{$targetHost}/event-admin/dashboard";
|
||||||
|
$encodedReturn = rtrim(strtr(base64_encode($target), '+/', '-_'), '=');
|
||||||
|
|
||||||
|
$response = $this->post(route('login.store'), [
|
||||||
|
'login' => 'hostcheck@example.com',
|
||||||
|
'password' => 'password',
|
||||||
|
'return_to' => $encodedReturn,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertAuthenticatedAs($user);
|
||||||
|
$response->assertRedirect('/event-admin/dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
public function test_tenant_admin_can_access_login_with_admin_return_path_when_authenticated()
|
public function test_tenant_admin_can_access_login_with_admin_return_path_when_authenticated()
|
||||||
{
|
{
|
||||||
$user = User::factory()->create([
|
$user = User::factory()->create([
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ class CustomerEmailRenderTest extends TestCase
|
|||||||
$user->forceFill(['tenant_id' => $tenant->id])->save();
|
$user->forceFill(['tenant_id' => $tenant->id])->save();
|
||||||
|
|
||||||
$package = Package::factory()->create([
|
$package = Package::factory()->create([
|
||||||
'name' => 'Standard',
|
'name' => 'Classic',
|
||||||
'type' => 'endcustomer',
|
'type' => 'endcustomer',
|
||||||
'max_photos' => 500,
|
'max_photos' => 500,
|
||||||
'max_guests' => 200,
|
'max_guests' => 200,
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class PurchaseConfirmationMailTest extends TestCase
|
|||||||
$user->forceFill(['tenant_id' => $tenant->id])->save();
|
$user->forceFill(['tenant_id' => $tenant->id])->save();
|
||||||
|
|
||||||
$package = Package::factory()->create([
|
$package = Package::factory()->create([
|
||||||
'name' => 'Standard',
|
'name' => 'Classic',
|
||||||
'type' => 'endcustomer',
|
'type' => 'endcustomer',
|
||||||
'max_photos' => 500,
|
'max_photos' => 500,
|
||||||
'max_guests' => 200,
|
'max_guests' => 200,
|
||||||
@@ -51,7 +51,7 @@ class PurchaseConfirmationMailTest extends TestCase
|
|||||||
$html = $mailable->render();
|
$html = $mailable->render();
|
||||||
|
|
||||||
$this->assertStringContainsString('Die Fotospiel.App', $html);
|
$this->assertStringContainsString('Die Fotospiel.App', $html);
|
||||||
$this->assertStringContainsString('Standard', $html);
|
$this->assertStringContainsString('Classic', $html);
|
||||||
$this->assertStringContainsString('txn_123', $html);
|
$this->assertStringContainsString('txn_123', $html);
|
||||||
$this->assertStringContainsString('https://paddle.test/invoice/123', $html);
|
$this->assertStringContainsString('https://paddle.test/invoice/123', $html);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ class SeedDemoSwitcherTenantsTest extends TestCase
|
|||||||
|
|
||||||
Package::factory()->create([
|
Package::factory()->create([
|
||||||
'slug' => 'standard',
|
'slug' => 'standard',
|
||||||
'name' => 'Standard',
|
'name' => 'Classic',
|
||||||
'type' => 'endcustomer',
|
'type' => 'endcustomer',
|
||||||
'max_events_per_year' => 5,
|
'max_events_per_year' => 5,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -65,8 +65,8 @@ class DashboardSummaryTest extends TestCase
|
|||||||
$package = Package::factory()
|
$package = Package::factory()
|
||||||
->reseller()
|
->reseller()
|
||||||
->create([
|
->create([
|
||||||
'name' => 'Standard',
|
'name' => 'Classic',
|
||||||
'name_translations' => ['de' => 'Standard', 'en' => 'Standard'],
|
'name_translations' => ['de' => 'Classic', 'en' => 'Classic'],
|
||||||
'price' => 59,
|
'price' => 59,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -96,7 +96,7 @@ class DashboardSummaryTest extends TestCase
|
|||||||
$activePackagePayload = Arr::get($payload, 'active_package');
|
$activePackagePayload = Arr::get($payload, 'active_package');
|
||||||
|
|
||||||
$this->assertIsArray($activePackagePayload);
|
$this->assertIsArray($activePackagePayload);
|
||||||
$this->assertSame('Standard', Arr::get($activePackagePayload, 'name'));
|
$this->assertSame('Classic', Arr::get($activePackagePayload, 'name'));
|
||||||
$this->assertSame($activePackage->remaining_events, Arr::get($activePackagePayload, 'remaining_events'));
|
$this->assertSame($activePackage->remaining_events, Arr::get($activePackagePayload, 'remaining_events'));
|
||||||
|
|
||||||
$this->assertSame(
|
$this->assertSame(
|
||||||
|
|||||||
@@ -37,10 +37,10 @@ class EventListTest extends TenantTestCase
|
|||||||
{
|
{
|
||||||
$package = Package::factory()->create([
|
$package = Package::factory()->create([
|
||||||
'type' => 'endcustomer',
|
'type' => 'endcustomer',
|
||||||
'name' => 'Standard',
|
'name' => 'Classic',
|
||||||
'name_translations' => [
|
'name_translations' => [
|
||||||
'de' => 'Standard',
|
'de' => 'Classic',
|
||||||
'en' => 'Standard',
|
'en' => 'Classic',
|
||||||
],
|
],
|
||||||
'price' => 59,
|
'price' => 59,
|
||||||
'gallery_days' => 45,
|
'gallery_days' => 45,
|
||||||
@@ -75,7 +75,7 @@ class EventListTest extends TenantTestCase
|
|||||||
|
|
||||||
$this->assertIsArray($matchingEvent['package']);
|
$this->assertIsArray($matchingEvent['package']);
|
||||||
$this->assertSame($package->id, $matchingEvent['package']['id']);
|
$this->assertSame($package->id, $matchingEvent['package']['id']);
|
||||||
$this->assertSame('Standard', $matchingEvent['package']['name']);
|
$this->assertSame('Classic', $matchingEvent['package']['name']);
|
||||||
$this->assertSame('59.00', $matchingEvent['package']['price']);
|
$this->assertSame('59.00', $matchingEvent['package']['price']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
103
tests/Feature/Tenant/PhotoUploadSecurityTest.php
Normal file
103
tests/Feature/Tenant/PhotoUploadSecurityTest.php
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Tenant;
|
||||||
|
|
||||||
|
use App\Models\Event;
|
||||||
|
use App\Models\EventMember;
|
||||||
|
use App\Models\EventPackage;
|
||||||
|
use App\Models\MediaStorageTarget;
|
||||||
|
use App\Models\Package;
|
||||||
|
use App\Models\Photo;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
|
||||||
|
class PhotoUploadSecurityTest extends TenantTestCase
|
||||||
|
{
|
||||||
|
public function test_photo_upload_uses_safe_extension_from_mime(): void
|
||||||
|
{
|
||||||
|
$event = Event::factory()->for($this->tenant)->create([
|
||||||
|
'slug' => 'secure-photo-upload',
|
||||||
|
]);
|
||||||
|
MediaStorageTarget::query()->create([
|
||||||
|
'key' => 'local',
|
||||||
|
'name' => 'Local Storage',
|
||||||
|
'driver' => 'local',
|
||||||
|
'is_hot' => true,
|
||||||
|
'is_default' => true,
|
||||||
|
'is_active' => true,
|
||||||
|
'priority' => 1,
|
||||||
|
]);
|
||||||
|
$package = Package::factory()->endcustomer()->create([
|
||||||
|
'max_photos' => 500,
|
||||||
|
'max_guests' => 100,
|
||||||
|
'gallery_days' => 30,
|
||||||
|
]);
|
||||||
|
EventPackage::query()->create([
|
||||||
|
'event_id' => $event->id,
|
||||||
|
'package_id' => $package->id,
|
||||||
|
'purchased_price' => 0,
|
||||||
|
'used_photos' => 0,
|
||||||
|
'used_guests' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$file = UploadedFile::fake()->image('photo.jpeg', 800, 600);
|
||||||
|
|
||||||
|
$response = $this->withHeaders([
|
||||||
|
'Authorization' => 'Bearer '.$this->token,
|
||||||
|
'Accept' => 'application/json',
|
||||||
|
])->post("/api/v1/tenant/events/{$event->slug}/photos", [
|
||||||
|
'photo' => $file,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertCreated();
|
||||||
|
|
||||||
|
$photo = Photo::query()->first();
|
||||||
|
$this->assertNotNull($photo);
|
||||||
|
$this->assertSame('jpg', strtolower(pathinfo((string) $photo->file_path, PATHINFO_EXTENSION)));
|
||||||
|
|
||||||
|
if (Photo::supportsFilenameColumn()) {
|
||||||
|
$this->assertStringEndsWith('.jpg', (string) $photo->filename);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_member_without_moderation_permission_cannot_access_moderation_queue(): void
|
||||||
|
{
|
||||||
|
$event = Event::factory()->for($this->tenant)->create([
|
||||||
|
'slug' => 'no-moderation-permission',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$memberUser = User::factory()->create([
|
||||||
|
'email' => 'limited.member@example.com',
|
||||||
|
'tenant_id' => $this->tenant->id,
|
||||||
|
'role' => 'member',
|
||||||
|
'password' => Hash::make('secret123'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
EventMember::factory()->create([
|
||||||
|
'tenant_id' => $this->tenant->id,
|
||||||
|
'event_id' => $event->id,
|
||||||
|
'user_id' => $memberUser->id,
|
||||||
|
'email' => $memberUser->email,
|
||||||
|
'role' => 'member',
|
||||||
|
'status' => 'active',
|
||||||
|
'permissions' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$login = $this->postJson('/api/v1/tenant-auth/login', [
|
||||||
|
'login' => $memberUser->email,
|
||||||
|
'password' => 'secret123',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$login->assertOk();
|
||||||
|
$token = $login->json('token');
|
||||||
|
|
||||||
|
$response = $this->withHeaders([
|
||||||
|
'Authorization' => 'Bearer '.$token,
|
||||||
|
'Accept' => 'application/json',
|
||||||
|
])->getJson("/api/v1/tenant/events/{$event->slug}/photos/moderation");
|
||||||
|
|
||||||
|
$response->assertForbidden();
|
||||||
|
$response->assertJsonPath('error.code', 'insufficient_permission');
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user