Compare commits

..

7 Commits

Author SHA1 Message Date
Codex Agent
a9fa1546f7 bd sync: 2026-01-23 09:20:33 2026-01-23 09:20:34 +01:00
Codex Agent
7c6eee187c bd sync: 2026-01-23 08:56:22 2026-01-23 08:56:23 +01:00
Codex Agent
fbd46b8e5c bd sync: 2026-01-21 12:55:26 2026-01-21 12:55:26 +01:00
Codex Agent
886b336a08 bd sync: 2026-01-19 18:50:20 2026-01-19 18:50:20 +01:00
Codex Agent
02237735ec bd sync: 2026-01-18 11:02:27 2026-01-18 11:02:28 +01:00
Codex Agent
5e420a0dd8 bd sync: 2026-01-15 19:54:28 2026-01-15 19:54:28 +01:00
Codex Agent
2a55ae934f bd sync: 2026-01-13 11:04:44 2026-01-13 11:04:44 +01:00
87 changed files with 954 additions and 4945 deletions

6
.beads/.gitignore vendored
View File

@@ -11,12 +11,6 @@ daemon.log
daemon.pid daemon.pid
bd.sock bd.sock
sync-state.json sync-state.json
.sync.lock
last-touched
sync_base.jsonl
.sync.lock
last-touched
sync_base.jsonl
# Local version tracking (prevents upgrade notification spam after git ops) # Local version tracking (prevents upgrade notification spam after git ops)
.local_version .local_version

View File

@@ -42,7 +42,7 @@
# This setting persists across clones (unlike database config which is gitignored). # This setting persists across clones (unlike database config which is gitignored).
# Can also use BEADS_SYNC_BRANCH env var for local override. # Can also use BEADS_SYNC_BRANCH env var for local override.
# If not set, bd sync will require you to run 'bd config set sync.branch <branch>'. # If not set, bd sync will require you to run 'bd config set sync.branch <branch>'.
sync-branch: "beads-sync" # sync-branch: "beads-sync"
# Multi-repo configuration (experimental - bd-307) # Multi-repo configuration (experimental - bd-307)
# Allows hydrating from multiple repositories and routing writes to the correct JSONL # Allows hydrating from multiple repositories and routing writes to the correct JSONL

View File

@@ -17,10 +17,12 @@
{"id":"fotospiel-app-38f","title":"Paddle catalog sync: surface last sync error/log context in admin","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:59:14.865414785+01:00","created_by":"soeren","updated_at":"2026-01-02T21:16:09.109922491+01:00","closed_at":"2026-01-02T21:16:09.109922491+01:00","close_reason":"Completed"} {"id":"fotospiel-app-38f","title":"Paddle catalog sync: surface last sync error/log context in admin","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:59:14.865414785+01:00","created_by":"soeren","updated_at":"2026-01-02T21:16:09.109922491+01:00","closed_at":"2026-01-02T21:16:09.109922491+01:00","close_reason":"Completed"}
{"id":"fotospiel-app-3ut","title":"SEC-API-03 Synthetic monitoring + alert config","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:52:46.793875724+01:00","created_by":"soeren","updated_at":"2026-01-01T15:52:46.793875724+01:00"} {"id":"fotospiel-app-3ut","title":"SEC-API-03 Synthetic monitoring + alert config","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:52:46.793875724+01:00","created_by":"soeren","updated_at":"2026-01-01T15:52:46.793875724+01:00"}
{"id":"fotospiel-app-3xa","title":"Security review: event admin code audit (policies, PKCE, file handling)","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:05:20.115675149+01:00","created_by":"soeren","updated_at":"2026-01-01T16:05:20.115675149+01:00"} {"id":"fotospiel-app-3xa","title":"Security review: event admin code audit (policies, PKCE, file handling)","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:05:20.115675149+01:00","created_by":"soeren","updated_at":"2026-01-01T16:05:20.115675149+01:00"}
{"id":"fotospiel-app-43mp","title":"Help-System für Event Admin PWA planen","notes":"Context help links wired into priority admin pages.","status":"in_progress","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-23T08:21:47.812129626+01:00","created_by":"Codex Agent","updated_at":"2026-01-23T09:19:45.828239299+01:00"}
{"id":"fotospiel-app-4ar","title":"SEC-BILL-03 Failed capture notifications + ledger hook","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:54:33.266516715+01:00","created_by":"soeren","updated_at":"2026-01-01T15:54:33.266516715+01:00"} {"id":"fotospiel-app-4ar","title":"SEC-BILL-03 Failed capture notifications + ledger hook","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:54:33.266516715+01:00","created_by":"soeren","updated_at":"2026-01-01T15:54:33.266516715+01:00"}
{"id":"fotospiel-app-4en","title":"Add translations for Mobile Package Shop","description":"The new MobilePackageShopPage.tsx uses translation keys like 'shop.title', 'shop.legal.agb', etc. Ensure these are added to the management.json files for de and en.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T18:05:50.469751088+01:00","created_by":"soeren","updated_at":"2026-01-06T18:14:19.984343737+01:00","closed_at":"2026-01-06T18:14:19.984346372+01:00"} {"id":"fotospiel-app-4en","title":"Add translations for Mobile Package Shop","description":"The new MobilePackageShopPage.tsx uses translation keys like 'shop.title', 'shop.legal.agb', etc. Ensure these are added to the management.json files for de and en.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T18:05:50.469751088+01:00","created_by":"soeren","updated_at":"2026-01-06T18:14:19.984343737+01:00","closed_at":"2026-01-06T18:14:19.984346372+01:00"}
{"id":"fotospiel-app-4i4","title":"Security review: map roles/data","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:02:58.370301875+01:00","created_by":"soeren","updated_at":"2026-01-01T16:03:03.997327414+01:00","closed_at":"2026-01-01T16:03:03.997327414+01:00","close_reason":"Completed in codebase (verified)"} {"id":"fotospiel-app-4i4","title":"Security review: map roles/data","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:02:58.370301875+01:00","created_by":"soeren","updated_at":"2026-01-01T16:03:03.997327414+01:00","closed_at":"2026-01-01T16:03:03.997327414+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-4zu","title":"SEC-IO-02 Refresh-token management UI + audit logs","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:51:50.24186222+01:00","created_by":"soeren","updated_at":"2026-01-04T16:10:39.752587431+01:00","closed_at":"2026-01-04T16:10:39.752587431+01:00","close_reason":"Obsolete: authentication now uses Sanctum PATs; OAuth/refresh-token tables removed and no refresh-token flow remains. See docs/archive/prp/13-backend-authentication.md and docs/archive/prp/marketing-checkout-payment-architecture.md."} {"id":"fotospiel-app-4zu","title":"SEC-IO-02 Refresh-token management UI + audit logs","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:51:50.24186222+01:00","created_by":"soeren","updated_at":"2026-01-04T16:10:39.752587431+01:00","closed_at":"2026-01-04T16:10:39.752587431+01:00","close_reason":"Obsolete: authentication now uses Sanctum PATs; OAuth/refresh-token tables removed and no refresh-token flow remains. See docs/archive/prp/13-backend-authentication.md and docs/archive/prp/marketing-checkout-payment-architecture.md."}
{"id":"fotospiel-app-4zy","title":"Refine Dashboard Translations","description":"Fix missing translations in the modern dashboard UI and use proper i18n keys for stats and status labels.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-17T16:35:14.464529363+01:00","created_by":"Codex Agent","updated_at":"2026-01-17T16:35:14.464529363+01:00"}
{"id":"fotospiel-app-539","title":"Live Show: public player view with effects engine","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-01-05T11:11:36.821959901+01:00","created_by":"soeren","updated_at":"2026-01-05T18:30:13.318396255+01:00","closed_at":"2026-01-05T18:30:13.318396255+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-539","depends_on_id":"fotospiel-app-qne","type":"blocks","created_at":"2026-01-05T11:12:58.721858159+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-539","depends_on_id":"fotospiel-app-6zc","type":"blocks","created_at":"2026-01-05T11:13:07.289796993+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-539","depends_on_id":"fotospiel-app-h5d","type":"blocks","created_at":"2026-01-05T11:44:42.719445471+01:00","created_by":"soeren"}]} {"id":"fotospiel-app-539","title":"Live Show: public player view with effects engine","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-01-05T11:11:36.821959901+01:00","created_by":"soeren","updated_at":"2026-01-05T18:30:13.318396255+01:00","closed_at":"2026-01-05T18:30:13.318396255+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-539","depends_on_id":"fotospiel-app-qne","type":"blocks","created_at":"2026-01-05T11:12:58.721858159+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-539","depends_on_id":"fotospiel-app-6zc","type":"blocks","created_at":"2026-01-05T11:13:07.289796993+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-539","depends_on_id":"fotospiel-app-h5d","type":"blocks","created_at":"2026-01-05T11:44:42.719445471+01:00","created_by":"soeren"}]}
{"id":"fotospiel-app-539.2","title":"Live Show player shell + routing + data layer","description":"Add /show/{token} route + guest player page shell, Live Show API client, SSE/polling subscription and state model.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-05T15:57:41.587003393+01:00","created_by":"soeren","updated_at":"2026-01-05T16:44:39.577762479+01:00","closed_at":"2026-01-05T16:44:39.577762479+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-539.2","depends_on_id":"fotospiel-app-539","type":"parent-child","created_at":"2026-01-05T15:57:41.641767879+01:00","created_by":"soeren"}]} {"id":"fotospiel-app-539.2","title":"Live Show player shell + routing + data layer","description":"Add /show/{token} route + guest player page shell, Live Show API client, SSE/polling subscription and state model.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-05T15:57:41.587003393+01:00","created_by":"soeren","updated_at":"2026-01-05T16:44:39.577762479+01:00","closed_at":"2026-01-05T16:44:39.577762479+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-539.2","depends_on_id":"fotospiel-app-539","type":"parent-child","created_at":"2026-01-05T15:57:41.641767879+01:00","created_by":"soeren"}]}
{"id":"fotospiel-app-539.3","title":"Live Show playback engine (queue, pacing, layouts)","description":"Implement player playback scheduler, queue management, and layout rendering for single/split/grid.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-05T15:57:56.531080931+01:00","created_by":"soeren","updated_at":"2026-01-05T17:40:45.929168571+01:00","closed_at":"2026-01-05T17:40:45.929168571+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-539.3","depends_on_id":"fotospiel-app-539","type":"parent-child","created_at":"2026-01-05T15:57:56.631147026+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-539.3","depends_on_id":"fotospiel-app-539.2","type":"blocks","created_at":"2026-01-05T15:57:56.655278463+01:00","created_by":"soeren"}]} {"id":"fotospiel-app-539.3","title":"Live Show playback engine (queue, pacing, layouts)","description":"Implement player playback scheduler, queue management, and layout rendering for single/split/grid.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-05T15:57:56.531080931+01:00","created_by":"soeren","updated_at":"2026-01-05T17:40:45.929168571+01:00","closed_at":"2026-01-05T17:40:45.929168571+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-539.3","depends_on_id":"fotospiel-app-539","type":"parent-child","created_at":"2026-01-05T15:57:56.631147026+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-539.3","depends_on_id":"fotospiel-app-539.2","type":"blocks","created_at":"2026-01-05T15:57:56.655278463+01:00","created_by":"soeren"}]}
@@ -36,6 +38,7 @@
{"id":"fotospiel-app-5ie","title":"Help docs: Live Show how-to + recommended hardware (DE/EN)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-05T11:12:05.973844187+01:00","created_by":"soeren","updated_at":"2026-01-05T19:42:44.39939087+01:00","closed_at":"2026-01-05T19:42:44.39939087+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-5ie","depends_on_id":"fotospiel-app-vro","type":"blocks","created_at":"2026-01-05T11:13:54.925412888+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-5ie","depends_on_id":"fotospiel-app-539","type":"blocks","created_at":"2026-01-05T11:14:03.257649076+01:00","created_by":"soeren"}]} {"id":"fotospiel-app-5ie","title":"Help docs: Live Show how-to + recommended hardware (DE/EN)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-05T11:12:05.973844187+01:00","created_by":"soeren","updated_at":"2026-01-05T19:42:44.39939087+01:00","closed_at":"2026-01-05T19:42:44.39939087+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-5ie","depends_on_id":"fotospiel-app-vro","type":"blocks","created_at":"2026-01-05T11:13:54.925412888+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-5ie","depends_on_id":"fotospiel-app-539","type":"blocks","created_at":"2026-01-05T11:14:03.257649076+01:00","created_by":"soeren"}]}
{"id":"fotospiel-app-5iy","title":"Security review: confirm env/header defaults","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:03:20.808188183+01:00","created_by":"soeren","updated_at":"2026-01-01T16:03:26.388002115+01:00","closed_at":"2026-01-01T16:03:26.388002115+01:00","close_reason":"Completed in codebase (verified)"} {"id":"fotospiel-app-5iy","title":"Security review: confirm env/header defaults","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:03:20.808188183+01:00","created_by":"soeren","updated_at":"2026-01-01T16:03:26.388002115+01:00","closed_at":"2026-01-01T16:03:26.388002115+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-5s3","title":"Localized SEO: canonical/hreflang tags + localized navigation","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:02:03.909947355+01:00","created_by":"soeren","updated_at":"2026-01-01T16:02:09.550647107+01:00","closed_at":"2026-01-01T16:02:09.550647107+01:00","close_reason":"Completed in codebase (verified)"} {"id":"fotospiel-app-5s3","title":"Localized SEO: canonical/hreflang tags + localized navigation","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:02:03.909947355+01:00","created_by":"soeren","updated_at":"2026-01-01T16:02:09.550647107+01:00","closed_at":"2026-01-01T16:02:09.550647107+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-5veo","title":"Investigate vite build timeout","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-21T12:49:14.166622473+01:00","created_by":"Codex Agent","updated_at":"2026-01-21T12:49:14.166622473+01:00"}
{"id":"fotospiel-app-5zl","title":"Ensure checkout step 3 requires login for Paddle checkout","description":"Problem: Paddle checkout on step 3 fails when user is not logged in. Step 3 must enforce authentication before initializing Paddle checkout.\\n\\nSuggestions:\\n- Protect step 3 route/controller with auth middleware and redirect to login with intended return URL.\\n- Gate step 3 UI/CTA on auth state; show inline login prompt and disable Paddle until authenticated.\\n- Require auth in backend endpoint that creates Paddle transaction/session; return 401 and send user to login.\\n- Optionally preflight at end of step 2 to prompt login before advancing.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-04T12:31:43.215017311+01:00","created_by":"soeren","updated_at":"2026-01-04T12:42:45.088723058+01:00","closed_at":"2026-01-04T12:42:45.088723058+01:00","close_reason":"Closed"} {"id":"fotospiel-app-5zl","title":"Ensure checkout step 3 requires login for Paddle checkout","description":"Problem: Paddle checkout on step 3 fails when user is not logged in. Step 3 must enforce authentication before initializing Paddle checkout.\\n\\nSuggestions:\\n- Protect step 3 route/controller with auth middleware and redirect to login with intended return URL.\\n- Gate step 3 UI/CTA on auth state; show inline login prompt and disable Paddle until authenticated.\\n- Require auth in backend endpoint that creates Paddle transaction/session; return 401 and send user to login.\\n- Optionally preflight at end of step 2 to prompt login before advancing.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-04T12:31:43.215017311+01:00","created_by":"soeren","updated_at":"2026-01-04T12:42:45.088723058+01:00","closed_at":"2026-01-04T12:42:45.088723058+01:00","close_reason":"Closed"}
{"id":"fotospiel-app-64l","title":"SEC-FE-01 CSP nonce/hashing rollout","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:54:47.607047443+01:00","created_by":"soeren","updated_at":"2026-01-01T15:55:56.477104351+01:00","closed_at":"2026-01-01T15:55:56.477104351+01:00","close_reason":"Completed in codebase (verified) - duplicate of fotospiel-app-zli"} {"id":"fotospiel-app-64l","title":"SEC-FE-01 CSP nonce/hashing rollout","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:54:47.607047443+01:00","created_by":"soeren","updated_at":"2026-01-01T15:55:56.477104351+01:00","closed_at":"2026-01-01T15:55:56.477104351+01:00","close_reason":"Completed in codebase (verified) - duplicate of fotospiel-app-zli"}
{"id":"fotospiel-app-6dp","title":"Coupon ops enhancements (redemption service, preview endpoint, widget, export)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:09:09.275919717+01:00","created_by":"soeren","updated_at":"2026-01-01T16:09:14.882264149+01:00","closed_at":"2026-01-01T16:09:14.882264149+01:00","close_reason":"Completed in codebase (verified)"} {"id":"fotospiel-app-6dp","title":"Coupon ops enhancements (redemption service, preview endpoint, widget, export)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:09:09.275919717+01:00","created_by":"soeren","updated_at":"2026-01-01T16:09:14.882264149+01:00","closed_at":"2026-01-01T16:09:14.882264149+01:00","close_reason":"Completed in codebase (verified)"}
@@ -48,6 +51,7 @@
{"id":"fotospiel-app-7uu","title":"Uploader: improve file readiness detection","description":"Part of epic fotospiel-app-5aa. Use size + last-write stabilization to avoid partial uploads.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:01:54.142231578+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:01:54.142231578+01:00"} {"id":"fotospiel-app-7uu","title":"Uploader: improve file readiness detection","description":"Part of epic fotospiel-app-5aa. Use size + last-write stabilization to avoid partial uploads.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:01:54.142231578+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:01:54.142231578+01:00"}
{"id":"fotospiel-app-7x1","title":"Uploader: response format manual override","description":"Part of epic fotospiel-app-5aa. Allow manual response format override when connect code doesn't set it.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:03:54.824613016+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:03:54.824613016+01:00"} {"id":"fotospiel-app-7x1","title":"Uploader: response format manual override","description":"Part of epic fotospiel-app-5aa. Allow manual response format override when connect code doesn't set it.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:03:54.824613016+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:03:54.824613016+01:00"}
{"id":"fotospiel-app-83q","title":"Implement Advanced Analytics","description":"Full plan: Phase 1 (MVP) includes Activity Timeline, Top Contributors, and Task Stats. Phase 2 includes Engagement Funnel, Vibe Check, and PDF Export. See chat history for details.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T15:40:08.826105426+01:00","created_by":"soeren","updated_at":"2026-01-06T16:15:17.722450844+01:00","closed_at":"2026-01-06T16:15:17.722455019+01:00"} {"id":"fotospiel-app-83q","title":"Implement Advanced Analytics","description":"Full plan: Phase 1 (MVP) includes Activity Timeline, Top Contributors, and Task Stats. Phase 2 includes Engagement Funnel, Vibe Check, and PDF Export. See chat history for details.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T15:40:08.826105426+01:00","created_by":"soeren","updated_at":"2026-01-06T16:15:17.722450844+01:00","closed_at":"2026-01-06T16:15:17.722455019+01:00"}
{"id":"fotospiel-app-8iw","title":"Modernize Tenant Admin PWA UI","status":"open","priority":1,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-17T14:36:39.802617182+01:00","created_by":"Codex Agent","updated_at":"2026-01-17T14:36:39.802617182+01:00"}
{"id":"fotospiel-app-8ui","title":"Uploader: persist queue across restarts","description":"Part of epic fotospiel-app-5aa. Persist pending upload queue to disk (settings or local DB) so restarts don't lose files.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:01:42.213478619+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:01:42.213478619+01:00"} {"id":"fotospiel-app-8ui","title":"Uploader: persist queue across restarts","description":"Part of epic fotospiel-app-5aa. Persist pending upload queue to disk (settings or local DB) so restarts don't lose files.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:01:42.213478619+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:01:42.213478619+01:00"}
{"id":"fotospiel-app-95m","title":"Paddle migration: admin catalog sync UI for packages","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:57:49.790409261+01:00","created_by":"soeren","updated_at":"2026-01-01T15:57:55.418180246+01:00","closed_at":"2026-01-01T15:57:55.418180246+01:00","close_reason":"Completed in codebase (verified)"} {"id":"fotospiel-app-95m","title":"Paddle migration: admin catalog sync UI for packages","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:57:49.790409261+01:00","created_by":"soeren","updated_at":"2026-01-01T15:57:55.418180246+01:00","closed_at":"2026-01-01T15:57:55.418180246+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-99d","title":"Paddle migration: marketing checkout uses Paddle-hosted checkout","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:58:12.298063897+01:00","created_by":"soeren","updated_at":"2026-01-01T15:58:17.968032021+01:00","closed_at":"2026-01-01T15:58:17.968032021+01:00","close_reason":"Completed in codebase (verified)"} {"id":"fotospiel-app-99d","title":"Paddle migration: marketing checkout uses Paddle-hosted checkout","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:58:12.298063897+01:00","created_by":"soeren","updated_at":"2026-01-01T15:58:17.968032021+01:00","closed_at":"2026-01-01T15:58:17.968032021+01:00","close_reason":"Completed in codebase (verified)"}
@@ -74,6 +78,7 @@
{"id":"fotospiel-app-cwq","title":"Integrations health: unified Paddle/RevenueCat/webhook status dashboard","description":"Add a superadmin integrations health dashboard for Paddle/RevenueCat/webhooks.\nScope: show latest webhook processing status/lag, recent failures, retry backlog, and config presence (env set) without exposing secrets.\nInclude per-provider status badges and time-window filters, plus links to related logs/actions.\n","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-02T17:34:20.84661157+01:00","created_by":"soeren","updated_at":"2026-01-02T18:33:07.133704488+01:00","closed_at":"2026-01-02T18:33:07.133704488+01:00","close_reason":"Closed"} {"id":"fotospiel-app-cwq","title":"Integrations health: unified Paddle/RevenueCat/webhook status dashboard","description":"Add a superadmin integrations health dashboard for Paddle/RevenueCat/webhooks.\nScope: show latest webhook processing status/lag, recent failures, retry backlog, and config presence (env set) without exposing secrets.\nInclude per-provider status badges and time-window filters, plus links to related logs/actions.\n","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-02T17:34:20.84661157+01:00","created_by":"soeren","updated_at":"2026-01-02T18:33:07.133704488+01:00","closed_at":"2026-01-02T18:33:07.133704488+01:00","close_reason":"Closed"}
{"id":"fotospiel-app-d39","title":"Superadmin control surface spec and access matrix","description":"Define the minimal superadmin control surface, permissions, and mapping to tenant/guest responsibilities. Document scope and non-goals.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T14:16:06.994379577+01:00","updated_at":"2026-01-01T14:20:43.080701114+01:00","closed_at":"2026-01-01T14:20:43.080701114+01:00"} {"id":"fotospiel-app-d39","title":"Superadmin control surface spec and access matrix","description":"Define the minimal superadmin control surface, permissions, and mapping to tenant/guest responsibilities. Document scope and non-goals.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T14:16:06.994379577+01:00","updated_at":"2026-01-01T14:20:43.080701114+01:00","closed_at":"2026-01-01T14:20:43.080701114+01:00"}
{"id":"fotospiel-app-dar","title":"Uploader: retry policy for failed uploads","description":"Part of epic fotospiel-app-5aa. Auto-retry with backoff and retry limit before marking failed.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:03:00.808893045+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:03:00.808893045+01:00"} {"id":"fotospiel-app-dar","title":"Uploader: retry policy for failed uploads","description":"Part of epic fotospiel-app-5aa. Auto-retry with backoff and retry limit before marking failed.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:03:00.808893045+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:03:00.808893045+01:00"}
{"id":"fotospiel-app-de7","title":"Re-run admin Playwright tests with valid E2E credentials","status":"open","priority":3,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-15T19:53:26.674926731+01:00","created_by":"Codex Agent","updated_at":"2026-01-15T19:53:26.674926731+01:00"}
{"id":"fotospiel-app-dl5","title":"SEC-API-01 Signed URL middleware + asset migration","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:52:24.24098702+01:00","created_by":"soeren","updated_at":"2026-01-01T15:52:29.8793891+01:00","closed_at":"2026-01-01T15:52:29.8793891+01:00","close_reason":"Completed in codebase (verified)"} {"id":"fotospiel-app-dl5","title":"SEC-API-01 Signed URL middleware + asset migration","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:52:24.24098702+01:00","created_by":"soeren","updated_at":"2026-01-01T15:52:29.8793891+01:00","closed_at":"2026-01-01T15:52:29.8793891+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-dm4","title":"SEC-BILL-01 Checkout session linkage + idempotency locks","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:53:26.350238207+01:00","created_by":"soeren","updated_at":"2026-01-01T15:53:31.997737421+01:00","closed_at":"2026-01-01T15:53:31.997737421+01:00","close_reason":"Completed in codebase (verified)"} {"id":"fotospiel-app-dm4","title":"SEC-BILL-01 Checkout session linkage + idempotency locks","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:53:26.350238207+01:00","created_by":"soeren","updated_at":"2026-01-01T15:53:31.997737421+01:00","closed_at":"2026-01-01T15:53:31.997737421+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-dmb","title":"Security review checklist: Event Admin dynamic tests","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:04:46.359468828+01:00","created_by":"soeren","updated_at":"2026-01-01T16:04:46.359468828+01:00"} {"id":"fotospiel-app-dmb","title":"Security review checklist: Event Admin dynamic tests","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:04:46.359468828+01:00","created_by":"soeren","updated_at":"2026-01-01T16:04:46.359468828+01:00"}
@@ -135,6 +140,7 @@
{"id":"fotospiel-app-sbs","title":"Compliance tools: data export + retention overrides","description":"GDPR-compliant export requests and retention override workflows for tenants/events.","status":"closed","priority":3,"issue_type":"feature","created_at":"2026-01-01T14:20:16.530289009+01:00","updated_at":"2026-01-02T20:13:31.704875591+01:00","closed_at":"2026-01-02T20:13:31.704875591+01:00","close_reason":"Closed"} {"id":"fotospiel-app-sbs","title":"Compliance tools: data export + retention overrides","description":"GDPR-compliant export requests and retention override workflows for tenants/events.","status":"closed","priority":3,"issue_type":"feature","created_at":"2026-01-01T14:20:16.530289009+01:00","updated_at":"2026-01-02T20:13:31.704875591+01:00","closed_at":"2026-01-02T20:13:31.704875591+01:00","close_reason":"Closed"}
{"id":"fotospiel-app-sdg","title":"Uploader: watch include/exclude patterns","description":"Part of epic fotospiel-app-5aa. Configurable file patterns (ignore tmp/preview) for watcher.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:02:17.188267106+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:02:17.188267106+01:00"} {"id":"fotospiel-app-sdg","title":"Uploader: watch include/exclude patterns","description":"Part of epic fotospiel-app-5aa. Configurable file patterns (ignore tmp/preview) for watcher.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:02:17.188267106+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:02:17.188267106+01:00"}
{"id":"fotospiel-app-sju","title":"Live Show link sharing + QR in admin","description":"Expose Live Show link in Event Admin with copy/share/open actions and embedded QR (use simplesoftwareio/simple-qrcode, no external service). Add API endpoints for link fetch/rotate, admin UI card with rotate confirmation, and tests.","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-05T20:00:25.427132538+01:00","created_by":"soeren","updated_at":"2026-01-05T20:00:25.427132538+01:00"} {"id":"fotospiel-app-sju","title":"Live Show link sharing + QR in admin","description":"Expose Live Show link in Event Admin with copy/share/open actions and embedded QR (use simplesoftwareio/simple-qrcode, no external service). Add API endpoints for link fetch/rotate, admin UI card with rotate confirmation, and tests.","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-05T20:00:25.427132538+01:00","created_by":"soeren","updated_at":"2026-01-05T20:00:25.427132538+01:00"}
{"id":"fotospiel-app-spq8","title":"Eslint fails due to existing repo violations","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-19T18:49:19.208323875+01:00","created_by":"Codex Agent","updated_at":"2026-01-19T18:49:19.208323875+01:00"}
{"id":"fotospiel-app-swb","title":"Security review: replace public asset URLs with signed routes","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:04:05.610098299+01:00","created_by":"soeren","updated_at":"2026-01-01T16:04:11.215921463+01:00","closed_at":"2026-01-01T16:04:11.215921463+01:00","close_reason":"Completed in codebase (verified)"} {"id":"fotospiel-app-swb","title":"Security review: replace public asset URLs with signed routes","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:04:05.610098299+01:00","created_by":"soeren","updated_at":"2026-01-01T16:04:11.215921463+01:00","closed_at":"2026-01-01T16:04:11.215921463+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-t1k","title":"Live Show: data model \u0026 status workflow (pending/approved/ready)","acceptance_criteria":"- DB migrations add event token + photo live fields + indexes\\n- Token generation supports rotation (no expiry)\\n- Photo live workflow methods set timestamps/reviewer consistently\\n- Feature test covers token + workflow","notes":"Implemented Live Show data model: events.live_show_token + live_show_token_rotated_at; photos.live_status + timestamps/reviewer/rejection fields + indexes. Added PhotoLiveStatus enum and Photo workflow methods (markLivePending/approveForLiveShow/rejectForLiveShow). Added Event helpers (ensureLiveShowToken/rotateLiveShowToken). Tests: tests/Feature/LiveShowDataModelTest.php.","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-01-05T11:10:56.560421826+01:00","created_by":"soeren","updated_at":"2026-01-05T12:22:51.967913423+01:00","closed_at":"2026-01-05T12:22:51.967913423+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-t1k","depends_on_id":"fotospiel-app-vro","type":"blocks","created_at":"2026-01-05T11:12:20.345646244+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-t1k","depends_on_id":"fotospiel-app-h5d","type":"blocks","created_at":"2026-01-05T11:44:12.439413712+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-t1k","depends_on_id":"fotospiel-app-1eu","type":"blocks","created_at":"2026-01-05T11:44:22.588642567+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-t1k","depends_on_id":"fotospiel-app-1we","type":"blocks","created_at":"2026-01-05T11:44:31.775634827+01:00","created_by":"soeren"}]} {"id":"fotospiel-app-t1k","title":"Live Show: data model \u0026 status workflow (pending/approved/ready)","acceptance_criteria":"- DB migrations add event token + photo live fields + indexes\\n- Token generation supports rotation (no expiry)\\n- Photo live workflow methods set timestamps/reviewer consistently\\n- Feature test covers token + workflow","notes":"Implemented Live Show data model: events.live_show_token + live_show_token_rotated_at; photos.live_status + timestamps/reviewer/rejection fields + indexes. Added PhotoLiveStatus enum and Photo workflow methods (markLivePending/approveForLiveShow/rejectForLiveShow). Added Event helpers (ensureLiveShowToken/rotateLiveShowToken). Tests: tests/Feature/LiveShowDataModelTest.php.","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-01-05T11:10:56.560421826+01:00","created_by":"soeren","updated_at":"2026-01-05T12:22:51.967913423+01:00","closed_at":"2026-01-05T12:22:51.967913423+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-t1k","depends_on_id":"fotospiel-app-vro","type":"blocks","created_at":"2026-01-05T11:12:20.345646244+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-t1k","depends_on_id":"fotospiel-app-h5d","type":"blocks","created_at":"2026-01-05T11:44:12.439413712+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-t1k","depends_on_id":"fotospiel-app-1eu","type":"blocks","created_at":"2026-01-05T11:44:22.588642567+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-t1k","depends_on_id":"fotospiel-app-1we","type":"blocks","created_at":"2026-01-05T11:44:31.775634827+01:00","created_by":"soeren"}]}
{"id":"fotospiel-app-t2s","title":"Uploader: multiple event profiles","description":"Part of epic fotospiel-app-5aa. Save multiple event profiles and allow quick switching.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:04:18.20222112+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:04:18.20222112+01:00"} {"id":"fotospiel-app-t2s","title":"Uploader: multiple event profiles","description":"Part of epic fotospiel-app-5aa. Save multiple event profiles and allow quick switching.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:04:18.20222112+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:04:18.20222112+01:00"}

View File

@@ -1 +1 @@
fotospiel-app-6yz fotospiel-app-29r

View File

@@ -4,7 +4,6 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Photobooth\PhotoboothConnectRedeemRequest; use App\Http\Requests\Photobooth\PhotoboothConnectRedeemRequest;
use App\Models\Event;
use App\Services\Photobooth\PhotoboothConnectCodeService; use App\Services\Photobooth\PhotoboothConnectCodeService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@@ -34,8 +33,7 @@ class PhotoboothConnectController extends Controller
return response()->json([ return response()->json([
'data' => [ 'data' => [
'event_name' => $this->resolveEventName($event), 'upload_url' => route('api.v1.photobooth.sparkbooth.upload'),
'upload_url' => route('api.v1.photobooth.upload'),
'username' => $setting->username, 'username' => $setting->username,
'password' => $setting->password, 'password' => $setting->password,
'expires_at' => optional($setting->expires_at)->toIso8601String(), 'expires_at' => optional($setting->expires_at)->toIso8601String(),
@@ -44,27 +42,4 @@ class PhotoboothConnectController extends Controller
], ],
]); ]);
} }
private function resolveEventName(?Event $event): ?string
{
if (! $event) {
return null;
}
$name = $event->name;
if (is_string($name) && trim($name) !== '') {
return $name;
}
if (is_array($name)) {
foreach ($name as $value) {
if (is_string($value) && trim($value) !== '') {
return $value;
}
}
}
return $event->slug ?: null;
}
} }

View File

@@ -349,14 +349,9 @@ class EventController extends Controller
$validated['settings']['watermark_allowed'] = $watermarkAllowed; $validated['settings']['watermark_allowed'] = $watermarkAllowed;
$settings = $validated['settings']; $settings = $validated['settings'];
$branding = Arr::get($settings, 'branding', []);
$watermark = Arr::get($settings, 'watermark', []); $watermark = Arr::get($settings, 'watermark', []);
$existingWatermark = is_array($watermark) ? $watermark : []; $existingWatermark = is_array($watermark) ? $watermark : [];
if (is_array($branding)) {
$settings['branding'] = $this->normalizeBrandingSettings($branding, $event, $brandingAllowed);
}
if (is_array($watermark)) { if (is_array($watermark)) {
$mode = $watermark['mode'] ?? 'base'; $mode = $watermark['mode'] ?? 'base';
$policy = $watermarkAllowed ? 'basic' : 'none'; $policy = $watermarkAllowed ? 'basic' : 'none';
@@ -447,68 +442,6 @@ class EventController extends Controller
]); ]);
} }
/**
* @param array<string, mixed> $branding
* @return array<string, mixed>
*/
private function normalizeBrandingSettings(array $branding, Event $event, bool $brandingAllowed): array
{
$logoDataUrl = $branding['logo_data_url'] ?? null;
if (! $brandingAllowed) {
unset($branding['logo_data_url']);
return $branding;
}
if (! is_string($logoDataUrl) || trim($logoDataUrl) === '') {
unset($branding['logo_data_url']);
return $branding;
}
if (! preg_match('/^data:image\\/(png|webp|jpe?g);base64,(.+)$/i', $logoDataUrl, $matches)) {
throw ValidationException::withMessages([
'settings.branding.logo_data_url' => __('Ungültiges Branding-Logo.'),
]);
}
$decoded = base64_decode($matches[2], true);
if ($decoded === false) {
throw ValidationException::withMessages([
'settings.branding.logo_data_url' => __('Branding-Logo konnte nicht gelesen werden.'),
]);
}
if (strlen($decoded) > 1024 * 1024) { // 1 MB
throw ValidationException::withMessages([
'settings.branding.logo_data_url' => __('Branding-Logo ist zu groß (max. 1 MB).'),
]);
}
$extension = str_starts_with(strtolower($matches[1]), 'jp') ? 'jpg' : strtolower($matches[1]);
$path = sprintf('branding/logos/event-%s.%s', $event->id, $extension);
Storage::disk('public')->put($path, $decoded);
$branding['logo_url'] = $path;
$branding['logo_mode'] = 'upload';
$branding['logo_value'] = $path;
$logo = $branding['logo'] ?? [];
if (! is_array($logo)) {
$logo = [];
}
$logo['mode'] = 'upload';
$logo['value'] = $path;
$branding['logo'] = $logo;
unset($branding['logo_data_url']);
return $branding;
}
public function destroy(Request $request, Event $event): JsonResponse public function destroy(Request $request, Event $event): JsonResponse
{ {
$tenantId = $request->attributes->get('tenant_id'); $tenantId = $request->attributes->get('tenant_id');

View File

@@ -3,17 +3,12 @@
namespace App\Http\Controllers\Api\Tenant; namespace App\Http\Controllers\Api\Tenant;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Photobooth\PhotoboothSendUploaderDownloadRequest;
use App\Http\Resources\Tenant\PhotoboothStatusResource; use App\Http\Resources\Tenant\PhotoboothStatusResource;
use App\Mail\PhotoboothUploaderDownload;
use App\Models\Event; use App\Models\Event;
use App\Models\PhotoboothSetting; use App\Models\PhotoboothSetting;
use App\Services\Photobooth\PhotoboothProvisioner; use App\Services\Photobooth\PhotoboothProvisioner;
use App\Support\LocaleConfig;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
use Illuminate\Validation\ValidationException;
class PhotoboothController extends Controller class PhotoboothController extends Controller
{ {
@@ -74,39 +69,6 @@ class PhotoboothController extends Controller
]); ]);
} }
public function sendUploaderDownloadEmail(PhotoboothSendUploaderDownloadRequest $request, Event $event): JsonResponse
{
$this->assertEventBelongsToTenant($request, $event);
$user = $request->user();
if (! $user || ! $user->email) {
throw ValidationException::withMessages([
'email' => __('No email address is configured for this account.'),
]);
}
$locale = LocaleConfig::canonicalize($user->preferred_locale ?: app()->getLocale());
$eventName = $this->resolveEventName($event, $locale);
$recipientName = $user->fullName ?? $user->name ?? $user->email;
$mail = (new PhotoboothUploaderDownload(
recipientName: $recipientName,
eventName: $eventName,
links: [
'windows' => url('/downloads/PhotoboothUploader-win-x64.exe'),
'macos' => url('/downloads/PhotoboothUploader-macos-x64'),
'linux' => url('/downloads/PhotoboothUploader-linux-x64'),
],
))->locale($locale);
Mail::to($user->email)->queue($mail);
return response()->json([
'message' => __('Download links sent via email.'),
]);
}
protected function resource(Event $event): PhotoboothStatusResource protected function resource(Event $event): PhotoboothStatusResource
{ {
return PhotoboothStatusResource::make([ return PhotoboothStatusResource::make([
@@ -130,30 +92,4 @@ class PhotoboothController extends Controller
return in_array($mode, ['sparkbooth', 'ftp'], true) ? $mode : 'ftp'; return in_array($mode, ['sparkbooth', 'ftp'], true) ? $mode : 'ftp';
} }
protected function resolveEventName(Event $event, ?string $locale = null): string
{
$name = $event->name;
if (is_string($name) && trim($name) !== '') {
return $name;
}
if (is_array($name)) {
$locale = $locale ?: app()->getLocale();
$localized = $name[$locale] ?? null;
if (is_string($localized) && trim($localized) !== '') {
return $localized;
}
foreach ($name as $value) {
if (is_string($value) && trim($value) !== '') {
return $value;
}
}
}
return $event->slug ?: __('emails.photobooth_uploader.event_fallback');
}
} }

View File

@@ -1,18 +0,0 @@
<?php
namespace App\Http\Requests\Photobooth;
use Illuminate\Foundation\Http\FormRequest;
class PhotoboothSendUploaderDownloadRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [];
}
}

View File

@@ -47,7 +47,7 @@ class PhotoboothStatusResource extends JsonResource
'password' => $password, 'password' => $password,
'path' => $eventSetting?->path, 'path' => $eventSetting?->path,
'ftp_url' => $isSparkbooth ? null : $this->buildFtpUrl($eventSetting, $settings, $password), 'ftp_url' => $isSparkbooth ? null : $this->buildFtpUrl($eventSetting, $settings, $password),
'upload_url' => $isSparkbooth ? route('api.v1.photobooth.upload') : null, 'upload_url' => $isSparkbooth ? route('api.v1.photobooth.sparkbooth.upload') : null,
'expires_at' => optional($activeExpires)->toIso8601String(), 'expires_at' => optional($activeExpires)->toIso8601String(),
'rate_limit_per_minute' => (int) $settings->rate_limit_per_minute, 'rate_limit_per_minute' => (int) $settings->rate_limit_per_minute,
'ftp' => [ 'ftp' => [
@@ -62,7 +62,7 @@ class PhotoboothStatusResource extends JsonResource
'username' => $mode === 'sparkbooth' ? $eventSetting?->username : null, 'username' => $mode === 'sparkbooth' ? $eventSetting?->username : null,
'password' => $mode === 'sparkbooth' ? $password : null, 'password' => $mode === 'sparkbooth' ? $password : null,
'expires_at' => $mode === 'sparkbooth' ? optional($eventSetting?->expires_at)->toIso8601String() : null, 'expires_at' => $mode === 'sparkbooth' ? optional($eventSetting?->expires_at)->toIso8601String() : null,
'upload_url' => route('api.v1.photobooth.upload'), 'upload_url' => route('api.v1.photobooth.sparkbooth.upload'),
'response_format' => ($eventSetting?->metadata ?? [])['sparkbooth_response_format'] ?? config('photobooth.sparkbooth.response_format', 'json'), 'response_format' => ($eventSetting?->metadata ?? [])['sparkbooth_response_format'] ?? config('photobooth.sparkbooth.response_format', 'json'),
'metrics' => $sparkMetrics, 'metrics' => $sparkMetrics,
], ],

View File

@@ -1,50 +0,0 @@
<?php
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class PhotoboothUploaderDownload extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
/**
* @param array{windows:string, macos:string, linux:string} $links
*/
public function __construct(
public string $recipientName,
public string $eventName,
public array $links,
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: __('emails.photobooth_uploader.subject', [
'event' => $this->eventName,
]),
);
}
public function content(): Content
{
return new Content(
view: 'emails.photobooth-uploader-download',
with: [
'recipientName' => $this->recipientName,
'eventName' => $this->eventName,
'links' => $this->links,
],
);
}
public function attachments(): array
{
return [];
}
}

View File

@@ -77,8 +77,6 @@ class HelpSyncService
foreach ($articles->groupBy(fn ($article) => $article['audience'].'::'.$article['locale']) as $key => $group) { foreach ($articles->groupBy(fn ($article) => $article['audience'].'::'.$article['locale']) as $key => $group) {
[$audience, $locale] = explode('::', $key); [$audience, $locale] = explode('::', $key);
$path = sprintf('%s/%s/%s/articles.json', $compiledPath, $audience, $locale); $path = sprintf('%s/%s/%s/articles.json', $compiledPath, $audience, $locale);
$directory = sprintf('%s/%s/%s', $compiledPath, $audience, $locale);
Storage::disk($disk)->makeDirectory($directory);
Storage::disk($disk)->put($path, $group->values()->toJson(JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); Storage::disk($disk)->put($path, $group->values()->toJson(JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
Cache::forget($this->cacheKey($audience, $locale)); Cache::forget($this->cacheKey($audience, $locale));
$written[$audience][$locale] = $group->count(); $written[$audience][$locale] = $group->count();

View File

@@ -6,85 +6,5 @@
<Application.Styles> <Application.Styles>
<FluentTheme /> <FluentTheme />
<Style>
<Style.Resources>
<Color x:Key="BrandRose">#FFB6C1</Color>
<Color x:Key="BrandRoseStrong">#FF69B4</Color>
<Color x:Key="BrandRoseSoft">#FFE5EC</Color>
<Color x:Key="BrandGold">#FFD700</Color>
<Color x:Key="BrandSky">#87CEEB</Color>
<Color x:Key="BrandSkySoft">#E0F5FF</Color>
<Color x:Key="BrandNavy">#0F4C75</Color>
<Color x:Key="BrandSlate">#1F2937</Color>
<Color x:Key="BrandCream">#FFF8F5</Color>
<SolidColorBrush x:Key="TextPrimaryBrush" Color="{DynamicResource BrandSlate}" />
<SolidColorBrush x:Key="TextMutedBrush" Color="#6B7280" />
<SolidColorBrush x:Key="CardBorderBrush" Color="{DynamicResource BrandRoseSoft}" />
<SolidColorBrush x:Key="CardBackgroundBrush" Color="#FFFFFF" />
<SolidColorBrush x:Key="AccentBackgroundBrush" Color="{DynamicResource BrandSkySoft}" />
<SolidColorBrush x:Key="InputBorderBrush" Color="{DynamicResource BrandRoseSoft}" />
<SolidColorBrush x:Key="InputBackgroundBrush" Color="#FFFFFF" />
<SolidColorBrush x:Key="PrimaryButtonBrush" Color="{DynamicResource BrandRoseStrong}" />
<SolidColorBrush x:Key="PrimaryButtonTextBrush" Color="#FFFFFF" />
<SolidColorBrush x:Key="SecondaryButtonBrush" Color="{DynamicResource BrandSky}" />
<SolidColorBrush x:Key="SecondaryButtonTextBrush" Color="{DynamicResource BrandNavy}" />
<LinearGradientBrush x:Key="WindowBackgroundBrush" StartPoint="0,0" EndPoint="1,1">
<GradientStop Color="{DynamicResource BrandCream}" Offset="0" />
<GradientStop Color="{DynamicResource BrandRoseSoft}" Offset="0.5" />
<GradientStop Color="{DynamicResource BrandSkySoft}" Offset="1" />
</LinearGradientBrush>
</Style.Resources>
</Style>
<Style Selector="Window">
<Setter Property="Background" Value="{DynamicResource WindowBackgroundBrush}" />
<Setter Property="FontFamily" Value="Inter" />
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}" />
</Style>
<Style Selector="TextBlock.title">
<Setter Property="FontSize" Value="20" />
<Setter Property="FontWeight" Value="SemiBold" />
</Style>
<Style Selector="TextBlock.subtitle">
<Setter Property="FontSize" Value="12" />
<Setter Property="Foreground" Value="{DynamicResource TextMutedBrush}" />
</Style>
<Style Selector="Border.card">
<Setter Property="Background" Value="{DynamicResource CardBackgroundBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource CardBorderBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="12" />
</Style>
<Style Selector="Border.card.accent">
<Setter Property="Background" Value="{DynamicResource AccentBackgroundBrush}" />
</Style>
<Style Selector="TextBox">
<Setter Property="BorderBrush" Value="{DynamicResource InputBorderBrush}" />
<Setter Property="Background" Value="{DynamicResource InputBackgroundBrush}" />
<Setter Property="CornerRadius" Value="8" />
<Setter Property="Padding" Value="10,8" />
</Style>
<Style Selector="Button">
<Setter Property="CornerRadius" Value="8" />
<Setter Property="Padding" Value="12,8" />
</Style>
<Style Selector="Button.primary">
<Setter Property="Background" Value="{DynamicResource PrimaryButtonBrush}" />
<Setter Property="Foreground" Value="{DynamicResource PrimaryButtonTextBrush}" />
</Style>
<Style Selector="Button.secondary">
<Setter Property="Background" Value="{DynamicResource SecondaryButtonBrush}" />
<Setter Property="Foreground" Value="{DynamicResource SecondaryButtonTextBrush}" />
</Style>
</Application.Styles> </Application.Styles>
</Application> </Application>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 B

View File

@@ -4,27 +4,13 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="520" d:DesignHeight="360" mc:Ignorable="d" d:DesignWidth="520" d:DesignHeight="360"
x:Class="PhotoboothUploader.MainWindow" x:Class="PhotoboothUploader.MainWindow"
Width="560" Height="420" Width="520" Height="360"
MinWidth="520" MinHeight="400" Title="Fotospiel Photobooth Uploader">
Title="Die Fotospiel.App - Photobooth Uploader"> <Grid Margin="24" ColumnDefinitions="*,8,*">
<Grid Margin="24,32,24,24" RowDefinitions="Auto,*"> <StackPanel Grid.Column="0" Spacing="12" MaxWidth="420">
<StackPanel Grid.Row="0" Orientation="Horizontal" Spacing="12" VerticalAlignment="Center"> <TextBlock Text="Fotospiel Photobooth Uploader" FontSize="20" FontWeight="SemiBold" />
<Border Width="40" Height="40" Classes="card accent" VerticalAlignment="Center" HorizontalAlignment="Left">
<Image Source="avares://PhotoboothUploader/Assets/logo.png" Width="28" Height="28" HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
<StackPanel Spacing="2">
<TextBlock x:Name="TitleText"
Text="Die Fotospiel.App - Photobooth Uploader"
Classes="title"
PointerPressed="TitleText_PointerPressed" />
<TextBlock Text="Sicherer Upload der Fotobox-Fotos ins Event." Classes="subtitle" />
</StackPanel>
</StackPanel>
<Grid Grid.Row="1" ColumnDefinitions="*,16,*"> <Border Background="#1F000000" Padding="12" CornerRadius="8">
<StackPanel Grid.Column="0" Spacing="16" MaxWidth="420">
<Border Padding="14" Classes="card">
<StackPanel Spacing="6"> <StackPanel Spacing="6">
<TextBlock Text="Schritte" FontWeight="SemiBold" /> <TextBlock Text="Schritte" FontWeight="SemiBold" />
<TextBlock x:Name="StepCodeText" Text="1. Code eingeben" /> <TextBlock x:Name="StepCodeText" Text="1. Code eingeben" />
@@ -33,112 +19,34 @@
</StackPanel> </StackPanel>
</Border> </Border>
<Border Padding="14" Classes="card"> <TextBlock Text="Gib den 6-stelligen Verbindungscode ein." TextWrapping="Wrap" />
<StackPanel Spacing="10"> <TextBox x:Name="CodeBox" MaxLength="6" Watermark="123456" />
<TextBlock Text="Verbindungscode" FontWeight="SemiBold" /> <Button x:Name="ConnectButton" Content="Verbinden" Click="ConnectButton_Click" />
<TextBlock Text="Gib den 6-stelligen Verbindungscode ein." TextWrapping="Wrap" Classes="subtitle" />
<TextBox x:Name="CodeBox" MaxLength="6" Watermark="123456" TextChanged="CodeBox_TextChanged" />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button x:Name="ConnectButton" Content="Verbinden" Click="ConnectButton_Click" Classes="primary" />
<Button x:Name="ReconnectButton" Content="Erneut verbinden" Click="ReconnectButton_Click" IsEnabled="False" Classes="secondary" />
</StackPanel>
</StackPanel>
</Border>
<Border Padding="14" Classes="card"> <StackPanel Spacing="6">
<StackPanel Spacing="8">
<TextBlock Text="Upload-Ordner" FontWeight="SemiBold" /> <TextBlock Text="Upload-Ordner" FontWeight="SemiBold" />
<TextBlock x:Name="FolderText" Text="Noch nicht ausgewählt." TextWrapping="Wrap" Classes="subtitle" /> <TextBlock x:Name="FolderText" Text="Noch nicht ausgewählt." TextWrapping="Wrap" />
<StackPanel Orientation="Horizontal" Spacing="8"> <Button x:Name="PickFolderButton" Content="Ordner auswählen" Click="PickFolderButton_Click" IsEnabled="False" />
<Button x:Name="DslrBoothPresetButton" Content="DSLrBooth" Click="DslrBoothPresetButton_Click" Classes="secondary" IsVisible="False" />
<Button x:Name="SparkboothPresetButton" Content="Sparkbooth" Click="SparkboothPresetButton_Click" Classes="secondary" IsVisible="False" />
</StackPanel> </StackPanel>
<StackPanel Orientation="Horizontal" Spacing="8">
<Button x:Name="PickFolderButton" Content="Ordner auswählen" Click="PickFolderButton_Click" IsEnabled="False" Classes="primary" />
<Button x:Name="TestUploadButton" Content="Test-Upload senden" Click="TestUploadButton_Click" IsEnabled="False" Classes="secondary" />
</StackPanel>
</StackPanel>
</Border>
<ToggleSwitch x:Name="QuietToggle" Content="Ruhiger Modus (nur Fehler anzeigen)" /> <ToggleSwitch x:Name="QuietToggle" Content="Ruhiger Modus (nur Fehler anzeigen)" />
<Border x:Name="AdvancedPanel" Padding="12" Classes="card accent" IsVisible="False">
<StackPanel Spacing="6">
<TextBlock Text="Erweiterte Einstellungen" FontWeight="SemiBold" />
<ToggleSwitch x:Name="SettingsUnlockToggle" Content="Einstellungen entsperren" Checked="SettingsUnlockToggle_Changed" Unchecked="SettingsUnlockToggle_Changed" />
<TextBlock Text="Profile" />
<ComboBox x:Name="ProfilesBox" />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button x:Name="LoadProfileButton" Content="Profil laden" Click="LoadProfileButton_Click" Classes="secondary" />
<Button x:Name="SaveProfileButton" Content="Profil speichern" Click="SaveProfileButton_Click" Classes="secondary" />
</StackPanel>
<TextBlock Text="Basis-URL" />
<TextBox x:Name="BaseUrlBox" Watermark="https://fotospiel.app" />
<TextBlock Text="Max. parallele Uploads" />
<TextBox x:Name="MaxUploadsBox" Watermark="2" />
<TextBlock Text="Upload-Tempo" />
<ComboBox x:Name="UploadTempoBox" SelectedIndex="1">
<ComboBoxItem Content="Schnell (ohne Pause)" />
<ComboBoxItem Content="Normal" />
<ComboBoxItem Content="Sanft (schont Netzwerk)" />
</ComboBox>
<TextBlock Text="Nur diese Dateien (optional)" />
<TextBox x:Name="IncludePatternsBox" Watermark="*.jpg;*.jpeg;*.png" />
<TextBlock Text="Dateien ausschliessen (optional)" />
<TextBox x:Name="ExcludePatternsBox" Watermark="*_preview*;*.tmp" />
<TextBlock Text="Antwort-Format (optional)" />
<ComboBox x:Name="ResponseFormatBox" SelectedIndex="0">
<ComboBoxItem Content="Auto" />
<ComboBoxItem Content="JSON" />
<ComboBoxItem Content="XML" />
</ComboBox>
<TextBlock Text="Manuelle Zugangsdaten (optional)" FontWeight="SemiBold" Margin="0,8,0,0" />
<TextBlock Text="Diese Felder ueberschreiben den Verbindungscode." Classes="subtitle" TextWrapping="Wrap" />
<TextBlock Text="Upload-URL" />
<TextBox x:Name="ManualUploadUrlBox" Watermark="https://fotospiel.app/api/v1/photobooth/upload" />
<TextBlock Text="Benutzername" />
<TextBox x:Name="ManualUsernameBox" />
<TextBlock Text="Passwort" />
<TextBox x:Name="ManualPasswordBox" PasswordChar="•" />
<Button x:Name="TestConnectionButton" Content="Verbindung testen" Click="TestConnectionButton_Click" Classes="secondary" />
<Button x:Name="SaveAdvancedButton" Content="Speichern" Click="SaveAdvancedButton_Click" Classes="primary" />
</StackPanel>
</Border>
</StackPanel> </StackPanel>
<StackPanel Grid.Column="2" Spacing="16" MaxWidth="380" Margin="0,6,0,0"> <StackPanel Grid.Column="2" Spacing="12" MaxWidth="380">
<Border Padding="14" Classes="card accent"> <Border Background="#1F000000" Padding="12" CornerRadius="8">
<StackPanel Spacing="6"> <StackPanel Spacing="6">
<TextBlock Text="Status" FontWeight="SemiBold" /> <TextBlock Text="Status" FontWeight="SemiBold" />
<TextBlock x:Name="StatusText" Text="Nicht verbunden." TextWrapping="Wrap" /> <TextBlock x:Name="StatusText" Text="Nicht verbunden." TextWrapping="Wrap" />
<TextBlock x:Name="LastUploadText" Text="Letzter Upload: —" /> <TextBlock x:Name="LastUploadText" Text="Letzter Upload: —" />
<TextBlock x:Name="QueueStatusText" Text="Warteschlange: 0 · Läuft: 0 · Fehlgeschlagen: 0" />
<TextBlock x:Name="LiveStatusText" Text="Live: —" />
</StackPanel> </StackPanel>
</Border> </Border>
<Border Padding="14" Classes="card">
<StackPanel Spacing="6"> <StackPanel Spacing="6">
<TextBlock Text="Details" FontWeight="SemiBold" />
<TextBlock x:Name="EventNameText" Text="Event: —" TextWrapping="Wrap" />
<TextBlock x:Name="BaseUrlText" Text="Basis-URL: —" TextWrapping="Wrap" />
<TextBlock x:Name="VersionText" Text="App-Version: —" />
<TextBlock x:Name="ConnectExpiryText" Text="Verbindungscode: —" TextWrapping="Wrap" />
<TextBlock x:Name="FolderHealthText" Text="Ordner: —" TextWrapping="Wrap" />
<TextBlock x:Name="DiskFreeText" Text="Freier Speicher: —" TextWrapping="Wrap" />
<TextBlock x:Name="LastSeenText" Text="Letzte Datei: —" TextWrapping="Wrap" />
<TextBlock x:Name="LastErrorText" Text="Letzter Fehler: —" TextWrapping="Wrap" />
<Button x:Name="LogCopyButton" Content="Log kopieren" Click="LogCopyButton_Click" Classes="secondary" />
</StackPanel>
</Border>
<Border Padding="14" Classes="card">
<StackPanel Spacing="8">
<TextBlock Text="Letzte Uploads" FontWeight="SemiBold" /> <TextBlock Text="Letzte Uploads" FontWeight="SemiBold" />
<ItemsControl x:Name="RecentUploadsList" ItemsSource="{Binding RecentUploads}"> <ItemsControl x:Name="RecentUploadsList" ItemsSource="{Binding RecentUploads}">
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate> <DataTemplate>
<Border Background="#14FFFFFF" Padding="10" CornerRadius="8" Margin="0,0,0,8"> <Border Background="#14000000" Padding="8" CornerRadius="6" Margin="0,0,0,6">
<Grid ColumnDefinitions="*,Auto" RowDefinitions="Auto,Auto"> <Grid ColumnDefinitions="*,Auto" RowDefinitions="Auto,Auto">
<TextBlock Grid.Column="0" Grid.Row="0" Text="{Binding FileName}" /> <TextBlock Grid.Column="0" Grid.Row="0" Text="{Binding FileName}" />
<TextBlock Grid.Column="1" Grid.Row="0" Text="{Binding StatusLabel}" /> <TextBlock Grid.Column="1" Grid.Row="0" Text="{Binding StatusLabel}" />
@@ -148,13 +56,8 @@
</DataTemplate> </DataTemplate>
</ItemsControl.ItemTemplate> </ItemsControl.ItemTemplate>
</ItemsControl> </ItemsControl>
<StackPanel Orientation="Horizontal" Spacing="8"> <Button x:Name="RetryFailedButton" Content="Fehlgeschlagene erneut senden" Click="RetryFailedButton_Click" IsEnabled="False" />
<Button x:Name="RetryFailedButton" Content="Fehlgeschlagene erneut senden" Click="RetryFailedButton_Click" IsEnabled="False" Classes="secondary" />
<Button x:Name="ClearFailedButton" Content="Fehlerliste leeren" Click="ClearFailedButton_Click" IsEnabled="False" Classes="secondary" />
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>
</Border>
</StackPanel>
</Grid>
</Grid> </Grid>
</Window> </Window>

View File

@@ -13,9 +13,6 @@ public sealed class PhotoboothConnectResponse
public sealed class PhotoboothConnectPayload public sealed class PhotoboothConnectPayload
{ {
[JsonPropertyName("event_name")]
public string? EventName { get; set; }
[JsonPropertyName("upload_url")] [JsonPropertyName("upload_url")]
public string? UploadUrl { get; set; } public string? UploadUrl { get; set; }

View File

@@ -1,26 +0,0 @@
using System;
namespace PhotoboothUploader.Models;
public sealed class PhotoboothProfile
{
public string? Label { get; set; }
public string? EventName { get; set; }
public string? BaseUrl { get; set; }
public string? UploadUrl { get; set; }
public string? Username { get; set; }
public string? Password { get; set; }
public string? ResponseFormat { get; set; }
public string? WatchFolder { get; set; }
public string? IncludePatterns { get; set; }
public string? ExcludePatterns { get; set; }
public int MaxConcurrentUploads { get; set; } = 2;
public int UploadDelayMs { get; set; } = 500;
public string DisplayName
=> !string.IsNullOrWhiteSpace(Label)
? Label
: !string.IsNullOrWhiteSpace(EventName)
? EventName
: UploadUrl ?? BaseUrl ?? "Profil";
}

View File

@@ -1,29 +1,11 @@
using System;
using System.Collections.Generic;
namespace PhotoboothUploader.Models; namespace PhotoboothUploader.Models;
public sealed class PhotoboothSettings public sealed class PhotoboothSettings
{ {
public string? BaseUrl { get; set; } public string? BaseUrl { get; set; }
public string? EventName { get; set; }
public string? UploadUrl { get; set; } public string? UploadUrl { get; set; }
public string? Username { get; set; } public string? Username { get; set; }
public string? Password { get; set; } public string? Password { get; set; }
public string? ResponseFormat { get; set; } public string? ResponseFormat { get; set; }
public string? WatchFolder { get; set; } public string? WatchFolder { get; set; }
public string? IncludePatterns { get; set; }
public string? ExcludePatterns { get; set; }
public List<string> PendingUploads { get; set; } = new();
public Dictionary<string, string> UploadedFiles { get; set; } = new(StringComparer.OrdinalIgnoreCase);
public List<PhotoboothProfile> Profiles { get; set; } = new();
public string? ConnectExpiresAt { get; set; }
public string? LastSeenFile { get; set; }
public string? LastSeenAt { get; set; }
public string? LastError { get; set; }
public string? LastErrorAt { get; set; }
public int MaxConcurrentUploads { get; set; } = 2;
public int UploadDelayMs { get; set; } = 500;
public double WindowWidth { get; set; }
public double WindowHeight { get; set; }
} }

View File

@@ -4,7 +4,6 @@
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ApplicationManifest>app.manifest</ApplicationManifest> <ApplicationManifest>app.manifest</ApplicationManifest>
<ApplicationIcon>Assets\app.ico</ApplicationIcon>
<AvaloniaUseCompiledBindingsByDefault>false</AvaloniaUseCompiledBindingsByDefault> <AvaloniaUseCompiledBindingsByDefault>false</AvaloniaUseCompiledBindingsByDefault>
</PropertyGroup> </PropertyGroup>
@@ -19,10 +18,4 @@
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets> <PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
</PackageReference> </PackageReference>
</ItemGroup> </ItemGroup>
<ItemGroup>
<None Include="Assets\app.ico" />
<AvaloniaResource Include="Assets\logo.png" />
<AvaloniaResource Include="Assets\sample-upload.png" />
</ItemGroup>
</Project> </Project>

View File

@@ -1,7 +1,5 @@
using System; using System;
using System.Net;
using System.Net.Http; using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Text.Json; using System.Text.Json;
using System.Threading; using System.Threading;
@@ -12,111 +10,41 @@ namespace PhotoboothUploader.Services;
public sealed class PhotoboothConnectClient public sealed class PhotoboothConnectClient
{ {
private const int MaxRetries = 2;
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(10);
private readonly HttpClient _httpClient; private readonly HttpClient _httpClient;
private readonly JsonSerializerOptions _jsonOptions = new() private readonly JsonSerializerOptions _jsonOptions = new()
{ {
PropertyNameCaseInsensitive = true, PropertyNameCaseInsensitive = true,
}; };
public PhotoboothConnectClient(string baseUrl, string userAgent) public PhotoboothConnectClient(string baseUrl)
{ {
_httpClient = new HttpClient _httpClient = new HttpClient
{ {
BaseAddress = new Uri(baseUrl), BaseAddress = new Uri(baseUrl),
Timeout = DefaultTimeout,
}; };
_httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd(userAgent);
} }
public async Task<PhotoboothConnectResponse> RedeemAsync(string code, CancellationToken cancellationToken = default) public async Task<PhotoboothConnectResponse> RedeemAsync(string code, CancellationToken cancellationToken = default)
{ {
var request = new { code }; var response = await _httpClient.PostAsJsonAsync("/api/v1/photobooth/connect", new { code }, cancellationToken);
var payload = await response.Content.ReadFromJsonAsync<PhotoboothConnectResponse>(_jsonOptions, cancellationToken);
for (var attempt = 0; attempt <= MaxRetries; attempt++) if (payload is null)
{
try
{
using var response = await _httpClient.PostAsJsonAsync("/api/v1/photobooth/connect", request, cancellationToken);
var payload = await ReadPayloadAsync(response, cancellationToken);
if (response.IsSuccessStatusCode)
{
return payload ?? Fail(response.ReasonPhrase ?? "Verbindung fehlgeschlagen.");
}
if (response.StatusCode is HttpStatusCode.UnprocessableEntity or HttpStatusCode.Conflict or HttpStatusCode.Unauthorized)
{
return payload ?? Fail(response.ReasonPhrase ?? "Verbindung fehlgeschlagen.");
}
if (attempt < MaxRetries && IsTransientStatus(response.StatusCode))
{
await Task.Delay(GetRetryDelay(attempt), cancellationToken);
continue;
}
return payload ?? Fail(response.ReasonPhrase ?? "Verbindung fehlgeschlagen.");
}
catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested)
{
if (attempt < MaxRetries)
{
await Task.Delay(GetRetryDelay(attempt), cancellationToken);
continue;
}
return Fail("Zeitüberschreitung bei der Verbindung.");
}
catch (HttpRequestException)
{
if (attempt < MaxRetries)
{
await Task.Delay(GetRetryDelay(attempt), cancellationToken);
continue;
}
return Fail("Netzwerkfehler. Bitte Verbindung prüfen.");
}
catch (JsonException)
{
return Fail("Serverantwort konnte nicht gelesen werden.");
}
}
return Fail("Verbindung fehlgeschlagen.");
}
private async Task<PhotoboothConnectResponse?> ReadPayloadAsync(HttpResponseMessage response, CancellationToken cancellationToken)
{
if (response.Content.Headers.ContentLength == 0)
{
return null;
}
return await response.Content.ReadFromJsonAsync<PhotoboothConnectResponse>(_jsonOptions, cancellationToken);
}
private static bool IsTransientStatus(HttpStatusCode statusCode)
{
return statusCode is HttpStatusCode.RequestTimeout or HttpStatusCode.TooManyRequests
or HttpStatusCode.BadGateway or HttpStatusCode.ServiceUnavailable or HttpStatusCode.GatewayTimeout
or HttpStatusCode.InternalServerError;
}
private static TimeSpan GetRetryDelay(int attempt)
{
return TimeSpan.FromMilliseconds(500 * (attempt + 1));
}
private static PhotoboothConnectResponse Fail(string message)
{ {
return new PhotoboothConnectResponse return new PhotoboothConnectResponse
{ {
Message = message, Message = response.ReasonPhrase ?? "Verbindung fehlgeschlagen.",
}; };
} }
if (!response.IsSuccessStatusCode)
{
return new PhotoboothConnectResponse
{
Message = payload.Message ?? "Verbindung fehlgeschlagen.",
};
}
return payload;
}
} }

View File

@@ -14,7 +14,6 @@ public sealed class SettingsStore
}; };
public string SettingsPath { get; } public string SettingsPath { get; }
public string LogPath { get; }
public SettingsStore() public SettingsStore()
{ {
@@ -25,7 +24,6 @@ public sealed class SettingsStore
Directory.CreateDirectory(basePath); Directory.CreateDirectory(basePath);
SettingsPath = Path.Combine(basePath, "settings.json"); SettingsPath = Path.Combine(basePath, "settings.json");
LogPath = Path.Combine(basePath, "uploader.log");
} }
public PhotoboothSettings Load() public PhotoboothSettings Load()

View File

@@ -1,6 +1,5 @@
using System; using System;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO; using System.IO;
using System.Net.Http; using System.Net.Http;
using System.Net.Http.Headers; using System.Net.Http.Headers;
@@ -13,38 +12,21 @@ namespace PhotoboothUploader.Services;
public sealed class UploadService public sealed class UploadService
{ {
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(20);
private static readonly TimeSpan RetryBaseDelay = TimeSpan.FromSeconds(2);
private const int MaxRetries = 2;
private readonly Channel<string> _queue = Channel.CreateUnbounded<string>(); private readonly Channel<string> _queue = Channel.CreateUnbounded<string>();
private readonly ConcurrentDictionary<string, byte> _pending = new(StringComparer.OrdinalIgnoreCase); private readonly ConcurrentDictionary<string, byte> _pending = new(StringComparer.OrdinalIgnoreCase);
private string _userAgent = "FotospielPhotoboothUploader";
private CancellationTokenSource? _cts; private CancellationTokenSource? _cts;
private readonly List<Task> _workers = new();
public void Configure(string userAgent)
{
if (!string.IsNullOrWhiteSpace(userAgent))
{
_userAgent = userAgent;
}
}
public void Start( public void Start(
PhotoboothSettings settings, PhotoboothSettings settings,
Action<string> onQueued, Action<string> onQueued,
Action<string> onUploading, Action<string> onUploading,
Action<string> onSuccess, Action<string> onSuccess,
Action<string, string> onFailure) Action<string> onFailure)
{ {
Stop(); Stop();
_cts = new CancellationTokenSource(); _cts = new CancellationTokenSource();
var workerCount = GetWorkerCount(settings); _ = Task.Run(() => WorkerAsync(settings, onQueued, onUploading, onSuccess, onFailure, _cts.Token));
for (var i = 0; i < workerCount; i++)
{
_workers.Add(Task.Run(() => WorkerAsync(settings, onQueued, onUploading, onSuccess, onFailure, _cts.Token)));
}
} }
public void Stop() public void Stop()
@@ -52,7 +34,6 @@ public sealed class UploadService
_cts?.Cancel(); _cts?.Cancel();
_cts = null; _cts = null;
_pending.Clear(); _pending.Clear();
_workers.Clear();
} }
public void Enqueue(string path, Action<string> onQueued) public void Enqueue(string path, Action<string> onQueued)
@@ -71,7 +52,7 @@ public sealed class UploadService
Action<string> onQueued, Action<string> onQueued,
Action<string> onUploading, Action<string> onUploading,
Action<string> onSuccess, Action<string> onSuccess,
Action<string, string> onFailure, Action<string> onFailure,
CancellationToken token) CancellationToken token)
{ {
if (string.IsNullOrWhiteSpace(settings.UploadUrl)) if (string.IsNullOrWhiteSpace(settings.UploadUrl))
@@ -80,9 +61,6 @@ public sealed class UploadService
} }
using var client = new HttpClient(); using var client = new HttpClient();
client.Timeout = DefaultTimeout;
client.DefaultRequestHeaders.UserAgent.ParseAdd(_userAgent);
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
while (await _queue.Reader.WaitToReadAsync(token)) while (await _queue.Reader.WaitToReadAsync(token))
{ {
@@ -91,72 +69,58 @@ public sealed class UploadService
try try
{ {
onUploading(path); onUploading(path);
var error = await UploadWithRetryAsync(client, settings, path, token); await WaitForFileReadyAsync(path, token);
if (error is null) await UploadAsync(client, settings, path, token);
{
onSuccess(path); onSuccess(path);
} }
else
{
onFailure(path, error);
}
}
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
return; return;
} }
catch
{
onFailure(path);
}
finally finally
{ {
_pending.TryRemove(path, out _); _pending.TryRemove(path, out _);
if (settings.UploadDelayMs > 0)
{
await Task.Delay(settings.UploadDelayMs, token);
}
} }
} }
} }
} }
private static async Task<string?> UploadWithRetryAsync( private static async Task WaitForFileReadyAsync(string path, CancellationToken token)
HttpClient client,
PhotoboothSettings settings,
string path,
CancellationToken token)
{ {
for (var attempt = 0; attempt <= MaxRetries; attempt++) var lastSize = -1L;
{
var attemptError = await UploadOnceAsync(client, settings, path, token);
if (attemptError.Success)
{
return null;
}
if (!attemptError.Retryable || attempt >= MaxRetries) for (var attempts = 0; attempts < 10; attempts++)
{ {
return attemptError.Error ?? "Upload fehlgeschlagen."; token.ThrowIfCancellationRequested();
}
await Task.Delay(GetRetryDelay(attempt), token);
}
return "Upload fehlgeschlagen.";
}
private static async Task<UploadAttempt> UploadOnceAsync(
HttpClient client,
PhotoboothSettings settings,
string path,
CancellationToken token)
{
var readyError = await WaitForFileReadyAsync(path, token);
if (readyError is not null)
{
return UploadAttempt.Fail(readyError, retryable: false);
}
if (!File.Exists(path)) if (!File.Exists(path))
{ {
return UploadAttempt.Fail("Datei nicht gefunden.", retryable: false); await Task.Delay(500, token);
continue;
}
var info = new FileInfo(path);
var size = info.Length;
if (size > 0 && size == lastSize)
{
return;
}
lastSize = size;
await Task.Delay(700, token);
}
}
private static async Task UploadAsync(HttpClient client, PhotoboothSettings settings, string path, CancellationToken token)
{
if (!File.Exists(path))
{
return;
} }
using var content = new MultipartFormDataContent(); using var content = new MultipartFormDataContent();
@@ -181,61 +145,8 @@ public sealed class UploadService
fileContent.Headers.ContentType = new MediaTypeHeaderValue(ResolveContentType(path)); fileContent.Headers.ContentType = new MediaTypeHeaderValue(ResolveContentType(path));
content.Add(fileContent, "media", Path.GetFileName(path)); content.Add(fileContent, "media", Path.GetFileName(path));
try
{
var response = await client.PostAsync(settings.UploadUrl, content, token); var response = await client.PostAsync(settings.UploadUrl, content, token);
response.EnsureSuccessStatusCode();
if (response.IsSuccessStatusCode)
{
return UploadAttempt.Ok();
}
var body = await ReadResponseBodyAsync(response, token);
var status = $"{(int)response.StatusCode} {response.ReasonPhrase}".Trim();
var message = string.IsNullOrWhiteSpace(body) ? status : $"{status} {body}";
return UploadAttempt.Fail(message, IsRetryableStatus(response.StatusCode));
}
catch (TaskCanceledException) when (!token.IsCancellationRequested)
{
return UploadAttempt.Fail("Zeitüberschreitung beim Upload.", retryable: true);
}
catch (HttpRequestException)
{
return UploadAttempt.Fail("Netzwerkfehler beim Upload.", retryable: true);
}
catch (IOException)
{
return UploadAttempt.Fail("Datei konnte nicht gelesen werden.", retryable: false);
}
}
private static async Task<string?> WaitForFileReadyAsync(string path, CancellationToken token)
{
var lastSize = -1L;
for (var attempts = 0; attempts < 10; attempts++)
{
token.ThrowIfCancellationRequested();
if (!File.Exists(path))
{
await Task.Delay(500, token);
continue;
}
var info = new FileInfo(path);
var size = info.Length;
if (size > 0 && size == lastSize)
{
return null;
}
lastSize = size;
await Task.Delay(700, token);
}
return "Datei ist noch in Bearbeitung.";
} }
private static string ResolveContentType(string path) private static string ResolveContentType(string path)
@@ -247,51 +158,4 @@ public sealed class UploadService
_ => "image/jpeg", _ => "image/jpeg",
}; };
} }
private static bool IsRetryableStatus(System.Net.HttpStatusCode statusCode)
{
var numeric = (int)statusCode;
return numeric >= 500 || statusCode is System.Net.HttpStatusCode.RequestTimeout or System.Net.HttpStatusCode.TooManyRequests;
}
private static TimeSpan GetRetryDelay(int attempt)
{
var jitter = TimeSpan.FromMilliseconds(Random.Shared.Next(100, 350));
return TimeSpan.FromMilliseconds(RetryBaseDelay.TotalMilliseconds * Math.Pow(2, attempt)) + jitter;
}
private static async Task<string?> ReadResponseBodyAsync(HttpResponseMessage response, CancellationToken token)
{
if (response.Content is null)
{
return null;
}
var body = await response.Content.ReadAsStringAsync(token);
if (string.IsNullOrWhiteSpace(body))
{
return null;
}
body = body.Trim();
return body.Length > 200 ? body[..200] + "…" : body;
}
private static int GetWorkerCount(PhotoboothSettings settings)
{
var count = settings.MaxConcurrentUploads;
if (count < 1)
{
return 1;
}
return count > 5 ? 5 : count;
}
private readonly record struct UploadAttempt(bool Success, bool Retryable, string? Error)
{
public static UploadAttempt Ok() => new(true, false, null);
public static UploadAttempt Fail(string error, bool retryable) => new(false, retryable, error);
}
} }

View File

@@ -24,8 +24,7 @@
"simplesoftwareio/simple-qrcode": "^4.2", "simplesoftwareio/simple-qrcode": "^4.2",
"spatie/laravel-translatable": "^6.11", "spatie/laravel-translatable": "^6.11",
"staudenmeir/belongs-to-through": "^2.17", "staudenmeir/belongs-to-through": "^2.17",
"stripe/stripe-php": "*", "stripe/stripe-php": "*"
"symfony/yaml": "^7.0"
}, },
"require-dev": { "require-dev": {
"fakerphp/faker": "^1.23", "fakerphp/faker": "^1.23",

154
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "5e1d60e650853d6113b01e1adaf49d65", "content-hash": "c1a772e5fe6f8d5c92fdbbea232f9f78",
"packages": [ "packages": [
{ {
"name": "anourvalar/eloquent-serialize", "name": "anourvalar/eloquent-serialize",
@@ -10043,82 +10043,6 @@
], ],
"time": "2025-10-27T20:36:44+00:00" "time": "2025-10-27T20:36:44+00:00"
}, },
{
"name": "symfony/yaml",
"version": "v7.4.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/yaml.git",
"reference": "24dd4de28d2e3988b311751ac49e684d783e2345"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/yaml/zipball/24dd4de28d2e3988b311751ac49e684d783e2345",
"reference": "24dd4de28d2e3988b311751ac49e684d783e2345",
"shasum": ""
},
"require": {
"php": ">=8.2",
"symfony/deprecation-contracts": "^2.5|^3",
"symfony/polyfill-ctype": "^1.8"
},
"conflict": {
"symfony/console": "<6.4"
},
"require-dev": {
"symfony/console": "^6.4|^7.0|^8.0"
},
"bin": [
"Resources/bin/yaml-lint"
],
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\Yaml\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Loads and dumps YAML files",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/yaml/tree/v7.4.1"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2025-12-04T18:11:45+00:00"
},
{ {
"name": "tijsverkoyen/css-to-inline-styles", "name": "tijsverkoyen/css-to-inline-styles",
"version": "v2.3.0", "version": "v2.3.0",
@@ -12928,6 +12852,82 @@
], ],
"time": "2024-10-20T05:08:20+00:00" "time": "2024-10-20T05:08:20+00:00"
}, },
{
"name": "symfony/yaml",
"version": "v7.4.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/yaml.git",
"reference": "24dd4de28d2e3988b311751ac49e684d783e2345"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/yaml/zipball/24dd4de28d2e3988b311751ac49e684d783e2345",
"reference": "24dd4de28d2e3988b311751ac49e684d783e2345",
"shasum": ""
},
"require": {
"php": ">=8.2",
"symfony/deprecation-contracts": "^2.5|^3",
"symfony/polyfill-ctype": "^1.8"
},
"conflict": {
"symfony/console": "<6.4"
},
"require-dev": {
"symfony/console": "^6.4|^7.0|^8.0"
},
"bin": [
"Resources/bin/yaml-lint"
],
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\Yaml\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Loads and dumps YAML files",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/yaml/tree/v7.4.1"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2025-12-04T18:11:45+00:00"
},
{ {
"name": "theseer/tokenizer", "name": "theseer/tokenizer",
"version": "1.3.1", "version": "1.3.1",

View File

@@ -106,24 +106,6 @@ services:
condition: service_healthy condition: service_healthy
restart: "no" restart: "no"
photobooth-uploader-build:
image: mcr.microsoft.com/dotnet/sdk:10.0
working_dir: /var/www/html
command:
- bash
- -lc
- /var/www/html/scripts/build-photobooth-uploader.sh
environment:
DOTNET_CLI_TELEMETRY_OPTOUT: "1"
NUGET_PACKAGES: /root/.nuget/packages
volumes:
- app-code:/var/www/html
- nuget-cache:/root/.nuget/packages
depends_on:
app:
condition: service_healthy
restart: "no"
help-sync: help-sync:
image: ${APP_IMAGE_REPO:-fotospiel-app}:${APP_IMAGE_TAG:-latest} image: ${APP_IMAGE_REPO:-fotospiel-app}:${APP_IMAGE_TAG:-latest}
env_file: env_file:
@@ -358,7 +340,6 @@ volumes:
external: true external: true
name: fotospiel-${APP_ENV:-prod}-storage name: fotospiel-${APP_ENV:-prod}-storage
app-bootstrap-cache: app-bootstrap-cache:
nuget-cache:
photobooth-import: photobooth-import:
photobooth-ftp-auth: photobooth-ftp-auth:
mysql-data: mysql-data:

View File

@@ -53,23 +53,6 @@ refresh_config_cache() {
php artisan view:clear >/dev/null 2>&1 || true php artisan view:clear >/dev/null 2>&1 || true
} }
ensure_help_cache() {
cd "$APP_TARGET"
if [[ "${HELP_SYNC_ON_BOOT:-auto}" == "0" ]]; then
return
fi
if [[ "${HELP_SYNC_ON_BOOT:-auto}" == "1" ]]; then
php artisan help:sync >/dev/null 2>&1 || true
return
fi
if ! compgen -G "$APP_TARGET/storage/app/help/*/*/articles.json" > /dev/null; then
php artisan help:sync >/dev/null 2>&1 || true
fi
}
wait_for_service() { wait_for_service() {
local name="$1" host="$2" port="$3" timeout="$4" local name="$1" host="$2" port="$3" timeout="$4"
local start local start
@@ -137,7 +120,6 @@ ensure_helper_scripts
prepare_storage prepare_storage
refresh_config_cache refresh_config_cache
wait_for_dependencies wait_for_dependencies
ensure_help_cache
cd "$APP_TARGET" cd "$APP_TARGET"
exec "$@" exec "$@"

View File

@@ -20,12 +20,6 @@ server {
fastcgi_pass app:9000; fastcgi_pass app:9000;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info; fastcgi_param PATH_INFO $fastcgi_path_info;
fastcgi_param HTTP_X_FORWARDED_PROTO $http_x_forwarded_proto;
fastcgi_param HTTP_X_FORWARDED_HOST $http_x_forwarded_host;
fastcgi_param HTTP_X_FORWARDED_FOR $proxy_add_x_forwarded_for;
fastcgi_param HTTP_HOST $host;
fastcgi_param HTTP_X_FORWARDED_PORT $server_port;
fastcgi_param HTTPS $http_x_forwarded_proto;
fastcgi_buffer_size 32k; fastcgi_buffer_size 32k;
fastcgi_buffers 8 16k; fastcgi_buffers 8 16k;
} }

View File

@@ -84,7 +84,7 @@ php artisan photobooth:ingest --event=123 --max-files=20
Use this when Sparkbooth runs in “Custom Upload” mode instead of FTP. Use this when Sparkbooth runs in “Custom Upload” mode instead of FTP.
- Endpoint: `POST /api/v1/photobooth/upload` - Endpoint: `POST /api/v1/photobooth/sparkbooth/upload`
- Auth: per-event username/password (set in Event Admin → Fotobox-Uploads; switch mode to “Sparkbooth”). - Auth: per-event username/password (set in Event Admin → Fotobox-Uploads; switch mode to “Sparkbooth”).
- Body (multipart/form-data): `media` (file or base64), `username`, `password`, optionally `name`, `email`, `message`. - Body (multipart/form-data): `media` (file or base64), `username`, `password`, optionally `name`, `email`, `message`.
- Response: - Response:
@@ -99,7 +99,7 @@ Use this when Sparkbooth runs in “Custom Upload” mode instead of FTP.
Example cURL (JSON response): Example cURL (JSON response):
```bash ```bash
curl -X POST https://app.example.com/api/v1/photobooth/upload \ curl -X POST https://app.example.com/api/v1/photobooth/sparkbooth/upload \
-F "media=@/path/to/photo.jpg" \ -F "media=@/path/to/photo.jpg" \
-F "username=PB123" \ -F "username=PB123" \
-F "password=SECRET" \ -F "password=SECRET" \
@@ -109,7 +109,7 @@ curl -X POST https://app.example.com/api/v1/photobooth/upload \
Example cURL (request XML response): Example cURL (request XML response):
```bash ```bash
curl -X POST https://app.example.com/api/v1/photobooth/upload \ curl -X POST https://app.example.com/api/v1/photobooth/sparkbooth/upload \
-F "media=@/path/to/photo.jpg" \ -F "media=@/path/to/photo.jpg" \
-F "username=PB123" \ -F "username=PB123" \
-F "password=SECRET" \ -F "password=SECRET" \

View File

@@ -65,25 +65,6 @@ return [
'benefit4' => 'Unterstuetzung, wenn du sie brauchst', 'benefit4' => 'Unterstuetzung, wenn du sie brauchst',
'footer' => 'Brauchst du Hilfe? Antworte einfach auf diese E-Mail.', 'footer' => 'Brauchst du Hilfe? Antworte einfach auf diese E-Mail.',
], ],
'photobooth_uploader' => [
'subject' => 'Fotospiel Uploader App fuer :event',
'preheader' => 'Download-Links fuer die Fotospiel Photobooth Uploader App.',
'hero_title' => 'Hallo :name,',
'hero_subtitle' => 'Deine Uploader App fuer :event ist bereit.',
'body' => 'Hier findest du die Download-Links fuer die Fotospiel Photobooth Uploader App. Installiere die passende Version auf dem Photobooth-PC, bevor dein Event startet.',
'downloads_title' => 'Download-Links',
'downloads' => [
'windows' => 'Windows (x64)',
'macos' => 'macOS (x64)',
'linux' => 'Linux (x64)',
],
'cta_windows' => 'Download fuer Windows',
'cta_macos' => 'Download fuer macOS',
'cta_linux' => 'Download fuer Linux',
'credentials_hint' => 'Die Zugangsdaten bleiben im Admin-Dashboard. Erstelle einen Verbindungscode, sobald du die App koppeln moechtest.',
'footer' => 'Fragen? Antworte einfach auf diese E-Mail.',
'event_fallback' => 'dein Event',
],
'package_limits' => [ 'package_limits' => [
'package_fallback' => 'Paket', 'package_fallback' => 'Paket',
'team_fallback' => 'dein Team', 'team_fallback' => 'dein Team',

View File

@@ -65,25 +65,6 @@ return [
'benefit4' => 'Friendly support whenever you need help', 'benefit4' => 'Friendly support whenever you need help',
'footer' => 'Need help? Reply to this email.', 'footer' => 'Need help? Reply to this email.',
], ],
'photobooth_uploader' => [
'subject' => 'Fotospiel Uploader App for :event',
'preheader' => 'Download links for the Fotospiel Photobooth Uploader.',
'hero_title' => 'Hi :name,',
'hero_subtitle' => 'Your uploader app for :event is ready.',
'body' => 'Here are the download links for the Fotospiel Photobooth Uploader. Install the right version on the photobooth PC before your event starts.',
'downloads_title' => 'Download links',
'downloads' => [
'windows' => 'Windows (x64)',
'macos' => 'macOS (x64)',
'linux' => 'Linux (x64)',
],
'cta_windows' => 'Download for Windows',
'cta_macos' => 'Download for macOS',
'cta_linux' => 'Download for Linux',
'credentials_hint' => 'Connection credentials stay in the admin dashboard. Generate a connect code when you are ready to pair the app.',
'footer' => 'Questions? Reply to this email and we will help.',
'event_fallback' => 'your event',
],
'package_limits' => [ 'package_limits' => [
'package_fallback' => 'package', 'package_fallback' => 'package',
'team_fallback' => 'your team', 'team_fallback' => 'your team',

View File

@@ -121,7 +121,6 @@
--guest-radius: 14px; --guest-radius: 14px;
--guest-button-style: filled; --guest-button-style: filled;
--guest-link: #007aff; --guest-link: #007aff;
--guest-font-scale: 1;
--guest-font-family: 'Montserrat', 'Inter', 'Helvetica Neue', system-ui, -apple-system, BlinkMacSystemFont, sans-serif; --guest-font-family: 'Montserrat', 'Inter', 'Helvetica Neue', system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
--guest-body-font: 'Montserrat', 'Inter', 'Helvetica Neue', system-ui, -apple-system, BlinkMacSystemFont, sans-serif; --guest-body-font: 'Montserrat', 'Inter', 'Helvetica Neue', system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
--guest-heading-font: 'Playfair Display', 'Montserrat', 'Inter', 'Helvetica Neue', system-ui, -apple-system, BlinkMacSystemFont, sans-serif; --guest-heading-font: 'Playfair Display', 'Montserrat', 'Inter', 'Helvetica Neue', system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
@@ -512,21 +511,6 @@ h4,
--sidebar-ring: oklch(0.439 0 0); --sidebar-ring: oklch(0.439 0 0);
} }
html.guest-theme {
--background: var(--guest-background);
--card: var(--guest-surface);
--popover: var(--guest-surface);
background-color: var(--guest-background);
font-size: calc(16px * var(--guest-font-scale, 1));
}
html.guest-theme.dark {
--background: var(--guest-background);
--card: var(--guest-surface);
--popover: var(--guest-surface);
background-color: var(--guest-background);
}
@keyframes mobile-shimmer { @keyframes mobile-shimmer {
0% { 0% {
background-position: -200% 0; background-position: -200% 0;

View File

@@ -218,11 +218,6 @@ export type PhotoboothStatus = {
metrics?: PhotoboothStatusMetrics | null; metrics?: PhotoboothStatusMetrics | null;
}; };
export type PhotoboothConnectCode = {
code: string;
expires_at: string | null;
};
export type EventAddonCheckout = { export type EventAddonCheckout = {
addon_key: string; addon_key: string;
quantity?: number; quantity?: number;
@@ -2046,35 +2041,6 @@ export async function disableEventPhotobooth(slug: string, options?: { mode?: 'f
); );
} }
export async function createEventPhotoboothConnectCode(
slug: string,
options?: { expires_in_minutes?: number }
): Promise<PhotoboothConnectCode> {
const body = options ? JSON.stringify(options) : undefined;
const headers = body ? { 'Content-Type': 'application/json' } : undefined;
const response = await authorizedFetch(`${photoboothEndpoint(slug)}/connect-codes`, {
method: 'POST',
body,
headers,
});
const data = await jsonOrThrow<{ data?: JsonValue }>(response, 'Failed to create photobooth connect code');
const record = (data.data ?? {}) as Record<string, JsonValue>;
return {
code: typeof record.code === 'string' ? record.code : '',
expires_at: typeof record.expires_at === 'string' ? record.expires_at : null,
};
}
export async function sendEventPhotoboothUploaderEmail(slug: string): Promise<void> {
const response = await authorizedFetch(`${photoboothEndpoint(slug)}/uploader-email`, {
method: 'POST',
});
await jsonOrThrow<{ message?: string }>(response, 'Failed to send photobooth uploader email');
}
export async function submitTenantFeedback(payload: { export async function submitTenantFeedback(payload: {
category: string; category: string;
sentiment?: 'positive' | 'neutral' | 'negative'; sentiment?: 'positive' | 'neutral' | 'negative';

View File

@@ -13,7 +13,6 @@ export const ADMIN_AUTH_CALLBACK_PATH = adminPath('/auth/callback');
export const ADMIN_EVENTS_PATH = adminPath('/mobile/events'); export const ADMIN_EVENTS_PATH = adminPath('/mobile/events');
export const ADMIN_SETTINGS_PATH = adminPath('/mobile/settings'); export const ADMIN_SETTINGS_PATH = adminPath('/mobile/settings');
export const ADMIN_PROFILE_PATH = adminPath('/mobile/profile'); export const ADMIN_PROFILE_PATH = adminPath('/mobile/profile');
export const ADMIN_PROFILE_ACCOUNT_PATH = adminPath('/mobile/profile/account');
export const ADMIN_FAQ_PATH = adminPath('/mobile/help'); export const ADMIN_FAQ_PATH = adminPath('/mobile/help');
export const ADMIN_BILLING_PATH = adminPath('/mobile/billing'); export const ADMIN_BILLING_PATH = adminPath('/mobile/billing');
export const ADMIN_PACKAGE_SHOP_PATH = adminPath('/mobile/billing/shop'); export const ADMIN_PACKAGE_SHOP_PATH = adminPath('/mobile/billing/shop');

View File

@@ -1168,23 +1168,15 @@
"mode": "Modus" "mode": "Modus"
}, },
"mode": { "mode": {
"title": "Uploader-Verbindung", "title": "Photobooth-Typ auswählen",
"description": "Nutze die Fotospiel-Uploader-App für HTTP-Uploads. Beim Zurücksetzen werden neue Zugangsdaten generiert.", "description": "Wähle zwischen klassischem FTP und Sparkbooth HTTP-Upload. Umschalten generiert neue Zugangsdaten.",
"active": "Aktuell: {{mode}}", "active": "Aktuell: {{mode}}"
"uploader": "Uploader-App (HTTP)"
},
"selector": {
"title": "Verbindung",
"description": "Nutze die Fotospiel-Uploader-App für HTTP-Uploads."
}, },
"credentials": { "credentials": {
"heading": "Zugangsdaten für die Uploader-App", "heading": "FTP-Zugangsdaten",
"description": "Teile die Zugangsdaten mit der Fotospiel-Uploader-App.", "description": "Teile die Zugangsdaten mit eurer Photobooth-Software.",
"uploaderTitle": "Uploader-App (HTTP)", "sparkboothTitle": "Sparkbooth-Upload (HTTP)",
"uploaderDescription": "Trage URL, Benutzername und Passwort in die Fotospiel-Uploader-App ein. Antworten sind JSON (optional XML).", "sparkboothDescription": "Trage URL, Benutzername und Passwort in Sparkbooth ein. Antworten sind JSON (optional XML).",
"show": "Zugangsdaten anzeigen",
"hide": "Zugangsdaten verbergen",
"hidden": "Zugangsdaten verborgen. Tippe zum Anzeigen.",
"host": "Host", "host": "Host",
"port": "Port", "port": "Port",
"username": "Benutzername", "username": "Benutzername",
@@ -1193,44 +1185,6 @@
"postUrl": "Upload-URL", "postUrl": "Upload-URL",
"responseFormat": "Antwort-Format" "responseFormat": "Antwort-Format"
}, },
"connectCode": {
"label": "Verbindungscode",
"description": "Erstelle einen 6-stelligen Code für die Uploader-App.",
"expires": "Läuft ab: {{date}}",
"actions": {
"generate": "Verbindungscode erstellen",
"generated": "Verbindungscode erstellt"
},
"errors": {
"failed": "Verbindungscode konnte nicht erstellt werden."
}
},
"uploader": {
"hint": "POST mit Mediendatei oder base64-Feld \"media\"; die App nutzt diese Zugangsdaten."
},
"steps": {
"activate": {
"title": "1. Photobooth aktivieren",
"description": "Schalte den Upload-Zugang fuer dieses Event frei."
},
"download": {
"title": "2. Uploader App herunterladen"
},
"access": {
"title": "3. Verbindungscode erstellen",
"description": "Der Code verbindet die App sicher mit deinem Event."
}
},
"uploaderDownload": {
"title": "Fotospiel Uploader App",
"description": "Die Fotospiel Uploader App wird benötigt, damit Uploads stabil laufen, die Zugangsdaten geschützt bleiben und keine Dateien verloren gehen.",
"emailAction": "Download-Links per E-Mail senden",
"emailSuccess": "Download-Links wurden per E-Mail gesendet.",
"emailFailed": "E-Mail konnte nicht gesendet werden.",
"actionWindows": "Uploader herunterladen (Windows)",
"actionMac": "Uploader herunterladen (macOS)",
"actionLinux": "Uploader herunterladen (Linux)"
},
"actions": { "actions": {
"enable": "Photobooth aktivieren", "enable": "Photobooth aktivieren",
"disable": "Deaktivieren", "disable": "Deaktivieren",
@@ -1248,9 +1202,9 @@
"title": "Setup-Checkliste", "title": "Setup-Checkliste",
"description": "Durchlaufe die Schritte, bevor du Gästen Zugang gibst.", "description": "Durchlaufe die Schritte, bevor du Gästen Zugang gibst.",
"enable": "Zugang aktivieren", "enable": "Zugang aktivieren",
"enableCopy": "Aktiviere die Verbindung für die Uploader-App.", "enableCopy": "Aktiviere den FTP-Account für eure Photobooth-Software.",
"share": "Zugang teilen", "share": "Zugang teilen",
"shareCopy": "Übergib URL, Benutzername & Passwort an den Betreiber.", "shareCopy": "Übergib Host, Benutzer & Passwort an den Betreiber.",
"monitor": "Uploads beobachten", "monitor": "Uploads beobachten",
"monitorCopy": "Verfolge Uploads & Limits direkt im Dashboard." "monitorCopy": "Verfolge Uploads & Limits direkt im Dashboard."
}, },
@@ -1477,7 +1431,7 @@
"photobooth": { "photobooth": {
"title": "Fotobox-Uploads", "title": "Fotobox-Uploads",
"titleForEvent": "Fotobox-Uploads verwalten", "titleForEvent": "Fotobox-Uploads verwalten",
"subtitle": "Erstelle Zugang für die Uploader-App und behalte Limits im Blick.", "subtitle": "Erstelle FTP-Zugänge für Photobooth-Software und behalte Limits im Blick.",
"actions": { "actions": {
"backToEvent": "Zur Detailansicht", "backToEvent": "Zur Detailansicht",
"allEvents": "Zur Eventliste" "allEvents": "Zur Eventliste"
@@ -1918,63 +1872,23 @@
"titleShort": "Branding", "titleShort": "Branding",
"previewTitle": "Guest-App-Vorschau", "previewTitle": "Guest-App-Vorschau",
"previewSubtitle": "Aktuelle Farben & Schriften", "previewSubtitle": "Aktuelle Farben & Schriften",
"previewCta": "Fotos hochladen",
"primary": "Primärfarbe", "primary": "Primärfarbe",
"accent": "Akzentfarbe", "accent": "Akzentfarbe",
"background": "Hintergrund",
"surface": "Fläche",
"lockedBranding": "Branding ist in diesem Paket gesperrt.",
"source": "Branding-Quelle",
"sourceHint": "Nutze das Tenant-Branding oder überschreibe es für dieses Event.",
"useDefault": "Tenant",
"useCustom": "Event",
"usingDefault": "Tenant-Branding aktiv",
"usingCustom": "Event-Branding aktiv",
"mode": "Theme",
"modeLight": "Hell",
"modeAuto": "Auto",
"modeDark": "Dunkel",
"colors": "Farben", "colors": "Farben",
"primaryColor": "Primärfarbe", "primaryColor": "Primärfarbe",
"accentColor": "Akzentfarbe", "accentColor": "Akzentfarbe",
"backgroundColor": "Hintergrundfarbe",
"surfaceColor": "Flächenfarbe",
"fonts": "Schriften", "fonts": "Schriften",
"headingFont": "Überschrift-Schrift", "headingFont": "Überschrift-Schrift",
"headingFontPlaceholder": "SF Pro Display", "headingFontPlaceholder": "SF Pro Display",
"bodyFont": "Fließtext-Schrift", "bodyFont": "Fließtext-Schrift",
"bodyFontPlaceholder": "SF Pro Text", "bodyFontPlaceholder": "SF Pro Text",
"fontSize": "Schriftgröße",
"fontSizeSmall": "S",
"fontSizeMedium": "M",
"fontSizeLarge": "L",
"logo": "Logo", "logo": "Logo",
"logoAlt": "Logo", "logoAlt": "Logo",
"logoModeUpload": "Upload",
"logoModeEmoticon": "Emoticon",
"logoValue": "Emoticon",
"logoValuePlaceholder": "🎉",
"logoPosition": "Position",
"positionLeft": "Links",
"positionCenter": "Zentriert",
"positionRight": "Rechts",
"logoSize": "Größe",
"logoSizeSmall": "S",
"logoSizeMedium": "M",
"logoSizeLarge": "L",
"replaceLogo": "Logo ersetzen", "replaceLogo": "Logo ersetzen",
"removeLogo": "Entfernen", "removeLogo": "Entfernen",
"logoHint": "Logo hochladen oder Emoji für den Guest-Header nutzen.", "logoHint": "Lade ein Logo hoch, um Einladungen und QR-Poster zu branden.",
"uploadLogo": "Logo hochladen (max. 1 MB)", "uploadLogo": "Logo hochladen (max. 1 MB)",
"logoTooLarge": "Logo muss unter 1 MB sein.", "logoTooLarge": "Logo muss unter 1 MB sein.",
"buttons": "Buttons & Links",
"buttonsHint": "Stil, Radius und Link-Farbe für CTAs.",
"buttonFilled": "Gefüllt",
"buttonOutline": "Outline",
"buttonRadius": "Radius",
"buttonPrimary": "Button Primär",
"buttonSecondary": "Button Sekundär",
"linkColor": "Link-Farbe",
"save": "Branding speichern", "save": "Branding speichern",
"saving": "Speichere...", "saving": "Speichere...",
"saveSuccess": "Branding gespeichert.", "saveSuccess": "Branding gespeichert.",
@@ -2432,7 +2346,7 @@
"mobileProfile": { "mobileProfile": {
"title": "Profil", "title": "Profil",
"settings": "Einstellungen", "settings": "Einstellungen",
"account": "Account bearbeiten", "account": "Account & Sicherheit",
"language": "Sprache", "language": "Sprache",
"languageDe": "Deutsch", "languageDe": "Deutsch",
"languageEn": "Englisch", "languageEn": "Englisch",

View File

@@ -2,7 +2,6 @@
"profile": { "profile": {
"title": "Profil", "title": "Profil",
"subtitle": "Verwalte deine Kontodaten und Zugangsdaten.", "subtitle": "Verwalte deine Kontodaten und Zugangsdaten.",
"loading": "Lädt ...",
"sections": { "sections": {
"account": { "account": {
"heading": "Account-Informationen", "heading": "Account-Informationen",

View File

@@ -881,23 +881,15 @@
"mode": "Mode" "mode": "Mode"
}, },
"mode": { "mode": {
"title": "Uploader connection", "title": "Choose your photobooth type",
"description": "Use the Fotospiel uploader app for live HTTP uploads. Rotating access regenerates credentials.", "description": "Pick classic FTP or Sparkbooth HTTP upload. Switching regenerates credentials.",
"active": "Current: {{mode}}", "active": "Current: {{mode}}"
"uploader": "Uploader App (HTTP)"
},
"selector": {
"title": "Connection",
"description": "Use the Fotospiel uploader app for HTTP uploads."
}, },
"credentials": { "credentials": {
"heading": "Uploader app credentials", "heading": "FTP credentials",
"description": "Share these credentials with the Fotospiel uploader app.", "description": "Share these credentials with your photobooth software.",
"uploaderTitle": "Uploader App (HTTP)", "sparkboothTitle": "Sparkbooth upload (HTTP)",
"uploaderDescription": "Enter URL, username and password in the Fotospiel uploader app. Responses default to JSON (XML optional).", "sparkboothDescription": "Enter URL, username and password in Sparkbooth. Responses default to JSON (XML optional).",
"show": "Show credentials",
"hide": "Hide credentials",
"hidden": "Credentials are hidden. Tap to show them.",
"host": "Host", "host": "Host",
"port": "Port", "port": "Port",
"username": "Username", "username": "Username",
@@ -906,44 +898,6 @@
"postUrl": "Upload URL", "postUrl": "Upload URL",
"responseFormat": "Response format" "responseFormat": "Response format"
}, },
"connectCode": {
"label": "Connect code",
"description": "Create a 6-digit code for the uploader app.",
"expires": "Expires: {{date}}",
"actions": {
"generate": "Generate connect code",
"generated": "Connect code created"
},
"errors": {
"failed": "Connect code could not be created."
}
},
"uploader": {
"hint": "POST with media file or base64 \"media\" field; app uses these credentials."
},
"steps": {
"activate": {
"title": "1. Activate photobooth",
"description": "Enable upload access for this event."
},
"download": {
"title": "2. Download uploader app"
},
"access": {
"title": "3. Generate connect code",
"description": "The code securely pairs the app with your event."
}
},
"uploaderDownload": {
"title": "Fotospiel Uploader App",
"description": "The Fotospiel Uploader App is required so uploads stay stable, credentials remain protected, and no files are lost.",
"emailAction": "Send download links by email",
"emailSuccess": "Download links were sent by email.",
"emailFailed": "Email could not be sent.",
"actionWindows": "Download uploader (Windows)",
"actionMac": "Download uploader (macOS)",
"actionLinux": "Download uploader (Linux)"
},
"actions": { "actions": {
"enable": "Activate photobooth", "enable": "Activate photobooth",
"disable": "Disable", "disable": "Disable",
@@ -961,9 +915,9 @@
"title": "Setup checklist", "title": "Setup checklist",
"description": "Complete each step before guests upload.", "description": "Complete each step before guests upload.",
"enable": "Activate access", "enable": "Activate access",
"enableCopy": "Enable the uploader app connection for this event.", "enableCopy": "Enable the FTP account in your photobooth software.",
"share": "Share credentials", "share": "Share credentials",
"shareCopy": "Share URL, username, and password with the operator.", "shareCopy": "Hand over host, user, and password to the operator.",
"monitor": "Monitor uploads", "monitor": "Monitor uploads",
"monitorCopy": "Watch uploads & limits in the dashboard." "monitorCopy": "Watch uploads & limits in the dashboard."
}, },
@@ -1474,7 +1428,7 @@
"photobooth": { "photobooth": {
"title": "Photobooth uploads", "title": "Photobooth uploads",
"titleForEvent": "Manage photobooth uploads", "titleForEvent": "Manage photobooth uploads",
"subtitle": "Create uploader access for photobooth apps and keep limits in sight.", "subtitle": "Create FTP access for photobooth software and keep limits in sight.",
"actions": { "actions": {
"backToEvent": "Back to detail view", "backToEvent": "Back to detail view",
"allEvents": "Back to event list" "allEvents": "Back to event list"
@@ -1922,63 +1876,23 @@
"titleShort": "Branding", "titleShort": "Branding",
"previewTitle": "Guest app preview", "previewTitle": "Guest app preview",
"previewSubtitle": "Current colors & fonts", "previewSubtitle": "Current colors & fonts",
"previewCta": "Upload photos",
"primary": "Primary", "primary": "Primary",
"accent": "Accent", "accent": "Accent",
"background": "Background",
"surface": "Surface",
"lockedBranding": "Branding is locked for this package.",
"source": "Branding source",
"sourceHint": "Use tenant branding or override for this event.",
"useDefault": "Tenant",
"useCustom": "Event",
"usingDefault": "Tenant branding active",
"usingCustom": "Event branding active",
"mode": "Theme",
"modeLight": "Light",
"modeAuto": "Auto",
"modeDark": "Dark",
"colors": "Colors", "colors": "Colors",
"primaryColor": "Primary color", "primaryColor": "Primary color",
"accentColor": "Accent color", "accentColor": "Accent color",
"backgroundColor": "Background color",
"surfaceColor": "Surface color",
"fonts": "Fonts", "fonts": "Fonts",
"headingFont": "Headline font", "headingFont": "Headline font",
"headingFontPlaceholder": "SF Pro Display", "headingFontPlaceholder": "SF Pro Display",
"bodyFont": "Body font", "bodyFont": "Body font",
"bodyFontPlaceholder": "SF Pro Text", "bodyFontPlaceholder": "SF Pro Text",
"fontSize": "Font size",
"fontSizeSmall": "S",
"fontSizeMedium": "M",
"fontSizeLarge": "L",
"logo": "Logo", "logo": "Logo",
"logoAlt": "Logo", "logoAlt": "Logo",
"logoModeUpload": "Upload",
"logoModeEmoticon": "Emoticon",
"logoValue": "Emoticon",
"logoValuePlaceholder": "🎉",
"logoPosition": "Position",
"positionLeft": "Left",
"positionCenter": "Center",
"positionRight": "Right",
"logoSize": "Size",
"logoSizeSmall": "S",
"logoSizeMedium": "M",
"logoSizeLarge": "L",
"replaceLogo": "Replace logo", "replaceLogo": "Replace logo",
"removeLogo": "Remove", "removeLogo": "Remove",
"logoHint": "Upload a logo or use an emoji for the guest header.", "logoHint": "Upload a logo to brand guest invites and QR posters.",
"uploadLogo": "Upload logo (max. 1 MB)", "uploadLogo": "Upload logo (max. 1 MB)",
"logoTooLarge": "Logo must be under 1 MB.", "logoTooLarge": "Logo must be under 1 MB.",
"buttons": "Buttons & links",
"buttonsHint": "Style, radius, and link color for CTA buttons.",
"buttonFilled": "Filled",
"buttonOutline": "Outline",
"buttonRadius": "Radius",
"buttonPrimary": "Button primary",
"buttonSecondary": "Button secondary",
"linkColor": "Link color",
"save": "Save branding", "save": "Save branding",
"saving": "Saving...", "saving": "Saving...",
"saveSuccess": "Branding saved.", "saveSuccess": "Branding saved.",
@@ -2436,7 +2350,7 @@
"mobileProfile": { "mobileProfile": {
"title": "Profile", "title": "Profile",
"settings": "Settings", "settings": "Settings",
"account": "Edit account", "account": "Account & security",
"language": "Language", "language": "Language",
"languageDe": "Deutsch", "languageDe": "Deutsch",
"languageEn": "English", "languageEn": "English",

View File

@@ -2,7 +2,6 @@
"profile": { "profile": {
"title": "Profile", "title": "Profile",
"subtitle": "Manage your account details and credentials.", "subtitle": "Manage your account details and credentials.",
"loading": "Loading ...",
"sections": { "sections": {
"account": { "account": {
"heading": "Account information", "heading": "Account information",

View File

@@ -1,123 +0,0 @@
import { describe, expect, it } from 'vitest';
import { extractBrandingForm } from '../brandingForm';
const defaults = {
primary: '#111111',
accent: '#222222',
background: '#ffffff',
surface: '#f0f0f0',
mode: 'auto' as const,
buttonStyle: 'filled' as const,
buttonRadius: 12,
buttonPrimary: '#111111',
buttonSecondary: '#222222',
linkColor: '#222222',
fontSize: 'm' as const,
logoMode: 'upload' as const,
logoPosition: 'left' as const,
logoSize: 'm' as const,
};
describe('extractBrandingForm', () => {
it('prefers palette values when available', () => {
const settings = {
branding: {
palette: {
primary: '#aa0000',
secondary: '#00aa00',
background: '#000000',
surface: '#111111',
},
primary_color: '#bbbbbb',
secondary_color: '#cccccc',
background_color: '#dddddd',
surface_color: '#eeeeee',
mode: 'dark',
},
};
const result = extractBrandingForm(settings, defaults);
expect(result.primary).toBe('#aa0000');
expect(result.accent).toBe('#00aa00');
expect(result.background).toBe('#000000');
expect(result.surface).toBe('#111111');
expect(result.mode).toBe('dark');
expect(result.fontSize).toBe('m');
});
it('falls back to legacy keys and defaults', () => {
const settings = {
branding: {
accent_color: '#123456',
background_color: '#abcdef',
mode: 'light',
},
};
const result = extractBrandingForm(settings, defaults);
expect(result.primary).toBe(defaults.primary);
expect(result.accent).toBe('#123456');
expect(result.background).toBe('#abcdef');
expect(result.surface).toBe('#abcdef');
expect(result.mode).toBe('light');
expect(result.buttonStyle).toBe(defaults.buttonStyle);
expect(result.buttonRadius).toBe(defaults.buttonRadius);
});
it('extracts buttons, logo, and typography settings', () => {
const settings = {
branding: {
typography: {
heading: 'Display Font',
body: 'Body Font',
size: 'l',
},
buttons: {
style: 'outline',
radius: 24,
primary: '#333333',
secondary: '#444444',
link_color: '#555555',
},
logo: {
mode: 'emoticon',
value: '🎉',
position: 'center',
size: 'l',
},
use_default_branding: true,
},
};
const result = extractBrandingForm(settings, defaults);
expect(result.headingFont).toBe('Display Font');
expect(result.bodyFont).toBe('Body Font');
expect(result.fontSize).toBe('l');
expect(result.buttonStyle).toBe('outline');
expect(result.buttonRadius).toBe(24);
expect(result.buttonPrimary).toBe('#333333');
expect(result.buttonSecondary).toBe('#444444');
expect(result.linkColor).toBe('#555555');
expect(result.logoMode).toBe('emoticon');
expect(result.logoValue).toBe('🎉');
expect(result.logoPosition).toBe('center');
expect(result.logoSize).toBe('l');
expect(result.useDefaultBranding).toBe(true);
});
it('normalizes stored logo paths for previews', () => {
const settings = {
branding: {
logo_url: 'branding/logos/event-123.png',
logo_mode: 'upload',
},
};
const result = extractBrandingForm(settings, defaults);
expect(result.logoDataUrl).toBe('/storage/branding/logos/event-123.png');
});
});

View File

@@ -1,149 +0,0 @@
export type BrandingFormValues = {
primary: string;
accent: string;
background: string;
surface: string;
headingFont: string;
bodyFont: string;
fontSize: 's' | 'm' | 'l';
logoDataUrl: string;
logoValue: string;
logoMode: 'upload' | 'emoticon';
logoPosition: 'left' | 'center' | 'right';
logoSize: 's' | 'm' | 'l';
mode: 'light' | 'dark' | 'auto';
buttonStyle: 'filled' | 'outline';
buttonRadius: number;
buttonPrimary: string;
buttonSecondary: string;
linkColor: string;
useDefaultBranding: boolean;
};
export type BrandingFormDefaults = Pick<
BrandingFormValues,
| 'primary'
| 'accent'
| 'background'
| 'surface'
| 'mode'
| 'buttonStyle'
| 'buttonRadius'
| 'buttonPrimary'
| 'buttonSecondary'
| 'linkColor'
| 'fontSize'
| 'logoMode'
| 'logoPosition'
| 'logoSize'
>;
type BrandingRecord = Record<string, unknown>;
const isRecord = (value: unknown): value is BrandingRecord =>
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
const readHexColor = (value: unknown, fallback: string): string => {
if (typeof value === 'string' && value.trim().startsWith('#')) {
return value.trim();
}
return fallback;
};
const readEnum = <T extends string>(value: unknown, allowed: readonly T[], fallback: T): T => (
allowed.includes(value as T) ? (value as T) : fallback
);
const readNumber = (value: unknown, fallback: number): number => (
typeof value === 'number' && !Number.isNaN(value) ? value : fallback
);
const resolveAssetPreviewUrl = (value: string | null | undefined): string => {
if (typeof value !== 'string') {
return '';
}
const trimmed = value.trim();
if (!trimmed) {
return '';
}
if (trimmed.startsWith('data:') || trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
return trimmed;
}
const normalized = trimmed.startsWith('/') ? trimmed.slice(1) : trimmed;
if (normalized.startsWith('storage/')) {
return `/${normalized}`;
}
if (normalized.startsWith('branding/') || normalized.startsWith('tenant-branding/')) {
return `/storage/${normalized}`;
}
return trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
};
export function extractBrandingForm(settings: unknown, defaults: BrandingFormDefaults): BrandingFormValues {
const settingsRecord = isRecord(settings) ? settings : {};
const branding = isRecord(settingsRecord.branding) ? (settingsRecord.branding as BrandingRecord) : settingsRecord;
const palette = isRecord(branding.palette) ? (branding.palette as BrandingRecord) : {};
const typography = isRecord(branding.typography) ? (branding.typography as BrandingRecord) : {};
const buttons = isRecord(branding.buttons) ? (branding.buttons as BrandingRecord) : {};
const logo = isRecord(branding.logo) ? (branding.logo as BrandingRecord) : {};
const primary = readHexColor(palette.primary, readHexColor(branding.primary_color, defaults.primary));
const accent = readHexColor(
palette.secondary,
readHexColor(branding.secondary_color, readHexColor(branding.accent_color, defaults.accent))
);
const background = readHexColor(palette.background, readHexColor(branding.background_color, defaults.background));
const surface = readHexColor(palette.surface, readHexColor(branding.surface_color, background));
const headingFont = typeof typography.heading === 'string' ? typography.heading : (branding.heading_font as string | undefined);
const bodyFont = typeof typography.body === 'string' ? typography.body : (branding.body_font as string | undefined);
const mode = readEnum(branding.mode, ['light', 'dark', 'auto'], defaults.mode);
const fontSize = readEnum(typography.size ?? branding.font_size, ['s', 'm', 'l'], defaults.fontSize);
const logoMode = readEnum(logo.mode ?? branding.logo_mode, ['upload', 'emoticon'], defaults.logoMode);
const logoValue = logoMode === 'emoticon'
? (typeof logo.value === 'string' ? logo.value : (branding.logo_value as string | undefined) ?? '')
: '';
const logoPosition = readEnum(logo.position ?? branding.logo_position, ['left', 'center', 'right'], defaults.logoPosition);
const logoSize = readEnum(logo.size ?? branding.logo_size, ['s', 'm', 'l'], defaults.logoSize);
const logoUploadValue =
typeof branding.logo_data_url === 'string'
? branding.logo_data_url
: logoMode === 'upload'
? (typeof logo.value === 'string' ? logo.value : (branding.logo_url as string | undefined))
: undefined;
const buttonStyle = readEnum(buttons.style ?? branding.button_style, ['filled', 'outline'], defaults.buttonStyle);
const buttonRadius = readNumber(buttons.radius ?? branding.button_radius, defaults.buttonRadius);
const buttonPrimary = readHexColor(buttons.primary, readHexColor(branding.button_primary_color, primary));
const buttonSecondary = readHexColor(buttons.secondary, readHexColor(branding.button_secondary_color, accent));
const linkColor = readHexColor(buttons.link_color ?? buttons.linkColor, readHexColor(branding.link_color, accent));
return {
primary,
accent,
background,
surface,
headingFont: headingFont ?? '',
bodyFont: bodyFont ?? '',
fontSize,
logoDataUrl: resolveAssetPreviewUrl(logoUploadValue),
logoValue,
logoMode,
logoPosition,
logoSize,
mode,
buttonStyle,
buttonRadius,
buttonPrimary,
buttonSecondary,
linkColor,
useDefaultBranding: branding.use_default_branding === true,
};
}

View File

@@ -48,29 +48,6 @@ export function formatEventDate(value?: string | null, locale = 'de-DE'): string
} }
} }
export function formatEventDateTime(value?: string | null, locale = 'de-DE'): string | null {
if (!value) {
return null;
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return null;
}
try {
return new Intl.DateTimeFormat(locale, {
day: '2-digit',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(date);
} catch {
return date.toISOString().slice(0, 16).replace('T', ' ');
}
}
export function resolveEngagementMode(event?: TenantEvent | null): 'tasks' | 'photo_only' | null { export function resolveEngagementMode(event?: TenantEvent | null): 'tasks' | 'photo_only' | null {
if (!event) { if (!event) {
return null; return null;

View File

@@ -7,7 +7,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 { MobileShell, HeaderActionButton } from './components/MobileShell'; import { MobileShell, HeaderActionButton } from './components/MobileShell';
import { MobileCard, CTAButton, SkeletonCard } from './components/Primitives'; import { MobileCard, CTAButton, SkeletonCard } from './components/Primitives';
import { TenantEvent, getEvent, updateEvent, getTenantFonts, getTenantSettings, TenantFont, WatermarkSettings, trackOnboarding } from '../api'; import { TenantEvent, getEvent, updateEvent, getTenantFonts, TenantFont, WatermarkSettings, trackOnboarding } from '../api';
import { isAuthError } from '../auth/tokens'; import { isAuthError } from '../auth/tokens';
import { ApiError, getApiErrorMessage } from '../lib/apiError'; import { ApiError, getApiErrorMessage } from '../lib/apiError';
import { isBrandingAllowed } from '../lib/events'; import { isBrandingAllowed } from '../lib/events';
@@ -16,45 +16,13 @@ import toast from 'react-hot-toast';
import { adminPath } from '../constants'; import { adminPath } from '../constants';
import { useBackNavigation } from './hooks/useBackNavigation'; import { useBackNavigation } from './hooks/useBackNavigation';
import { ADMIN_COLORS, ADMIN_GRADIENTS, useAdminTheme } from './theme'; import { ADMIN_COLORS, ADMIN_GRADIENTS, useAdminTheme } from './theme';
import { extractBrandingForm, type BrandingFormValues } from '../lib/brandingForm';
import { getContrastingTextColor } from '@/guest/lib/color';
const BRANDING_FORM_DEFAULTS = { type BrandingForm = {
primary: ADMIN_COLORS.primary, primary: string;
accent: ADMIN_COLORS.accent, accent: string;
background: '#ffffff', headingFont: string;
surface: '#ffffff', bodyFont: string;
mode: 'auto' as const, logoDataUrl: string;
buttonStyle: 'filled' as const,
buttonRadius: 12,
buttonPrimary: ADMIN_COLORS.primary,
buttonSecondary: ADMIN_COLORS.accent,
linkColor: ADMIN_COLORS.accent,
fontSize: 'm' as const,
logoMode: 'upload' as const,
logoPosition: 'left' as const,
logoSize: 'm' as const,
};
const BRANDING_FORM_BASE: BrandingFormValues = {
...BRANDING_FORM_DEFAULTS,
headingFont: '',
bodyFont: '',
logoDataUrl: '',
logoValue: '',
useDefaultBranding: false,
};
const FONT_SIZE_SCALE: Record<BrandingFormValues['fontSize'], number> = {
s: 0.94,
m: 1,
l: 1.08,
};
const LOGO_SIZE_PREVIEW: Record<BrandingFormValues['logoSize'], number> = {
s: 28,
m: 36,
l: 44,
}; };
type WatermarkPosition = type WatermarkPosition =
@@ -90,7 +58,13 @@ 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<BrandingForm>({
primary: ADMIN_COLORS.primary,
accent: ADMIN_COLORS.accent,
headingFont: '',
bodyFont: '',
logoDataUrl: '',
});
const [watermarkForm, setWatermarkForm] = React.useState<WatermarkForm>({ const [watermarkForm, setWatermarkForm] = React.useState<WatermarkForm>({
mode: 'base', mode: 'base',
assetPath: '', assetPath: '',
@@ -112,8 +86,6 @@ export default function MobileBrandingPage() {
const [fonts, setFonts] = React.useState<TenantFont[]>([]); const [fonts, setFonts] = React.useState<TenantFont[]>([]);
const [fontsLoading, setFontsLoading] = React.useState(false); const [fontsLoading, setFontsLoading] = React.useState(false);
const [fontsLoaded, setFontsLoaded] = React.useState(false); const [fontsLoaded, setFontsLoaded] = React.useState(false);
const [tenantBranding, setTenantBranding] = React.useState<BrandingFormValues | null>(null);
const [tenantBrandingLoaded, setTenantBrandingLoaded] = React.useState(false);
React.useEffect(() => { React.useEffect(() => {
if (!slug) return; if (!slug) return;
@@ -122,7 +94,7 @@ export default function MobileBrandingPage() {
try { try {
const data = await getEvent(slug); const data = await getEvent(slug);
setEvent(data); setEvent(data);
setForm(extractBrandingForm(data.settings ?? {}, BRANDING_FORM_DEFAULTS)); setForm(extractBranding(data));
setWatermarkForm(extractWatermark(data)); setWatermarkForm(extractWatermark(data));
setError(null); setError(null);
} catch (err) { } catch (err) {
@@ -147,42 +119,12 @@ export default function MobileBrandingPage() {
}); });
}, [showFontsSheet, fontsLoaded]); }, [showFontsSheet, fontsLoaded]);
React.useEffect(() => {
if (tenantBrandingLoaded) return;
let active = true;
getTenantSettings()
.then((payload) => {
if (!active) return;
setTenantBranding(extractBrandingForm(payload.settings ?? {}, BRANDING_FORM_DEFAULTS));
})
.catch(() => undefined)
.finally(() => {
if (active) {
setTenantBrandingLoaded(true);
}
});
return () => {
active = false;
};
}, [tenantBrandingLoaded]);
const previewForm = form.useDefaultBranding && tenantBranding ? tenantBranding : form;
const previewTitle = event ? renderName(event.name) : t('events.placeholders.untitled', 'Unbenanntes Event'); const previewTitle = event ? renderName(event.name) : t('events.placeholders.untitled', 'Unbenanntes Event');
const previewHeadingFont = previewForm.headingFont || 'Fraunces'; const previewHeadingFont = form.headingFont || 'Fraunces';
const previewBodyFont = previewForm.bodyFont || 'Manrope'; const previewBodyFont = form.bodyFont || 'Manrope';
const previewSurfaceText = getContrastingTextColor(previewForm.surface, '#ffffff', '#0f172a');
const previewScale = FONT_SIZE_SCALE[previewForm.fontSize] ?? 1;
const previewButtonColor = previewForm.buttonPrimary || previewForm.primary;
const previewButtonText = getContrastingTextColor(previewButtonColor, '#ffffff', '#0f172a');
const previewLogoSize = LOGO_SIZE_PREVIEW[previewForm.logoSize] ?? 36;
const previewLogoUrl = previewForm.logoMode === 'upload' ? previewForm.logoDataUrl : '';
const previewLogoValue = previewForm.logoMode === 'emoticon' ? previewForm.logoValue : '';
const previewInitials = getInitials(previewTitle);
const watermarkAllowed = event?.package?.watermark_allowed !== false; const watermarkAllowed = event?.package?.watermark_allowed !== false;
const brandingAllowed = isBrandingAllowed(event ?? null); const brandingAllowed = isBrandingAllowed(event ?? null);
const watermarkLocked = watermarkAllowed && !brandingAllowed; const watermarkLocked = watermarkAllowed && !brandingAllowed;
const brandingDisabled = !brandingAllowed || form.useDefaultBranding;
async function handleSave() { async function handleSave() {
if (!event?.slug) return; if (!event?.slug) return;
@@ -213,38 +155,18 @@ export default function MobileBrandingPage() {
is_active: event.is_active ?? undefined, is_active: event.is_active ?? undefined,
}; };
const settings = { ...(event.settings ?? {}) }; const settings = { ...(event.settings ?? {}) };
const logoUploadValue = form.logoMode === 'upload' ? form.logoDataUrl.trim() : '';
const logoIsDataUrl = logoUploadValue.startsWith('data:image/');
const normalizedLogoPath = logoIsDataUrl ? '' : normalizeBrandingPath(logoUploadValue);
const logoValue = form.logoMode === 'upload'
? (logoIsDataUrl ? logoUploadValue : normalizedLogoPath || null)
: (form.logoValue.trim() || null);
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,
accent_color: form.accent, accent_color: form.accent,
background_color: form.background,
surface_color: form.surface,
font_family: form.bodyFont,
heading_font: form.headingFont, heading_font: form.headingFont,
body_font: form.bodyFont, body_font: form.bodyFont,
font_size: form.fontSize,
mode: form.mode,
button_style: form.buttonStyle,
button_radius: form.buttonRadius,
button_primary_color: form.buttonPrimary,
button_secondary_color: form.buttonSecondary,
link_color: form.linkColor,
typography: { typography: {
...(typeof (settings.branding as Record<string, unknown> | undefined)?.typography === 'object' ...(typeof (settings.branding as Record<string, unknown> | undefined)?.typography === 'object'
? ((settings.branding as Record<string, unknown>).typography as Record<string, unknown>) ? ((settings.branding as Record<string, unknown>).typography as Record<string, unknown>)
: {}), : {}),
heading: form.headingFont, heading: form.headingFont,
body: form.bodyFont, body: form.bodyFont,
size: form.fontSize,
}, },
palette: { palette: {
...(typeof (settings.branding as Record<string, unknown> | undefined)?.palette === 'object' ...(typeof (settings.branding as Record<string, unknown> | undefined)?.palette === 'object'
@@ -252,31 +174,16 @@ export default function MobileBrandingPage() {
: {}), : {}),
primary: form.primary, primary: form.primary,
secondary: form.accent, secondary: form.accent,
background: form.background,
surface: form.surface,
},
buttons: {
...(typeof (settings.branding as Record<string, unknown> | undefined)?.buttons === 'object'
? ((settings.branding as Record<string, unknown>).buttons as Record<string, unknown>)
: {}),
style: form.buttonStyle,
radius: form.buttonRadius,
primary: form.buttonPrimary,
secondary: form.buttonSecondary,
link_color: form.linkColor,
},
logo_data_url: form.logoMode === 'upload' && logoIsDataUrl ? logoUploadValue : null,
logo_url: form.logoMode === 'upload' ? (normalizedLogoPath || null) : null,
logo_mode: form.logoMode,
logo_value: logoValue,
logo_position: form.logoPosition,
logo_size: form.logoSize,
logo: {
mode: form.logoMode,
value: logoValue,
position: form.logoPosition,
size: form.logoSize,
}, },
logo_data_url: form.logoDataUrl || null,
logo: form.logoDataUrl
? {
mode: 'upload',
value: form.logoDataUrl,
position: 'center',
size: 'm',
}
: null,
}; };
const watermarkPayload = buildWatermarkPayload(watermarkForm, watermarkAllowed, brandingAllowed); const watermarkPayload = buildWatermarkPayload(watermarkForm, watermarkAllowed, brandingAllowed);
if (watermarkPayload) { if (watermarkPayload) {
@@ -310,7 +217,7 @@ export default function MobileBrandingPage() {
function handleReset() { function handleReset() {
if (event) { if (event) {
setForm(extractBrandingForm(event.settings ?? {}, BRANDING_FORM_DEFAULTS)); setForm(extractBranding(event));
setWatermarkForm(extractWatermark(event)); setWatermarkForm(extractWatermark(event));
} }
} }
@@ -528,143 +435,25 @@ export default function MobileBrandingPage() {
<Text fontSize="$sm" fontWeight="700" color={textStrong}> <Text fontSize="$sm" fontWeight="700" color={textStrong}>
{t('events.branding.previewTitle', 'Guest App Preview')} {t('events.branding.previewTitle', 'Guest App Preview')}
</Text> </Text>
<YStack borderRadius={16} borderWidth={1} borderColor={border} backgroundColor={previewForm.background} padding="$3" space="$2" alignItems="center"> <YStack borderRadius={16} borderWidth={1} borderColor={border} backgroundColor={surfaceMuted} padding="$3" space="$2" alignItems="center">
<YStack width="100%" borderRadius={12} backgroundColor={previewForm.surface} borderWidth={1} borderColor={border} overflow="hidden"> <YStack width="100%" borderRadius={12} backgroundColor={surface} borderWidth={1} borderColor={border} overflow="hidden">
<YStack <YStack backgroundColor={form.primary} height={64} />
height={64} <YStack padding="$3" space="$1.5">
style={{ background: `linear-gradient(135deg, ${previewForm.primary}, ${previewForm.accent})` }} <Text fontSize="$md" fontWeight="800" color={textStrong} style={{ fontFamily: previewHeadingFont }}>
/>
<YStack padding="$3" space="$2">
<XStack
alignItems="center"
space="$2"
flexDirection={previewForm.logoPosition === 'center' ? 'column' : previewForm.logoPosition === 'right' ? 'row-reverse' : 'row'}
justifyContent={previewForm.logoPosition === 'center' ? 'center' : 'flex-start'}
>
<YStack
width={previewLogoSize}
height={previewLogoSize}
borderRadius={previewLogoSize}
alignItems="center"
justifyContent="center"
backgroundColor={previewForm.accent}
>
{previewLogoUrl ? (
<img
src={previewLogoUrl}
alt={t('events.branding.logoAlt', 'Logo')}
style={{ width: previewLogoSize - 6, height: previewLogoSize - 6, borderRadius: previewLogoSize, objectFit: 'cover' }}
/>
) : (
<Text fontSize="$sm" color={previewSurfaceText} fontWeight="700">
{previewLogoValue || previewInitials}
</Text>
)}
</YStack>
<YStack>
<Text
fontWeight="800"
color={previewSurfaceText}
style={{ fontFamily: previewHeadingFont, fontSize: 18 * previewScale }}
>
{previewTitle} {previewTitle}
</Text> </Text>
<Text <Text fontSize="$sm" color={muted} style={{ fontFamily: previewBodyFont }}>
color={previewSurfaceText}
style={{ fontFamily: previewBodyFont, opacity: 0.7, fontSize: 13 * previewScale }}
>
{t('events.branding.previewSubtitle', 'Aktuelle Farben & Schriften')} {t('events.branding.previewSubtitle', 'Aktuelle Farben & Schriften')}
</Text> </Text>
</YStack>
</XStack>
<XStack space="$2" marginTop="$1"> <XStack space="$2" marginTop="$1">
<ColorSwatch color={previewForm.primary} label={t('events.branding.primary', 'Primary')} /> <ColorSwatch color={form.primary} label={t('events.branding.primary', 'Primary')} />
<ColorSwatch color={previewForm.accent} label={t('events.branding.accent', 'Accent')} /> <ColorSwatch color={form.accent} label={t('events.branding.accent', 'Accent')} />
<ColorSwatch color={previewForm.background} label={t('events.branding.background', 'Background')} />
<ColorSwatch color={previewForm.surface} label={t('events.branding.surface', 'Surface')} />
</XStack>
<XStack marginTop="$2">
<div
style={{
padding: '8px 14px',
borderRadius: previewForm.buttonRadius,
background: previewForm.buttonStyle === 'outline' ? 'transparent' : previewButtonColor,
color: previewForm.buttonStyle === 'outline' ? previewForm.linkColor : previewButtonText,
border: previewForm.buttonStyle === 'outline' ? `1px solid ${previewForm.linkColor}` : 'none',
fontWeight: 700,
fontSize: 13 * previewScale,
}}
>
{t('events.branding.previewCta', 'Fotos hochladen')}
</div>
</XStack> </XStack>
</YStack> </YStack>
</YStack> </YStack>
</YStack> </YStack>
</MobileCard> </MobileCard>
{!brandingAllowed ? (
<InfoBadge
icon={<Lock size={16} color={danger} />}
text={t('events.branding.lockedBranding', 'Branding ist in diesem Paket gesperrt.')}
tone="danger"
/>
) : 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', 'Nutze das Tenant-Branding oder überschreibe es für dieses Event.')}
</Text>
<XStack space="$2">
<ModeButton
label={t('events.branding.useDefault', 'Tenant')}
active={form.useDefaultBranding}
onPress={() => setForm((prev) => ({ ...prev, useDefaultBranding: true }))}
disabled={!brandingAllowed}
/>
<ModeButton
label={t('events.branding.useCustom', 'Event')}
active={!form.useDefaultBranding}
onPress={() => setForm((prev) => ({ ...prev, useDefaultBranding: false }))}
disabled={!brandingAllowed}
/>
</XStack>
<Text fontSize="$xs" color={muted}>
{form.useDefaultBranding
? t('events.branding.usingDefault', 'Tenant-Branding aktiv')
: t('events.branding.usingCustom', 'Event-Branding aktiv')}
</Text>
</MobileCard>
<MobileCard space="$3">
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('events.branding.mode', 'Theme')}
</Text>
<XStack space="$2">
<ModeButton
label={t('events.branding.modeLight', 'Light')}
active={form.mode === 'light'}
onPress={() => setForm((prev) => ({ ...prev, mode: 'light' }))}
disabled={brandingDisabled}
/>
<ModeButton
label={t('events.branding.modeAuto', 'Auto')}
active={form.mode === 'auto'}
onPress={() => setForm((prev) => ({ ...prev, mode: 'auto' }))}
disabled={brandingDisabled}
/>
<ModeButton
label={t('events.branding.modeDark', 'Dark')}
active={form.mode === 'dark'}
onPress={() => setForm((prev) => ({ ...prev, mode: 'dark' }))}
disabled={brandingDisabled}
/>
</XStack>
</MobileCard>
<MobileCard space="$3"> <MobileCard space="$3">
<Text fontSize="$md" fontWeight="800" color={textStrong}> <Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('events.branding.colors', 'Colors')} {t('events.branding.colors', 'Colors')}
@@ -673,25 +462,11 @@ export default function MobileBrandingPage() {
label={t('events.branding.primary', 'Primary Color')} label={t('events.branding.primary', 'Primary Color')}
value={form.primary} value={form.primary}
onChange={(value) => setForm((prev) => ({ ...prev, primary: value }))} onChange={(value) => setForm((prev) => ({ ...prev, primary: value }))}
disabled={brandingDisabled}
/> />
<ColorField <ColorField
label={t('events.branding.accent', 'Accent Color')} label={t('events.branding.accent', 'Accent Color')}
value={form.accent} value={form.accent}
onChange={(value) => setForm((prev) => ({ ...prev, accent: value }))} onChange={(value) => setForm((prev) => ({ ...prev, accent: value }))}
disabled={brandingDisabled}
/>
<ColorField
label={t('events.branding.backgroundColor', 'Background Color')}
value={form.background}
onChange={(value) => setForm((prev) => ({ ...prev, background: value }))}
disabled={brandingDisabled}
/>
<ColorField
label={t('events.branding.surfaceColor', 'Surface Color')}
value={form.surface}
onChange={(value) => setForm((prev) => ({ ...prev, surface: value }))}
disabled={brandingDisabled}
/> />
</MobileCard> </MobileCard>
@@ -708,7 +483,6 @@ export default function MobileBrandingPage() {
setFontField('heading'); setFontField('heading');
setShowFontsSheet(true); setShowFontsSheet(true);
}} }}
disabled={brandingDisabled}
/> />
<InputField <InputField
label={t('events.branding.bodyFont', 'Body Font')} label={t('events.branding.bodyFont', 'Body Font')}
@@ -719,64 +493,13 @@ export default function MobileBrandingPage() {
setFontField('body'); setFontField('body');
setShowFontsSheet(true); setShowFontsSheet(true);
}} }}
disabled={brandingDisabled}
/> />
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{t('events.branding.fontSize', 'Font Size')}
</Text>
<XStack space="$2">
<ModeButton
label={t('events.branding.fontSizeSmall', 'S')}
active={form.fontSize === 's'}
onPress={() => setForm((prev) => ({ ...prev, fontSize: 's' }))}
disabled={brandingDisabled}
/>
<ModeButton
label={t('events.branding.fontSizeMedium', 'M')}
active={form.fontSize === 'm'}
onPress={() => setForm((prev) => ({ ...prev, fontSize: 'm' }))}
disabled={brandingDisabled}
/>
<ModeButton
label={t('events.branding.fontSizeLarge', 'L')}
active={form.fontSize === 'l'}
onPress={() => setForm((prev) => ({ ...prev, fontSize: 'l' }))}
disabled={brandingDisabled}
/>
</XStack>
</MobileCard> </MobileCard>
<MobileCard space="$3"> <MobileCard space="$3">
<Text fontSize="$md" fontWeight="800" color={textStrong}> <Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('events.branding.logo', 'Logo')} {t('events.branding.logo', 'Logo')}
</Text> </Text>
<Text fontSize="$sm" color={muted}>
{t('events.branding.logoHint', 'Upload a logo or use an emoji for the guest header.')}
</Text>
<XStack space="$2">
<ModeButton
label={t('events.branding.logoModeUpload', 'Upload')}
active={form.logoMode === 'upload'}
onPress={() => setForm((prev) => ({ ...prev, logoMode: 'upload' }))}
disabled={brandingDisabled}
/>
<ModeButton
label={t('events.branding.logoModeEmoticon', 'Emoticon')}
active={form.logoMode === 'emoticon'}
onPress={() => setForm((prev) => ({ ...prev, logoMode: 'emoticon' }))}
disabled={brandingDisabled}
/>
</XStack>
{form.logoMode === 'emoticon' ? (
<InputField
label={t('events.branding.logoValue', 'Emoticon')}
value={form.logoValue}
placeholder={t('events.branding.logoValuePlaceholder', '🎉')}
onChange={(value) => setForm((prev) => ({ ...prev, logoValue: value }))}
disabled={brandingDisabled}
/>
) : (
<YStack <YStack
borderRadius={14} borderRadius={14}
borderWidth={1} borderWidth={1}
@@ -798,12 +521,8 @@ export default function MobileBrandingPage() {
<CTAButton <CTAButton
label={t('events.branding.replaceLogo', 'Replace logo')} label={t('events.branding.replaceLogo', 'Replace logo')}
onPress={() => document.getElementById('branding-logo-input')?.click()} onPress={() => document.getElementById('branding-logo-input')?.click()}
disabled={brandingDisabled}
/> />
<Pressable <Pressable onPress={() => setForm((prev) => ({ ...prev, logoDataUrl: '' }))}>
disabled={brandingDisabled}
onPress={() => setForm((prev) => ({ ...prev, logoDataUrl: '' }))}
>
<XStack <XStack
alignItems="center" alignItems="center"
space="$1.5" space="$1.5"
@@ -824,10 +543,10 @@ export default function MobileBrandingPage() {
) : ( ) : (
<> <>
<ImageIcon size={28} color={subtle} /> <ImageIcon size={28} color={subtle} />
<Pressable <Text fontSize="$sm" color={muted} textAlign="center">
disabled={brandingDisabled} {t('events.branding.logoHint', 'Upload a logo to brand guest invites and QR posters.')}
onPress={() => document.getElementById('branding-logo-input')?.click()} </Text>
> <Pressable onPress={() => document.getElementById('branding-logo-input')?.click()}>
<XStack <XStack
alignItems="center" alignItems="center"
space="$2" space="$2"
@@ -851,7 +570,6 @@ export default function MobileBrandingPage() {
type="file" type="file"
accept="image/*" accept="image/*"
style={{ display: 'none' }} style={{ display: 'none' }}
disabled={brandingDisabled}
onChange={(event) => { onChange={(event) => {
const file = event.target.files?.[0]; const file = event.target.files?.[0];
if (!file) return; if (!file) return;
@@ -874,104 +592,6 @@ export default function MobileBrandingPage() {
}} }}
/> />
</YStack> </YStack>
)}
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{t('events.branding.logoPosition', 'Position')}
</Text>
<XStack space="$2">
<ModeButton
label={t('events.branding.positionLeft', 'Left')}
active={form.logoPosition === 'left'}
onPress={() => setForm((prev) => ({ ...prev, logoPosition: 'left' }))}
disabled={brandingDisabled}
/>
<ModeButton
label={t('events.branding.positionCenter', 'Center')}
active={form.logoPosition === 'center'}
onPress={() => setForm((prev) => ({ ...prev, logoPosition: 'center' }))}
disabled={brandingDisabled}
/>
<ModeButton
label={t('events.branding.positionRight', 'Right')}
active={form.logoPosition === 'right'}
onPress={() => setForm((prev) => ({ ...prev, logoPosition: 'right' }))}
disabled={brandingDisabled}
/>
</XStack>
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{t('events.branding.logoSize', 'Size')}
</Text>
<XStack space="$2">
<ModeButton
label={t('events.branding.logoSizeSmall', 'S')}
active={form.logoSize === 's'}
onPress={() => setForm((prev) => ({ ...prev, logoSize: 's' }))}
disabled={brandingDisabled}
/>
<ModeButton
label={t('events.branding.logoSizeMedium', 'M')}
active={form.logoSize === 'm'}
onPress={() => setForm((prev) => ({ ...prev, logoSize: 'm' }))}
disabled={brandingDisabled}
/>
<ModeButton
label={t('events.branding.logoSizeLarge', 'L')}
active={form.logoSize === 'l'}
onPress={() => setForm((prev) => ({ ...prev, logoSize: 'l' }))}
disabled={brandingDisabled}
/>
</XStack>
</MobileCard>
<MobileCard space="$3">
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('events.branding.buttons', 'Buttons & Links')}
</Text>
<Text fontSize="$sm" color={muted}>
{t('events.branding.buttonsHint', 'Style, radius, and link color for CTA buttons.')}
</Text>
<XStack space="$2">
<ModeButton
label={t('events.branding.buttonFilled', 'Filled')}
active={form.buttonStyle === 'filled'}
onPress={() => setForm((prev) => ({ ...prev, buttonStyle: 'filled' }))}
disabled={brandingDisabled}
/>
<ModeButton
label={t('events.branding.buttonOutline', 'Outline')}
active={form.buttonStyle === 'outline'}
onPress={() => setForm((prev) => ({ ...prev, buttonStyle: 'outline' }))}
disabled={brandingDisabled}
/>
</XStack>
<LabeledSlider
label={t('events.branding.buttonRadius', 'Radius')}
value={form.buttonRadius}
min={0}
max={32}
step={1}
onChange={(value) => setForm((prev) => ({ ...prev, buttonRadius: value }))}
disabled={brandingDisabled}
/>
<ColorField
label={t('events.branding.buttonPrimary', 'Button Primary')}
value={form.buttonPrimary}
onChange={(value) => setForm((prev) => ({ ...prev, buttonPrimary: value }))}
disabled={brandingDisabled}
/>
<ColorField
label={t('events.branding.buttonSecondary', 'Button Secondary')}
value={form.buttonSecondary}
onChange={(value) => setForm((prev) => ({ ...prev, buttonSecondary: value }))}
disabled={brandingDisabled}
/>
<ColorField
label={t('events.branding.linkColor', 'Link Color')}
value={form.linkColor}
onChange={(value) => setForm((prev) => ({ ...prev, linkColor: value }))}
disabled={brandingDisabled}
/>
</MobileCard> </MobileCard>
</> </>
) : ( ) : (
@@ -1051,6 +671,26 @@ export default function MobileBrandingPage() {
); );
} }
function extractBranding(event: TenantEvent): BrandingForm {
const source = (event.settings as Record<string, unknown>) ?? {};
const branding = (source.branding as Record<string, unknown>) ?? source;
const readColor = (key: string, fallback: string) => {
const value = branding[key];
return typeof value === 'string' && value.startsWith('#') ? value : fallback;
};
const readText = (key: string) => {
const value = branding[key];
return typeof value === 'string' ? value : '';
};
return {
primary: readColor('primary_color', ADMIN_COLORS.primary),
accent: readColor('accent_color', ADMIN_COLORS.accent),
headingFont: readText('heading_font'),
bodyFont: readText('body_font'),
logoDataUrl: readText('logo_data_url'),
};
}
function extractWatermark(event: TenantEvent): WatermarkForm { function extractWatermark(event: TenantEvent): WatermarkForm {
const settings = (event.settings as Record<string, unknown>) ?? {}; const settings = (event.settings as Record<string, unknown>) ?? {};
const wm = (settings.watermark as Record<string, unknown>) ?? {}; const wm = (settings.watermark as Record<string, unknown>) ?? {};
@@ -1122,47 +762,10 @@ function renderName(name: TenantEvent['name']): string {
return ''; return '';
} }
function normalizeBrandingPath(value: string): string { function ColorField({ label, value, onChange }: { label: string; value: string; onChange: (next: string) => void }) {
const trimmed = value.trim();
if (!trimmed) {
return '';
}
if (trimmed.startsWith('http://') || trimmed.startsWith('https://') || trimmed.startsWith('data:')) {
return trimmed;
}
const normalized = trimmed.replace(/^\/+/, '');
if (normalized.startsWith('storage/')) {
return normalized.slice('storage/'.length);
}
return normalized;
}
function getInitials(name: string): string {
const words = name.split(' ').filter(Boolean);
if (words.length >= 2) {
return `${words[0][0]}${words[1][0]}`.toUpperCase();
}
return name.substring(0, 2).toUpperCase();
}
function ColorField({
label,
value,
onChange,
disabled,
}: {
label: string;
value: string;
onChange: (next: string) => void;
disabled?: boolean;
}) {
const { textStrong, muted, border, surface } = useAdminTheme(); const { textStrong, muted, border, surface } = useAdminTheme();
return ( return (
<YStack space="$2" opacity={disabled ? 0.6 : 1}> <YStack space="$2">
<Text fontSize="$sm" fontWeight="700" color={textStrong}> <Text fontSize="$sm" fontWeight="700" color={textStrong}>
{label} {label}
</Text> </Text>
@@ -1171,7 +774,6 @@ function ColorField({
type="color" type="color"
value={value} value={value}
onChange={(event) => onChange(event.target.value)} onChange={(event) => onChange(event.target.value)}
disabled={disabled}
style={{ width: 52, height: 52, borderRadius: 12, border: `1px solid ${border}`, background: surface }} style={{ width: 52, height: 52, borderRadius: 12, border: `1px solid ${border}`, background: surface }}
/> />
<Text fontSize="$sm" color={muted}> <Text fontSize="$sm" color={muted}>
@@ -1201,7 +803,6 @@ function InputField({
onChange, onChange,
onPicker, onPicker,
children, children,
disabled,
}: { }: {
label: string; label: string;
value: string; value: string;
@@ -1209,11 +810,10 @@ function InputField({
onChange: (next: string) => void; onChange: (next: string) => void;
onPicker?: () => void; onPicker?: () => void;
children?: React.ReactNode; children?: React.ReactNode;
disabled?: boolean;
}) { }) {
const { textStrong, border, surface, primary } = useAdminTheme(); const { textStrong, border, surface, primary } = useAdminTheme();
return ( return (
<YStack space="$2" opacity={disabled ? 0.6 : 1}> <YStack space="$2">
<Text fontSize="$sm" fontWeight="700" color={textStrong}> <Text fontSize="$sm" fontWeight="700" color={textStrong}>
{label} {label}
</Text> </Text>
@@ -1234,7 +834,6 @@ function InputField({
value={value} value={value}
placeholder={placeholder} placeholder={placeholder}
onChange={(event) => onChange(event.target.value)} onChange={(event) => onChange(event.target.value)}
disabled={disabled}
style={{ style={{
flex: 1, flex: 1,
height: '100%', height: '100%',
@@ -1247,7 +846,7 @@ function InputField({
/> />
)} )}
{onPicker ? ( {onPicker ? (
<Pressable onPress={onPicker} disabled={disabled}> <Pressable onPress={onPicker}>
<ChevronDown size={16} color={primary} /> <ChevronDown size={16} color={primary} />
</Pressable> </Pressable>
) : null} ) : null}
@@ -1479,34 +1078,3 @@ function TabButton({ label, active, onPress }: { label: string; active: boolean;
</Pressable> </Pressable>
); );
} }
function ModeButton({
label,
active,
onPress,
disabled,
}: {
label: string;
active: boolean;
onPress: () => void;
disabled?: boolean;
}) {
const { backdrop, surfaceMuted, border, surface } = useAdminTheme();
return (
<Pressable onPress={onPress} disabled={disabled} style={{ flex: 1, opacity: disabled ? 0.6 : 1 }}>
<XStack
alignItems="center"
justifyContent="center"
paddingVertical="$2"
borderRadius={10}
backgroundColor={active ? backdrop : surfaceMuted}
borderWidth={1}
borderColor={active ? backdrop : border}
>
<Text fontSize="$xs" color={active ? surface : backdrop} fontWeight="700">
{label}
</Text>
</XStack>
</Pressable>
);
}

View File

@@ -1,9 +1,10 @@
import React from 'react'; import React from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { RefreshCcw, PlugZap, RefreshCw, ShieldCheck, Copy, Power, Clock3, Mail, Download } from 'lucide-react'; import { RefreshCcw, PlugZap, RefreshCw, ShieldCheck, Copy, Power, Clock3 } 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 { Switch } from '@tamagui/switch';
import { Pressable } from '@tamagui/react-native-web-lite'; import { Pressable } from '@tamagui/react-native-web-lite';
import { MobileShell, HeaderActionButton } from './components/MobileShell'; import { MobileShell, HeaderActionButton } from './components/MobileShell';
import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Primitives'; import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Primitives';
@@ -13,14 +14,12 @@ import {
enableEventPhotobooth, enableEventPhotobooth,
disableEventPhotobooth, disableEventPhotobooth,
rotateEventPhotobooth, rotateEventPhotobooth,
createEventPhotoboothConnectCode,
sendEventPhotoboothUploaderEmail,
PhotoboothStatus, PhotoboothStatus,
TenantEvent, TenantEvent,
} from '../api'; } from '../api';
import { isAuthError } from '../auth/tokens'; import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage } from '../lib/apiError'; import { getApiErrorMessage } from '../lib/apiError';
import { formatEventDate, formatEventDateTime } from '../lib/events'; import { formatEventDate } from '../lib/events';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { adminPath } from '../constants'; import { adminPath } from '../constants';
import { useBackNavigation } from './hooks/useBackNavigation'; import { useBackNavigation } from './hooks/useBackNavigation';
@@ -35,14 +34,10 @@ export default function MobileEventPhotoboothPage() {
const [event, setEvent] = React.useState<TenantEvent | null>(null); const [event, setEvent] = React.useState<TenantEvent | null>(null);
const [status, setStatus] = React.useState<PhotoboothStatus | null>(null); const [status, setStatus] = React.useState<PhotoboothStatus | null>(null);
const [selectedMode, setSelectedMode] = React.useState<'ftp' | 'sparkbooth'>('ftp');
const [loading, setLoading] = React.useState(true); const [loading, setLoading] = React.useState(true);
const [updating, setUpdating] = React.useState(false); const [updating, setUpdating] = React.useState(false);
const [error, setError] = React.useState<string | null>(null); const [error, setError] = React.useState<string | null>(null);
const [connectCode, setConnectCode] = React.useState<string | null>(null);
const [connectExpiresAt, setConnectExpiresAt] = React.useState<string | null>(null);
const [connectLoading, setConnectLoading] = React.useState(false);
const [sendingEmail, setSendingEmail] = React.useState(false);
const [showCredentials, setShowCredentials] = React.useState(false);
const back = useBackNavigation(slug ? adminPath(`/mobile/events/${slug}`) : adminPath('/mobile/events')); const back = useBackNavigation(slug ? adminPath(`/mobile/events/${slug}`) : adminPath('/mobile/events'));
const locale = i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE'; const locale = i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE';
@@ -55,6 +50,7 @@ export default function MobileEventPhotoboothPage() {
const [eventData, statusData] = await Promise.all([getEvent(slug), getEventPhotoboothStatus(slug)]); const [eventData, statusData] = await Promise.all([getEvent(slug), getEventPhotoboothStatus(slug)]);
setEvent(eventData); setEvent(eventData);
setStatus(statusData); setStatus(statusData);
setSelectedMode(statusData.mode ?? 'ftp');
} catch (err) { } catch (err) {
if (!isAuthError(err)) { if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('management.photobooth.errors.loadFailed', 'Photobooth-Link konnte nicht geladen werden.'))); setError(getApiErrorMessage(err, t('management.photobooth.errors.loadFailed', 'Photobooth-Link konnte nicht geladen werden.')));
@@ -68,14 +64,20 @@ export default function MobileEventPhotoboothPage() {
void load(); void load();
}, [load]); }, [load]);
React.useEffect(() => {
if (status?.mode) {
setSelectedMode(status.mode);
}
}, [status?.mode]);
const handleEnable = async () => { const handleEnable = async (mode?: 'ftp' | 'sparkbooth') => {
if (!slug) return; if (!slug) return;
const nextMode = 'sparkbooth'; const nextMode = mode ?? selectedMode ?? status?.mode ?? 'ftp';
setUpdating(true); setUpdating(true);
try { try {
const result = await enableEventPhotobooth(slug, { mode: nextMode }); const result = await enableEventPhotobooth(slug, { mode: nextMode });
setStatus(result); setStatus(result);
setSelectedMode(result.mode ?? nextMode);
toast.success(t('management.photobooth.actions.enable', 'Zugang aktiviert')); toast.success(t('management.photobooth.actions.enable', 'Zugang aktiviert'));
} catch (err) { } catch (err) {
if (!isAuthError(err)) { if (!isAuthError(err)) {
@@ -88,11 +90,12 @@ export default function MobileEventPhotoboothPage() {
const handleDisable = async () => { const handleDisable = async () => {
if (!slug) return; if (!slug) return;
const mode = 'sparkbooth'; const mode = status?.mode ?? selectedMode ?? 'ftp';
setUpdating(true); setUpdating(true);
try { try {
const result = await disableEventPhotobooth(slug, { mode }); const result = await disableEventPhotobooth(slug, { mode });
setStatus(result); setStatus(result);
setSelectedMode(result.mode ?? mode);
toast.success(t('management.photobooth.actions.disable', 'Zugang deaktiviert')); toast.success(t('management.photobooth.actions.disable', 'Zugang deaktiviert'));
} catch (err) { } catch (err) {
if (!isAuthError(err)) { if (!isAuthError(err)) {
@@ -105,11 +108,12 @@ export default function MobileEventPhotoboothPage() {
const handleRotate = async () => { const handleRotate = async () => {
if (!slug) return; if (!slug) return;
const mode = 'sparkbooth'; const mode = selectedMode ?? status?.mode ?? 'ftp';
setUpdating(true); setUpdating(true);
try { try {
const result = await rotateEventPhotobooth(slug, { mode }); const result = await rotateEventPhotobooth(slug, { mode });
setStatus(result); setStatus(result);
setSelectedMode(result.mode ?? mode);
toast.success(t('management.photobooth.presets.actions.rotate', 'Zugang zurückgesetzt')); toast.success(t('management.photobooth.presets.actions.rotate', 'Zugang zurückgesetzt'));
} catch (err) { } catch (err) {
if (!isAuthError(err)) { if (!isAuthError(err)) {
@@ -120,57 +124,39 @@ export default function MobileEventPhotoboothPage() {
} }
}; };
const handleGenerateConnectCode = async () => { const activeMode = selectedMode ?? status?.mode ?? 'ftp';
if (!slug) return; const isSpark = activeMode === 'sparkbooth';
setConnectLoading(true);
try {
const result = await createEventPhotoboothConnectCode(slug);
setConnectCode(result.code || null);
setConnectExpiresAt(result.expires_at ?? null);
toast.success(t('photobooth.connectCode.actions.generated', 'Verbindungscode erstellt'));
} catch (err) {
if (!isAuthError(err)) {
toast.error(
getApiErrorMessage(err, t('photobooth.connectCode.errors.failed', 'Verbindungscode konnte nicht erstellt werden.'))
);
}
} finally {
setConnectLoading(false);
}
};
const handleSendDownloadEmail = async () => {
if (!slug) return;
setSendingEmail(true);
try {
await sendEventPhotoboothUploaderEmail(slug);
toast.success(t('photobooth.uploaderDownload.emailSuccess', 'Download-Links wurden per E-Mail gesendet.'));
} catch (err) {
if (!isAuthError(err)) {
toast.error(
getApiErrorMessage(err, t('photobooth.uploaderDownload.emailFailed', 'E-Mail konnte nicht gesendet werden.'))
);
}
} finally {
setSendingEmail(false);
}
};
const spark = status?.sparkbooth ?? null; const spark = status?.sparkbooth ?? null;
const metrics = spark?.metrics ?? null; const ftp = status?.ftp ?? null;
const expiresAt = spark?.expires_at ?? status?.expires_at; const metrics = isSpark ? spark?.metrics ?? null : status?.metrics ?? null;
const expiresAt = isSpark ? spark?.expires_at ?? status?.expires_at : status?.expires_at ?? spark?.expires_at;
const lastUploadAt = metrics?.last_upload_at; const lastUploadAt = metrics?.last_upload_at;
const uploads24h = metrics?.uploads_24h ?? metrics?.uploads_today; const uploads24h = metrics?.uploads_24h ?? metrics?.uploads_today;
const uploadsTotal = metrics?.uploads_total; const uploadsTotal = metrics?.uploads_total;
const uploadUrl = spark?.upload_url ?? status?.upload_url; const connectionPath = status?.path ?? '—';
const username = spark?.username ?? status?.username ?? null; const ftpUrl = status?.ftp_url ?? '—';
const password = spark?.password ?? status?.password ?? null; const uploadUrl = isSpark ? spark?.upload_url ?? status?.upload_url : null;
const responseFormat = spark?.response_format ?? 'json';
const username = isSpark ? spark?.username ?? status?.username : status?.username ?? spark?.username ?? null;
const password = isSpark ? spark?.password ?? status?.password : status?.password ?? spark?.password ?? null;
const modeLabel = t('photobooth.mode.uploader', 'Uploader App (HTTP)'); const modeLabel =
activeMode === 'sparkbooth'
? t('photobooth.credentials.sparkboothTitle', 'Sparkbooth / HTTP')
: t('photobooth.credentials.heading', 'FTP (Classic)');
const isActive = Boolean(status?.enabled); const isActive = Boolean(status?.enabled);
const title = t('photobooth.title', 'Photobooth'); const title = t('photobooth.title', 'Photobooth');
const handleToggle = (checked: boolean) => {
if (!slug || updating) return;
if (checked) {
void handleEnable(status?.mode ?? 'ftp');
} else {
void handleDisable();
}
};
return ( return (
<MobileShell <MobileShell
activeTab="home" activeTab="home"
@@ -199,159 +185,143 @@ export default function MobileEventPhotoboothPage() {
) : ( ) : (
<YStack space="$2"> <YStack space="$2">
<MobileCard space="$3"> <MobileCard space="$3">
<YStack space="$1"> <XStack justifyContent="space-between" alignItems="center" space="$3" flexWrap="wrap">
<Text fontSize="$sm" fontWeight="800" color={text}> <YStack space="$1" flex={1} minWidth={0}>
{t('photobooth.steps.activate.title', '1. Photobooth aktivieren')} <Text fontSize="$md" fontWeight="800" color={text}>
{t('photobooth.title', 'Photobooth')}
</Text> </Text>
<Text fontSize="$xs" color={muted}> <Text fontSize="$xs" color={muted}>
{t('photobooth.steps.activate.description', 'Schalte den Upload-Zugang fuer dieses Event frei.')} {t('photobooth.credentials.description', 'Share these credentials with your photobooth software.')}
</Text> </Text>
</YStack>
<XStack alignItems="center" justifyContent="space-between" space="$3" flexWrap="wrap">
<PillBadge tone={isActive ? 'success' : 'warning'}>
{isActive ? t('photobooth.status.badgeActive', 'ACTIVE') : t('photobooth.status.badgeInactive', 'INACTIVE')}
</PillBadge>
<Text fontSize="$xs" color={muted}> <Text fontSize="$xs" color={muted}>
{t('photobooth.mode.active', 'Current: {{mode}}', { mode: modeLabel })} {t('photobooth.mode.active', 'Current: {{mode}}', { mode: modeLabel })}
</Text> </Text>
</XStack> </YStack>
<XStack space="$2" marginTop="$2"> <YStack alignItems="flex-end" space="$2">
<CTAButton <PillBadge tone={isActive ? 'success' : 'warning'}>
label={isActive ? t('photobooth.actions.disable', 'Disable uploads') : t('photobooth.actions.enable', 'Enable uploads')} {isActive ? t('photobooth.status.badgeActive', 'ACTIVE') : t('photobooth.status.badgeInactive', 'INACTIVE')}
onPress={() => (isActive ? handleDisable() : handleEnable())} </PillBadge>
tone={isActive ? 'ghost' : 'primary'} <XStack alignItems="center" space="$2">
iconLeft={isActive ? <Power size={14} color={text} /> : <PlugZap size={14} color={surface} />} <Text fontSize="$xs" color={muted}>
{isActive ? t('common.enabled', 'Enabled') : t('common.disabled', 'Disabled')}
</Text>
<Switch
size="$4"
checked={isActive}
disabled={updating} disabled={updating}
fullWidth={false} onCheckedChange={handleToggle}
/> aria-label={t('photobooth.actions.toggle', 'Toggle photobooth access')}
{isActive ? ( >
<CTAButton <Switch.Thumb />
label={t('photobooth.actions.rotate', 'Regenerate access')} </Switch>
onPress={() => handleRotate()}
tone="ghost"
iconLeft={<RefreshCw size={14} color={text} />}
disabled={updating}
fullWidth={false}
/>
) : null}
</XStack> </XStack>
</YStack>
</XStack>
<YStack space="$1" marginTop="$2">
<XStack justifyContent="space-between" alignItems="center">
<Text fontSize="$xs" color={muted}>
{t('photobooth.stats.lastUpload', 'Last upload')}
</Text>
<Text fontSize="$xs" fontWeight="700" color={text}>
{lastUploadAt ? formatEventDate(lastUploadAt, locale) : t('photobooth.status.never', 'Never')}
</Text>
</XStack>
<XStack justifyContent="space-between" alignItems="center">
<Text fontSize="$xs" color={muted}>
{t('photobooth.status.expires', 'Access expires')}
</Text>
<Text fontSize="$xs" fontWeight="700" color={text}>
{expiresAt ? formatEventDate(expiresAt, locale) : '—'}
</Text>
</XStack>
</YStack>
</MobileCard> </MobileCard>
<MobileCard space="$3"> <MobileCard space="$2">
<YStack space="$1"> <Text fontSize="$sm" fontWeight="700" color={text}>
<Text fontSize="$sm" fontWeight="800" color={text}> {t('photobooth.selector.title', 'Choose adapter')}
{t('photobooth.steps.download.title', '2. Uploader App herunterladen')}
</Text> </Text>
<Text fontSize="$xs" color={muted}> <Text fontSize="$xs" color={muted}>
{t( {t(
'photobooth.uploaderDownload.description', 'photobooth.selector.description',
'Die Fotospiel Uploader App ist verpflichtend, damit Uploads stabil laufen, die Zugangsdaten geschuetzt bleiben und keine Dateien verloren gehen.' 'FTP (Classic) works with most booths. Sparkbooth uses HTTP POST without FTP.'
)} )}
</Text> </Text>
</YStack> <XStack space="$2" marginTop="$2" flexWrap="nowrap">
<XStack space="$2" marginTop="$2" flexWrap="wrap"> <XStack flex={1} minWidth={0}>
<CTAButton <CTAButton
label={t('photobooth.uploaderDownload.actionWindows', 'Uploader herunterladen (Windows)')} label={t('photobooth.mode.ftp', 'FTP (Classic)')}
onPress={() => { tone={activeMode === 'ftp' ? 'primary' : 'ghost'}
const url = new URL('/downloads/PhotoboothUploader-win-x64.exe', window.location.origin).toString(); onPress={() => setSelectedMode('ftp')}
window.open(url, '_blank', 'noopener,noreferrer'); disabled={updating}
}} style={{ width: '100%', paddingHorizontal: 10, paddingVertical: 10 }}
iconLeft={<Download size={14} color={surface} />}
fullWidth={false}
/>
<CTAButton
label={t('photobooth.uploaderDownload.actionMac', 'Uploader herunterladen (macOS)')}
tone="ghost"
onPress={() => {
const url = new URL('/downloads/PhotoboothUploader-macos-x64', window.location.origin).toString();
window.open(url, '_blank', 'noopener,noreferrer');
}}
fullWidth={false}
/>
<CTAButton
label={t('photobooth.uploaderDownload.actionLinux', 'Uploader herunterladen (Linux)')}
tone="ghost"
onPress={() => {
const url = new URL('/downloads/PhotoboothUploader-linux-x64', window.location.origin).toString();
window.open(url, '_blank', 'noopener,noreferrer');
}}
fullWidth={false}
/> />
</XStack> </XStack>
<XStack space="$2" marginTop="$2"> <XStack flex={1} minWidth={0}>
<CTAButton <CTAButton
label={ label={t('photobooth.mode.sparkbooth', 'Sparkbooth (HTTP POST)')}
sendingEmail tone={activeMode === 'sparkbooth' ? 'primary' : 'ghost'}
? t('common.processing', '...') onPress={() => setSelectedMode('sparkbooth')}
: t('photobooth.uploaderDownload.emailAction', 'Download-Links per E-Mail senden') disabled={updating}
} style={{ width: '100%', paddingHorizontal: 10, paddingVertical: 10 }}
tone="ghost"
onPress={handleSendDownloadEmail}
iconLeft={<Mail size={14} color={text} />}
disabled={sendingEmail}
fullWidth={false}
/> />
</XStack> </XStack>
</XStack>
</MobileCard> </MobileCard>
<MobileCard space="$3"> <MobileCard space="$2">
<YStack space="$1"> <XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$sm" fontWeight="800" color={text}> <Text fontSize="$sm" fontWeight="700" color={text}>
{t('photobooth.steps.access.title', '3. Verbindungscode erstellen')} {isSpark ? t('photobooth.credentials.sparkboothTitle', 'Sparkbooth upload (HTTP)') : t('photobooth.credentials.heading', 'FTP credentials')}
</Text> </Text>
<Text fontSize="$xs" color={muted}> {!isSpark && ftp?.require_ftps ? <PillBadge tone="warning">{t('photobooth.credentials.ftps', 'FTPS required')}</PillBadge> : null}
{t('photobooth.steps.access.description', 'Der Code verbindet die App sicher mit deinem Event.')}
</Text>
</YStack>
<XStack space="$2" marginTop="$2">
<CTAButton
label={
connectLoading
? t('common.processing', '...')
: t('photobooth.connectCode.actions.generate', 'Generate connect code')
}
onPress={handleGenerateConnectCode}
iconLeft={<PlugZap size={14} color={surface} />}
disabled={!isActive || updating || connectLoading}
fullWidth={false}
/>
<CTAButton
label={
showCredentials
? t('photobooth.credentials.hide', 'Hide credentials')
: t('photobooth.credentials.show', 'Show credentials')
}
tone="ghost"
onPress={() => setShowCredentials((current) => !current)}
fullWidth={false}
/>
</XStack> </XStack>
<YStack space="$2" marginTop="$2">
{connectCode ? (
<CredentialRow label={t('photobooth.connectCode.label', 'Connect code')} value={connectCode} border={border} />
) : null}
{connectExpiresAt ? (
<Text fontSize="$xs" color={muted}>
{t('photobooth.connectCode.expires', 'Expires: {{date}}', {
date: formatEventDateTime(connectExpiresAt, locale),
})}
</Text>
) : null}
{showCredentials ? (
<YStack space="$1"> <YStack space="$1">
{isSpark ? (
<>
<CredentialRow label={t('photobooth.credentials.postUrl', 'Upload URL')} value={uploadUrl ?? '—'} border={border} /> <CredentialRow label={t('photobooth.credentials.postUrl', 'Upload URL')} value={uploadUrl ?? '—'} border={border} />
<CredentialRow label={t('photobooth.credentials.username', 'Username')} value={username ?? '—'} border={border} /> <CredentialRow label={t('photobooth.credentials.username', 'Username')} value={username ?? '—'} border={border} />
<CredentialRow label={t('photobooth.credentials.password', 'Password')} value={password ?? '—'} border={border} masked /> <CredentialRow label={t('photobooth.credentials.password', 'Password')} value={password ?? '—'} border={border} masked />
</YStack> <CredentialRow label={t('photobooth.sparkbooth.format', 'Response format')} value={responseFormat.toUpperCase()} border={border} />
<Text fontSize="$xs" color={muted}>
{t('photobooth.sparkbooth.hint', 'POST with media file or base64 "media" field; username/password required.')}
</Text>
</>
) : ( ) : (
<>
<CredentialRow label={t('photobooth.credentials.host', 'Host')} value={ftp?.host ?? '—'} border={border} />
<CredentialRow label={t('photobooth.credentials.port', 'Port')} value={String(ftp?.port ?? '—')} border={border} />
<CredentialRow label={t('photobooth.credentials.path', 'Target folder')} value={connectionPath} border={border} />
<CredentialRow label={t('photobooth.credentials.postUrl', 'FTP URL')} value={ftpUrl} border={border} />
<CredentialRow label={t('photobooth.credentials.username', 'Username')} value={username ?? '—'} border={border} />
<CredentialRow label={t('photobooth.credentials.password', 'Password')} value={password ?? '—'} border={border} masked />
<Text fontSize="$xs" color={muted}> <Text fontSize="$xs" color={muted}>
{t('photobooth.credentials.hidden', 'Credentials are hidden. Tap to show them.')} {t('photobooth.credentials.ftpsHint', 'Use FTPS if required; uploads go into the target folder for this event.')}
</Text> </Text>
</>
)} )}
<Text fontSize="$xs" color={muted}>
{t('photobooth.uploader.hint', 'POST with media file or base64 "media" field; app uses these credentials.')}
</Text>
</YStack> </YStack>
<XStack space="$2" marginTop="$2" flexWrap="wrap">
<XStack flex={1} minWidth={0}>
<CTAButton
label={updating ? t('common.processing', '...') : t('photobooth.actions.rotate', 'Regenerate access')}
onPress={() => handleRotate()}
iconLeft={<RefreshCw size={14} color={surface} />}
disabled={updating}
style={{ width: '100%', paddingHorizontal: 10, paddingVertical: 10 }}
/>
</XStack>
<XStack flex={1} minWidth={0}>
<CTAButton
label={isActive ? t('photobooth.actions.disable', 'Disable uploads') : t('photobooth.actions.enable', 'Enable uploads')}
onPress={() => (isActive ? handleDisable() : handleEnable(selectedMode))}
tone={isActive ? 'ghost' : 'primary'}
iconLeft={isActive ? <Power size={14} color={text} /> : <PlugZap size={14} color={surface} />}
disabled={updating}
style={{ width: '100%', paddingHorizontal: 10, paddingVertical: 10 }}
/>
</XStack>
</XStack>
</MobileCard> </MobileCard>
<MobileCard space="$2"> <MobileCard space="$2">

View File

@@ -1,302 +0,0 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { CheckCircle2, Lock, MailWarning, User } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import toast from 'react-hot-toast';
import { MobileShell } from './components/MobileShell';
import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
import { MobileField, MobileInput, MobileSelect } from './components/FormControls';
import { fetchTenantProfile, updateTenantProfile, type TenantAccountProfile } from '../api';
import { getApiErrorMessage, getApiValidationMessage } from '../lib/apiError';
import { ADMIN_PROFILE_PATH } from '../constants';
import { useBackNavigation } from './hooks/useBackNavigation';
import { useAdminTheme } from './theme';
import i18n from '../i18n';
type ProfileFormState = {
name: string;
email: string;
preferredLocale: string;
currentPassword: string;
password: string;
passwordConfirmation: string;
};
const LOCALE_OPTIONS = [
{ value: '', labelKey: 'profile.locale.auto', fallback: 'Automatisch' },
{ value: 'de', label: 'Deutsch' },
{ value: 'en', label: 'English' },
];
export default function MobileProfileAccountPage() {
const { t } = useTranslation('settings');
const { text, muted, danger, subtle, primary, accentSoft } = useAdminTheme();
const back = useBackNavigation(ADMIN_PROFILE_PATH);
const [profile, setProfile] = React.useState<TenantAccountProfile | null>(null);
const [form, setForm] = React.useState<ProfileFormState>({
name: '',
email: '',
preferredLocale: '',
currentPassword: '',
password: '',
passwordConfirmation: '',
});
const [loading, setLoading] = React.useState(true);
const [savingAccount, setSavingAccount] = React.useState(false);
const [savingPassword, setSavingPassword] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const loadErrorMessage = t('profile.errors.load', 'Profil konnte nicht geladen werden.');
const dateFormatter = React.useMemo(
() =>
new Intl.DateTimeFormat(i18n.language || 'de', {
day: '2-digit',
month: 'long',
year: 'numeric',
}),
[i18n.language],
);
React.useEffect(() => {
(async () => {
setLoading(true);
try {
const data = await fetchTenantProfile();
setProfile(data);
setForm((prev) => ({
...prev,
name: data.name ?? '',
email: data.email ?? '',
preferredLocale: data.preferred_locale ?? '',
}));
setError(null);
} catch (err) {
setError(getApiErrorMessage(err, loadErrorMessage));
} finally {
setLoading(false);
}
})();
}, []);
const verifiedAt = profile?.email_verified_at ? new Date(profile.email_verified_at) : null;
const verifiedDate = verifiedAt ? dateFormatter.format(verifiedAt) : null;
const emailStatusLabel = profile?.email_verified
? t('profile.status.emailVerified', 'E-Mail bestätigt')
: t('profile.status.emailNotVerified', 'Bestätigung erforderlich');
const emailHint = profile?.email_verified
? 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.');
const buildPayload = (includePassword: boolean) => ({
name: form.name.trim(),
email: form.email.trim(),
preferred_locale: form.preferredLocale ? form.preferredLocale : null,
...(includePassword
? {
current_password: form.currentPassword,
password: form.password,
password_confirmation: form.passwordConfirmation,
}
: {}),
});
const handleAccountSave = async () => {
setSavingAccount(true);
try {
const updated = await updateTenantProfile(buildPayload(false));
setProfile(updated);
setError(null);
toast.success(t('profile.toasts.updated', 'Profil wurde aktualisiert.'));
} catch (err) {
const message = getApiValidationMessage(err, t('profile.errors.update', 'Profil konnte nicht aktualisiert werden.'));
setError(message);
toast.error(message);
} finally {
setSavingAccount(false);
}
};
const handlePasswordSave = async () => {
setSavingPassword(true);
try {
const updated = await updateTenantProfile(buildPayload(true));
setProfile(updated);
setError(null);
setForm((prev) => ({
...prev,
currentPassword: '',
password: '',
passwordConfirmation: '',
}));
toast.success(t('profile.toasts.passwordChanged', 'Passwort wurde aktualisiert.'));
} catch (err) {
const message = getApiValidationMessage(err, t('profile.errors.update', 'Profil konnte nicht aktualisiert werden.'));
setError(message);
toast.error(message);
} finally {
setSavingPassword(false);
}
};
const passwordReady =
form.currentPassword.trim().length > 0 &&
form.password.trim().length > 0 &&
form.passwordConfirmation.trim().length > 0;
return (
<MobileShell
activeTab="profile"
title={t('profile.title', 'Profil')}
onBack={back}
>
{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>
);
}

View File

@@ -12,7 +12,7 @@ import { MobileCard, CTAButton } from './components/Primitives';
import { MobileSelect } from './components/FormControls'; import { MobileSelect } from './components/FormControls';
import { useAuth } from '../auth/context'; import { useAuth } from '../auth/context';
import { fetchTenantProfile } from '../api'; import { fetchTenantProfile } from '../api';
import { adminPath, ADMIN_DATA_EXPORTS_PATH, ADMIN_PROFILE_ACCOUNT_PATH } from '../constants'; import { adminPath, ADMIN_DATA_EXPORTS_PATH } from '../constants';
import i18n from '../i18n'; import i18n from '../i18n';
import { useAppearance } from '@/hooks/use-appearance'; import { useAppearance } from '@/hooks/use-appearance';
import { useBackNavigation } from './hooks/useBackNavigation'; import { useBackNavigation } from './hooks/useBackNavigation';
@@ -85,7 +85,7 @@ export default function MobileProfilePage() {
<YStack space="$4"> <YStack space="$4">
<YGroup {...({ borderRadius: "$4", borderWidth: 1, borderColor: borderColor, overflow: "hidden" } as any)}> <YGroup {...({ borderRadius: "$4", borderWidth: 1, borderColor: borderColor, overflow: "hidden" } as any)}>
<YGroup.Item> <YGroup.Item>
<Pressable onPress={() => navigate(ADMIN_PROFILE_ACCOUNT_PATH)}> <Pressable onPress={() => navigate(adminPath('/mobile/profile/security'))}>
<ListItem <ListItem
hoverTheme hoverTheme
pressTheme pressTheme
@@ -93,7 +93,7 @@ export default function MobileProfilePage() {
paddingHorizontal="$3" paddingHorizontal="$3"
title={ title={
<Text fontSize="$sm" color={textColor}> <Text fontSize="$sm" color={textColor}>
{t('mobileProfile.account', 'Account bearbeiten')} {t('mobileProfile.account', 'Account & security')}
</Text> </Text>
} }
iconAfter={<Settings size={18} color={subtle} />} iconAfter={<Settings size={18} color={subtle} />}

View File

@@ -16,7 +16,7 @@ import {
NotificationPreferences, NotificationPreferences,
} from '../api'; } from '../api';
import { getApiErrorMessage } from '../lib/apiError'; import { getApiErrorMessage } from '../lib/apiError';
import { adminPath, ADMIN_HOME_PATH, ADMIN_PROFILE_ACCOUNT_PATH } from '../constants'; import { adminPath, ADMIN_HOME_PATH } from '../constants';
import { useAdminPushSubscription } from './hooks/useAdminPushSubscription'; import { useAdminPushSubscription } from './hooks/useAdminPushSubscription';
import { useDevicePermissions } from './hooks/useDevicePermissions'; import { useDevicePermissions } from './hooks/useDevicePermissions';
import { type PermissionStatus, type StorageStatus } from './lib/devicePermissions'; import { type PermissionStatus, type StorageStatus } from './lib/devicePermissions';
@@ -224,7 +224,7 @@ export default function MobileSettingsPage() {
<PillBadge tone="muted">{t('mobileSettings.tenantBadge', 'Tenant #{{id}}', { id: user.tenant_id })}</PillBadge> <PillBadge tone="muted">{t('mobileSettings.tenantBadge', 'Tenant #{{id}}', { id: user.tenant_id })}</PillBadge>
) : null} ) : null}
<XStack space="$2"> <XStack space="$2">
<CTAButton label={t('settings.profile.actions.openProfile', 'Profil bearbeiten')} onPress={() => navigate(ADMIN_PROFILE_ACCOUNT_PATH)} /> <CTAButton label={t('settings.profile.actions.openProfile', 'Profil bearbeiten')} onPress={() => navigate(adminPath('/mobile/profile'))} />
<CTAButton label={t('settings.session.logout', 'Abmelden')} tone="ghost" onPress={() => logout({ redirect: adminPath('/logout') })} /> <CTAButton label={t('settings.session.logout', 'Abmelden')} tone="ghost" onPress={() => logout({ redirect: adminPath('/logout') })} />
</XStack> </XStack>
</MobileCard> </MobileCard>

View File

@@ -1,141 +0,0 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
const backMock = vi.fn();
vi.mock('../hooks/useBackNavigation', () => ({
useBackNavigation: () => backMock,
}));
vi.mock('../../api', () => ({
fetchTenantProfile: vi.fn(),
updateTenantProfile: vi.fn(),
}));
vi.mock('react-hot-toast', () => ({
default: {
error: vi.fn(),
success: vi.fn(),
},
}));
vi.mock('../components/MobileShell', () => ({
MobileShell: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../components/Primitives', () => ({
MobileCard: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
PillBadge: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
CTAButton: ({
label,
onPress,
disabled,
}: {
label: string;
onPress?: () => void;
disabled?: boolean;
}) => (
<button type="button" onClick={disabled ? undefined : onPress} disabled={disabled}>
{label}
</button>
),
}));
vi.mock('../components/FormControls', () => ({
MobileField: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
MobileInput: ({ hasError, compact, ...props }: React.InputHTMLAttributes<HTMLInputElement> & { hasError?: boolean; compact?: boolean }) => (
<input {...props} />
),
MobileSelect: ({ children, ...props }: { children: React.ReactNode }) => <select {...props}>{children}</select>,
}));
vi.mock('@tamagui/stacks', () => ({
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/text', () => ({
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
vi.mock('../theme', () => ({
useAdminTheme: () => ({
text: '#111827',
muted: '#6b7280',
subtle: '#94a3b8',
danger: '#b91c1c',
border: '#e5e7eb',
surface: '#ffffff',
primary: '#ff5a5f',
accentSoft: '#fde7ea',
}),
}));
import { fetchTenantProfile, updateTenantProfile } from '../../api';
import MobileProfileAccountPage from '../ProfileAccountPage';
const profileFixture = {
id: 1,
name: 'Test Admin',
email: 'admin@example.com',
preferred_locale: null,
email_verified: true,
email_verified_at: '2024-01-02T00:00:00.000Z',
};
describe('MobileProfileAccountPage', () => {
it('submits account updates with name, email, and locale', async () => {
vi.mocked(fetchTenantProfile).mockResolvedValue(profileFixture);
vi.mocked(updateTenantProfile).mockResolvedValue(profileFixture);
await act(async () => {
render(<MobileProfileAccountPage />);
});
await screen.findByDisplayValue('Test Admin');
await act(async () => {
fireEvent.click(screen.getByText('profile.actions.save'));
});
expect(updateTenantProfile).toHaveBeenCalledWith({
name: 'Test Admin',
email: 'admin@example.com',
preferred_locale: null,
});
});
it('submits password updates when all password fields are provided', async () => {
vi.mocked(fetchTenantProfile).mockResolvedValue(profileFixture);
vi.mocked(updateTenantProfile).mockResolvedValue(profileFixture);
await act(async () => {
render(<MobileProfileAccountPage />);
});
await screen.findByDisplayValue('Test Admin');
const passwordInputs = screen.getAllByPlaceholderText('••••••••');
await act(async () => {
fireEvent.change(passwordInputs[0], { target: { value: 'old-pass' } });
fireEvent.change(passwordInputs[1], { target: { value: 'new-pass-123' } });
fireEvent.change(passwordInputs[2], { target: { value: 'new-pass-123' } });
});
await waitFor(() => {
expect(screen.getByText('profile.actions.updatePassword')).not.toBeDisabled();
});
await act(async () => {
fireEvent.click(screen.getByText('profile.actions.updatePassword'));
});
expect(updateTenantProfile).toHaveBeenCalledWith({
name: 'Test Admin',
email: 'admin@example.com',
preferred_locale: null,
current_password: 'old-pass',
password: 'new-pass-123',
password_confirmation: 'new-pass-123',
});
});
});

View File

@@ -35,7 +35,6 @@ const MobileEventRecapPage = React.lazy(() => import('./mobile/EventRecapPage'))
const MobileEventAnalyticsPage = React.lazy(() => import('./mobile/EventAnalyticsPage')); const MobileEventAnalyticsPage = React.lazy(() => import('./mobile/EventAnalyticsPage'));
const MobileNotificationsPage = React.lazy(() => import('./mobile/NotificationsPage')); const MobileNotificationsPage = React.lazy(() => import('./mobile/NotificationsPage'));
const MobileProfilePage = React.lazy(() => import('./mobile/ProfilePage')); const MobileProfilePage = React.lazy(() => import('./mobile/ProfilePage'));
const MobileProfileAccountPage = React.lazy(() => import('./mobile/ProfileAccountPage'));
const MobileBillingPage = React.lazy(() => import('./mobile/BillingPage')); const MobileBillingPage = React.lazy(() => import('./mobile/BillingPage'));
const MobilePackageShopPage = React.lazy(() => import('./mobile/PackageShopPage')); const MobilePackageShopPage = React.lazy(() => import('./mobile/PackageShopPage'));
const MobileSettingsPage = React.lazy(() => import('./mobile/SettingsPage')); const MobileSettingsPage = React.lazy(() => import('./mobile/SettingsPage'));
@@ -213,7 +212,6 @@ export const router = createBrowserRouter([
{ path: 'mobile/notifications', element: <MobileNotificationsPage /> }, { path: 'mobile/notifications', element: <MobileNotificationsPage /> },
{ path: 'mobile/notifications/:notificationId', element: <MobileNotificationsPage /> }, { path: 'mobile/notifications/:notificationId', element: <MobileNotificationsPage /> },
{ path: 'mobile/profile', element: <RequireAdminAccess><MobileProfilePage /></RequireAdminAccess> }, { path: 'mobile/profile', element: <RequireAdminAccess><MobileProfilePage /></RequireAdminAccess> },
{ path: 'mobile/profile/account', element: <RequireAdminAccess><MobileProfileAccountPage /></RequireAdminAccess> },
{ path: 'mobile/billing', element: <RequireAdminAccess><MobileBillingPage /></RequireAdminAccess> }, { path: 'mobile/billing', element: <RequireAdminAccess><MobileBillingPage /></RequireAdminAccess> },
{ path: 'mobile/billing/shop', element: <RequireAdminAccess><MobilePackageShopPage /></RequireAdminAccess> }, { path: 'mobile/billing/shop', element: <RequireAdminAccess><MobilePackageShopPage /></RequireAdminAccess> },
{ path: 'mobile/settings', element: <RequireAdminAccess><MobileSettingsPage /></RequireAdminAccess> }, { path: 'mobile/settings', element: <RequireAdminAccess><MobileSettingsPage /></RequireAdminAccess> },

View File

@@ -37,36 +37,27 @@ export default function FiltersBar({
return ( return (
<div <div
className={cn( className={cn(
'flex overflow-x-auto px-1 pb-2 text-xs font-semibold [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden', 'flex gap-2 overflow-x-auto px-4 pb-2 text-sm font-medium [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
className, className,
)} )}
style={styleOverride} style={styleOverride}
> >
<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) => (
{filters.map((filter, index) => {
const isActive = value === filter.value;
return (
<div key={filter.value} className="flex items-center">
<button <button
key={filter.value}
type="button" type="button"
onClick={() => onChange(filter.value)} onClick={() => onChange(filter.value)}
className={cn( className={cn(
'inline-flex items-center gap-1 rounded-full px-3 py-1.5 transition', 'inline-flex items-center gap-2 rounded-full border px-4 py-2 transition',
isActive value === filter.value
? 'bg-pink-500 text-white shadow' ? 'border-pink-500 bg-gradient-to-r from-pink-500 to-pink-600 text-white shadow'
: 'text-muted-foreground hover:bg-pink-50 hover:text-pink-600', : 'border-transparent bg-white/70 text-muted-foreground hover:border-pink-200',
)} )}
> >
{React.cloneElement(filter.icon as React.ReactElement, { className: 'h-3.5 w-3.5' })} {filter.icon}
<span className="whitespace-nowrap">{t(filter.labelKey)}</span> {t(filter.labelKey)}
</button> </button>
{index < filters.length - 1 && ( ))}
<span className="mx-1 h-4 w-px bg-border/60 dark:bg-white/10" aria-hidden />
)}
</div>
);
})}
</div>
</div> </div>
); );
} }

View File

@@ -111,33 +111,33 @@ export default function GalleryPreview({ token }: Props) {
</Link> </Link>
</div> </div>
<div className="flex overflow-x-auto pb-1 text-xs font-semibold [-ms-overflow-style:none] [scrollbar-width:none]"> <div className="flex gap-2 overflow-x-auto pb-1 text-sm font-medium [-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"> {filters.map((filter) => {
{filters.map((filter, index) => {
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)}
style={{
borderRadius: radius,
border: isActive ? `1px solid ${branding.primaryColor}` : `1px solid ${branding.primaryColor}22`,
background: isActive ? branding.primaryColor : undefined,
boxShadow: isActive ? `0 8px 18px ${branding.primaryColor}33` : 'none',
}}
className={cn( className={cn(
'inline-flex items-center rounded-full px-3 py-1.5 transition', 'px-4 py-1 transition',
isActive isActive
? 'bg-pink-500 text-white shadow' ? 'text-white'
: 'text-muted-foreground hover:bg-pink-50 hover:text-pink-600', : 'bg-[var(--guest-surface)] text-foreground dark:bg-slate-950/70 dark:text-slate-100',
)} )}
> >
<span className="whitespace-nowrap">{filter.label}</span> {filter.label}
</button> </button>
{index < filters.length - 1 && (
<span className="mx-1 h-4 w-px bg-border/60 dark:bg-white/10" aria-hidden />
)}
</div>
); );
})} })}
</div> </div>
</div>
{loading && <p className="text-sm text-muted-foreground">Lädt</p>} {loading && <p className="text-sm text-muted-foreground">Lädt</p>}
{!loading && items.length === 0 && ( {!loading && items.length === 0 && (
@@ -147,29 +147,37 @@ export default function GalleryPreview({ token }: Props) {
</div> </div>
)} )}
<div className="grid gap-3 grid-cols-2 md:grid-cols-3"> <div className="grid gap-2 grid-cols-2 md:grid-cols-3">
{items.map((p: PreviewPhoto) => ( {items.map((p: PreviewPhoto) => (
<Link <Link
key={p.id} key={p.id}
to={`/e/${encodeURIComponent(token)}/gallery?photoId=${p.id}`} to={`/e/${encodeURIComponent(token)}/gallery?photoId=${p.id}`}
className="group flex flex-col overflow-hidden border border-border/60 bg-white shadow-sm ring-1 ring-black/5 transition duration-300 hover:-translate-y-0.5 hover:shadow-lg dark:border-white/10 dark:bg-slate-950 dark:ring-white/10" className="group relative block overflow-hidden bg-[var(--guest-surface)] text-foreground dark:bg-slate-950/70"
style={{ borderRadius: radius }} style={{
borderRadius: radius,
border: `1px solid ${branding.primaryColor}22`,
boxShadow: `0 12px 26px ${branding.primaryColor}22`,
}}
> >
<div className="relative">
<img <img
src={p.thumbnail_path || p.file_path} src={p.thumbnail_path || p.file_path}
alt={p.title || 'Foto'} alt={p.title || 'Foto'}
className="aspect-[3/4] w-full object-cover transition duration-300 group-hover:scale-105" className="h-40 w-full object-cover transition duration-300 group-hover:scale-105"
loading="lazy" loading="lazy"
/> />
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-black/50 via-black/0 to-transparent" aria-hidden /> <div
</div> className="absolute inset-0"
<div className="space-y-2 px-3 pb-3 pt-3"> style={{
<p className="text-sm font-semibold leading-tight line-clamp-2 text-foreground" style={headingFont ? { fontFamily: headingFont } : undefined}> background: `linear-gradient(180deg, transparent 50%, ${branding.primaryColor}33 100%)`,
}}
aria-hidden
/>
<div className="absolute bottom-0 left-0 right-0 space-y-1 p-3">
<p className="text-sm font-semibold leading-tight line-clamp-2" style={headingFont ? { fontFamily: headingFont } : undefined}>
{p.title || getPhotoTitle(p)} {p.title || getPhotoTitle(p)}
</p> </p>
<div className="flex items-center gap-1 text-xs text-muted-foreground"> <div className="flex items-center gap-1 text-xs text-foreground/80">
<Heart className="h-3.5 w-3.5 text-pink-500" aria-hidden /> <Heart className="h-4 w-4" style={{ color: branding.primaryColor }} aria-hidden />
{p.likes_count ?? 0} {p.likes_count ?? 0}
</div> </div>
</div> </div>

View File

@@ -38,18 +38,6 @@ const EVENT_ICON_COMPONENTS: Record<string, React.ComponentType<{ className?: st
camera: Camera, camera: Camera,
}; };
type LogoSize = 's' | 'm' | 'l';
const LOGO_SIZE_CLASSES: Record<LogoSize, { container: string; image: string; emoji: string; icon: string }> = {
s: { container: 'h-8 w-8', image: 'h-7 w-7', emoji: 'text-lg', icon: 'h-4 w-4' },
m: { container: 'h-10 w-10', image: 'h-9 w-9', emoji: 'text-xl', icon: 'h-5 w-5' },
l: { container: 'h-12 w-12', image: 'h-11 w-11', emoji: 'text-2xl', icon: 'h-6 w-6' },
};
function getLogoClasses(size?: LogoSize) {
return LOGO_SIZE_CLASSES[size ?? 'm'];
}
const NOTIFICATION_ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = { const NOTIFICATION_ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
broadcast: MessageSquare, broadcast: MessageSquare,
feedback_request: MessageSquare, feedback_request: MessageSquare,
@@ -81,25 +69,18 @@ function getInitials(name: string): string {
return name.substring(0, 2).toUpperCase(); return name.substring(0, 2).toUpperCase();
} }
function renderEventAvatar( function renderEventAvatar(name: string, icon: unknown, accentColor: string, textColor: string, logo?: { mode: 'emoticon' | 'upload'; value: string | null }) {
name: string,
icon: unknown,
accentColor: string,
textColor: string,
logo?: { mode: 'emoticon' | 'upload'; value: string | null; size?: LogoSize }
) {
const sizes = getLogoClasses(logo?.size);
if (logo?.mode === 'upload' && logo.value) { if (logo?.mode === 'upload' && logo.value) {
return ( return (
<div className={`flex items-center justify-center rounded-full bg-white shadow-sm ${sizes.container}`}> <div className="flex h-10 w-10 items-center justify-center rounded-full bg-white shadow-sm">
<img src={logo.value} alt={name} className={`rounded-full object-contain ${sizes.image}`} /> <img src={logo.value} alt={name} className="h-9 w-9 rounded-full object-contain" />
</div> </div>
); );
} }
if (logo?.mode === 'emoticon' && logo.value && isLikelyEmoji(logo.value)) { if (logo?.mode === 'emoticon' && logo.value && isLikelyEmoji(logo.value)) {
return ( return (
<div <div
className={`flex items-center justify-center rounded-full shadow-sm ${sizes.container} ${sizes.emoji}`} className="flex h-10 w-10 items-center justify-center rounded-full text-xl shadow-sm"
style={{ backgroundColor: accentColor, color: textColor }} style={{ backgroundColor: accentColor, color: textColor }}
> >
<span aria-hidden>{logo.value}</span> <span aria-hidden>{logo.value}</span>
@@ -116,10 +97,10 @@ function renderEventAvatar(
if (IconComponent) { if (IconComponent) {
return ( return (
<div <div
className={`flex items-center justify-center rounded-full shadow-sm ${sizes.container}`} className="flex h-10 w-10 items-center justify-center rounded-full shadow-sm"
style={{ backgroundColor: accentColor, color: textColor }} style={{ backgroundColor: accentColor, color: textColor }}
> >
<IconComponent className={sizes.icon} aria-hidden /> <IconComponent className="h-5 w-5" aria-hidden />
</div> </div>
); );
} }
@@ -127,7 +108,7 @@ function renderEventAvatar(
if (isLikelyEmoji(trimmed)) { if (isLikelyEmoji(trimmed)) {
return ( return (
<div <div
className={`flex items-center justify-center rounded-full shadow-sm ${sizes.container} ${sizes.emoji}`} className="flex h-10 w-10 items-center justify-center rounded-full text-xl shadow-sm"
style={{ backgroundColor: accentColor, color: textColor }} style={{ backgroundColor: accentColor, color: textColor }}
> >
<span aria-hidden>{trimmed}</span> <span aria-hidden>{trimmed}</span>
@@ -207,7 +188,13 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
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 basePath = eventToken ? `/e/${encodeURIComponent(eventToken)}` : '';
const showGalleryHelp = Boolean(
basePath
&& (location.pathname.startsWith(`${basePath}/gallery`) || location.pathname.startsWith(`${basePath}/photo`))
);
const galleryHelpHref = basePath ? `${basePath}/help/gallery-and-sharing` : '/help/gallery-and-sharing';
const headerStyle: React.CSSProperties = { const headerStyle: React.CSSProperties = {
background: `linear-gradient(135deg, ${branding.primaryColor}, ${branding.secondaryColor})`, background: `linear-gradient(135deg, ${branding.primaryColor}, ${branding.secondaryColor})`,
color: headerTextColor, color: headerTextColor,
@@ -239,20 +226,9 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
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 z-20 flex items-center justify-between border-b border-white/10 px-4 py-3 text-white shadow-sm backdrop-blur"
style={headerStyle} style={headerStyle}
> >
<div <div className="flex items-center gap-3">
className={
logoPosition === 'center'
? 'flex flex-col items-center gap-2 text-center'
: logoPosition === 'right'
? 'flex flex-row-reverse items-center gap-3'
: 'flex items-center gap-3'
}
>
{renderEventAvatar(event.name, event.type?.icon, accentColor, headerTextColor, branding.logo)} {renderEventAvatar(event.name, event.type?.icon, accentColor, headerTextColor, branding.logo)}
<div <div className="flex flex-col" style={headerFont ? { fontFamily: headerFont } : undefined}>
className={`flex flex-col${logoPosition === 'center' ? ' items-center text-center' : ''}`}
style={headerFont ? { fontFamily: headerFont } : undefined}
>
<div className="font-semibold text-lg">{event.name}</div> <div className="font-semibold text-lg">{event.name}</div>
<div className="flex items-center gap-2 text-xs text-white/70" style={bodyFont ? { fontFamily: bodyFont } : undefined}> <div className="flex items-center gap-2 text-xs text-white/70" style={bodyFont ? { fontFamily: bodyFont } : undefined}>
{stats && tasksEnabled && ( {stats && tasksEnabled && (
@@ -283,6 +259,15 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
t={t} t={t}
/> />
)} )}
{showGalleryHelp && (
<Link
to={galleryHelpHref}
className="rounded-full bg-white/15 p-2 text-white transition hover:bg-white/30"
aria-label={t('header.helpGallery')}
>
<LifeBuoy className="h-5 w-5" aria-hidden />
</Link>
)}
<AppearanceToggleDropdown /> <AppearanceToggleDropdown />
<SettingsSheet /> <SettingsSheet />
</div> </div>

View File

@@ -72,17 +72,17 @@ export default function RouteTransition({ children }: { children?: React.ReactNo
}; };
const tabVariants = { const tabVariants = {
enter: { opacity: 0, y: 8 }, enter: { opacity: 0, scale: 0.985 },
center: { opacity: 1, y: 0 }, center: { opacity: 1, scale: 1 },
exit: { opacity: 0, y: -8 }, exit: { opacity: 0, scale: 0.985 },
}; };
const transition = kind === 'tab' const transition = kind === 'tab'
? { duration: 0.22, ease: [0.22, 0.61, 0.36, 1] } ? { duration: 0.18, ease: [0.22, 0.61, 0.36, 1] }
: { duration: 0.28, ease: [0.25, 0.8, 0.25, 1] }; : { duration: 0.24, ease: [0.25, 0.8, 0.25, 1] };
return ( return (
<AnimatePresence initial={false} mode="wait"> <AnimatePresence initial={false}>
<motion.div <motion.div
key={location.pathname} key={location.pathname}
custom={{ direction }} custom={{ direction }}

View File

@@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import { Link, useLocation, useParams } from 'react-router-dom'; import { Link, useParams } from 'react-router-dom';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { import {
@@ -23,7 +23,6 @@ import { useTranslation } from '../i18n/useTranslation';
import type { LocaleCode } from '../i18n/messages'; import type { LocaleCode } from '../i18n/messages';
import { useHapticsPreference } from '../hooks/useHapticsPreference'; import { useHapticsPreference } from '../hooks/useHapticsPreference';
import { triggerHaptic } from '../lib/haptics'; import { triggerHaptic } from '../lib/haptics';
import { getHelpSlugForPathname } from '../lib/helpRouting';
const legalPages = [ const legalPages = [
{ slug: 'impressum', translationKey: 'settings.legal.section.impressum' }, { slug: 'impressum', translationKey: 'settings.legal.section.impressum' },
@@ -54,15 +53,12 @@ export function SettingsSheet() {
const localeContext = useLocale(); const localeContext = useLocale();
const { t } = useTranslation(); const { t } = useTranslation();
const params = useParams<{ token?: string }>(); const params = useParams<{ token?: string }>();
const location = useLocation();
const [nameDraft, setNameDraft] = React.useState(identity?.name ?? ''); const [nameDraft, setNameDraft] = React.useState(identity?.name ?? '');
const [nameStatus, setNameStatus] = React.useState<NameStatus>('idle'); const [nameStatus, setNameStatus] = React.useState<NameStatus>('idle');
const [savingName, setSavingName] = React.useState(false); const [savingName, setSavingName] = React.useState(false);
const isLegal = view.mode === 'legal'; const isLegal = view.mode === 'legal';
const legalDocument = useLegalDocument(isLegal ? view.slug : null, localeContext.locale); const legalDocument = useLegalDocument(isLegal ? view.slug : null, localeContext.locale);
const helpSlug = getHelpSlugForPathname(location.pathname); const helpHref = params?.token ? `/e/${encodeURIComponent(params.token)}/help` : '/help';
const helpBase = params?.token ? `/e/${encodeURIComponent(params.token)}/help` : '/help';
const helpHref = helpSlug ? `${helpBase}/${helpSlug}` : helpBase;
React.useEffect(() => { React.useEffect(() => {
if (open && identity?.hydrated) { if (open && identity?.hydrated) {

View File

@@ -38,11 +38,6 @@ export const DEFAULT_EVENT_BRANDING: EventBranding = {
const DEFAULT_PRIMARY = DEFAULT_EVENT_BRANDING.primaryColor.toLowerCase(); const DEFAULT_PRIMARY = DEFAULT_EVENT_BRANDING.primaryColor.toLowerCase();
const DEFAULT_SECONDARY = DEFAULT_EVENT_BRANDING.secondaryColor.toLowerCase(); const DEFAULT_SECONDARY = DEFAULT_EVENT_BRANDING.secondaryColor.toLowerCase();
const DEFAULT_BACKGROUND = DEFAULT_EVENT_BRANDING.backgroundColor.toLowerCase(); const DEFAULT_BACKGROUND = DEFAULT_EVENT_BRANDING.backgroundColor.toLowerCase();
const FONT_SCALE_MAP: Record<'s' | 'm' | 'l', number> = {
s: 0.94,
m: 1,
l: 1.08,
};
const EventBrandingContext = createContext<EventBrandingContextValue | undefined>(undefined); const EventBrandingContext = createContext<EventBrandingContextValue | undefined>(undefined);
@@ -67,8 +62,7 @@ function resolveBranding(input?: EventBranding | null): EventBranding {
const headingFont = input.typography?.heading ?? input.fontFamily ?? null; const headingFont = input.typography?.heading ?? input.fontFamily ?? null;
const bodyFont = input.typography?.body ?? input.fontFamily ?? null; const bodyFont = input.typography?.body ?? input.fontFamily ?? null;
const rawSize = input.typography?.sizePreset ?? 'm'; const sizePreset = input.typography?.sizePreset ?? 'm';
const sizePreset = rawSize === 's' || rawSize === 'm' || rawSize === 'l' ? rawSize : 'm';
const logoMode = input.logo?.mode ?? (input.logoUrl ? 'upload' : 'emoticon'); const logoMode = input.logo?.mode ?? (input.logoUrl ? 'upload' : 'emoticon');
const logoValue = input.logo?.value ?? input.logoUrl ?? null; const logoValue = input.logo?.value ?? input.logoUrl ?? null;
@@ -122,7 +116,6 @@ function applyCssVariables(branding: EventBranding) {
root.style.setProperty('--guest-radius', `${branding.buttons?.radius ?? 12}px`); root.style.setProperty('--guest-radius', `${branding.buttons?.radius ?? 12}px`);
root.style.setProperty('--guest-link', branding.buttons?.linkColor ?? branding.secondaryColor); root.style.setProperty('--guest-link', branding.buttons?.linkColor ?? branding.secondaryColor);
root.style.setProperty('--guest-button-style', branding.buttons?.style ?? 'filled'); root.style.setProperty('--guest-button-style', branding.buttons?.style ?? 'filled');
root.style.setProperty('--guest-font-scale', String(FONT_SCALE_MAP[branding.typography?.sizePreset ?? 'm'] ?? 1));
const headingFont = branding.typography?.heading ?? branding.fontFamily; const headingFont = branding.typography?.heading ?? branding.fontFamily;
const bodyFont = branding.typography?.body ?? branding.fontFamily; const bodyFont = branding.typography?.body ?? branding.fontFamily;
@@ -156,7 +149,6 @@ function resetCssVariables() {
root.style.removeProperty('--guest-radius'); root.style.removeProperty('--guest-radius');
root.style.removeProperty('--guest-link'); root.style.removeProperty('--guest-link');
root.style.removeProperty('--guest-button-style'); root.style.removeProperty('--guest-button-style');
root.style.removeProperty('--guest-font-scale');
root.style.removeProperty('--guest-font-family'); root.style.removeProperty('--guest-font-family');
root.style.removeProperty('--guest-body-font'); root.style.removeProperty('--guest-body-font');
root.style.removeProperty('--guest-heading-font'); root.style.removeProperty('--guest-heading-font');
@@ -168,32 +160,48 @@ function applyThemeMode(mode: EventBranding['mode']) {
} }
const root = document.documentElement; const root = document.documentElement;
const prefersDark = typeof window !== 'undefined' && typeof window.matchMedia === 'function' const prefersDark = typeof window !== 'undefined'
? window.matchMedia('(prefers-color-scheme: dark)').matches ? window.matchMedia('(prefers-color-scheme: dark)').matches
: false; : false;
let storedTheme: 'light' | 'dark' | 'system' | null = null;
try {
const raw = localStorage.getItem('theme');
storedTheme = raw === 'light' || raw === 'dark' || raw === 'system' ? raw : null;
} catch {
storedTheme = null;
}
const applyDark = () => root.classList.add('dark'); const applyDark = () => root.classList.add('dark');
const applyLight = () => root.classList.remove('dark'); const applyLight = () => root.classList.remove('dark');
if (mode === 'dark') { if (mode === 'dark') {
applyDark(); applyDark();
root.style.colorScheme = 'dark';
return; return;
} }
if (mode === 'light') { if (mode === 'light') {
applyLight(); applyLight();
root.style.colorScheme = 'light'; return;
}
if (storedTheme === 'dark') {
applyDark();
return;
}
if (storedTheme === 'light') {
applyLight();
return; return;
} }
if (prefersDark) { if (prefersDark) {
applyDark(); applyDark();
root.style.colorScheme = 'dark';
return; return;
} }
applyLight(); applyLight();
root.style.colorScheme = 'light';
} }
export function EventBrandingProvider({ export function EventBrandingProvider({
@@ -206,9 +214,6 @@ export function EventBrandingProvider({
const resolved = useMemo(() => resolveBranding(branding), [branding]); const resolved = useMemo(() => resolveBranding(branding), [branding]);
useEffect(() => { useEffect(() => {
if (typeof document !== 'undefined') {
document.documentElement.classList.add('guest-theme');
}
applyCssVariables(resolved); applyCssVariables(resolved);
const previousDark = typeof document !== 'undefined' ? document.documentElement.classList.contains('dark') : false; const previousDark = typeof document !== 'undefined' ? document.documentElement.classList.contains('dark') : false;
applyThemeMode(resolved.mode ?? 'auto'); applyThemeMode(resolved.mode ?? 'auto');
@@ -220,7 +225,6 @@ export function EventBrandingProvider({
} else { } else {
document.documentElement.classList.remove('dark'); document.documentElement.classList.remove('dark');
} }
document.documentElement.classList.remove('guest-theme');
} }
resetCssVariables(); resetCssVariables();
applyCssVariables(DEFAULT_EVENT_BRANDING); applyCssVariables(DEFAULT_EVENT_BRANDING);

View File

@@ -1,47 +0,0 @@
import React from 'react';
import { render, waitFor } from '@testing-library/react';
import { EventBrandingProvider } from '../EventBrandingContext';
import type { EventBranding } from '../../types/event-branding';
const sampleBranding: EventBranding = {
primaryColor: '#ff3366',
secondaryColor: '#ff99aa',
backgroundColor: '#fef2f2',
fontFamily: 'Montserrat, sans-serif',
logoUrl: null,
typography: {
heading: null,
body: null,
sizePreset: 'l',
},
mode: 'dark',
};
describe('EventBrandingProvider', () => {
afterEach(() => {
document.documentElement.classList.remove('guest-theme', 'dark');
document.documentElement.style.removeProperty('color-scheme');
document.documentElement.style.removeProperty('--guest-background');
document.documentElement.style.removeProperty('--guest-font-scale');
});
it('applies guest theme classes and variables', async () => {
const { unmount } = render(
<EventBrandingProvider branding={sampleBranding}>
<div>Guest</div>
</EventBrandingProvider>
);
await waitFor(() => {
expect(document.documentElement.classList.contains('guest-theme')).toBe(true);
expect(document.documentElement.classList.contains('dark')).toBe(true);
expect(document.documentElement.style.colorScheme).toBe('dark');
expect(document.documentElement.style.getPropertyValue('--guest-background')).toBe(sampleBranding.backgroundColor);
expect(document.documentElement.style.getPropertyValue('--guest-font-scale')).toBe('1.08');
});
unmount();
expect(document.documentElement.classList.contains('guest-theme')).toBe(false);
});
});

View File

@@ -746,8 +746,6 @@ export const messages: Record<LocaleCode, NestedMessages> = {
back: 'Zurück zur Übersicht', back: 'Zurück zur Übersicht',
updated: 'Aktualisiert am {date}', updated: 'Aktualisiert am {date}',
relatedTitle: 'Verwandte Artikel', relatedTitle: 'Verwandte Artikel',
loadingTitle: 'Artikel wird geladen',
loadingDescription: 'Wir holen die neuesten Infos für dich.',
unavailable: 'Dieser Artikel ist nicht verfügbar.', unavailable: 'Dieser Artikel ist nicht verfügbar.',
reload: 'Neu laden', reload: 'Neu laden',
}, },
@@ -1483,8 +1481,6 @@ export const messages: Record<LocaleCode, NestedMessages> = {
back: 'Back to overview', back: 'Back to overview',
updated: 'Updated on {date}', updated: 'Updated on {date}',
relatedTitle: 'Related articles', relatedTitle: 'Related articles',
loadingTitle: 'Loading article',
loadingDescription: 'Fetching the latest details for you.',
unavailable: 'This article is unavailable.', unavailable: 'This article is unavailable.',
reload: 'Reload', reload: 'Reload',
}, },

View File

@@ -1,13 +0,0 @@
import { shouldShowPhotoboothFilter } from '../galleryFilters';
describe('shouldShowPhotoboothFilter', () => {
it('returns true when photobooth is enabled', () => {
expect(shouldShowPhotoboothFilter({ photobooth_enabled: true } as any)).toBe(true);
});
it('returns false when photobooth is disabled or missing', () => {
expect(shouldShowPhotoboothFilter({ photobooth_enabled: false } as any)).toBe(false);
expect(shouldShowPhotoboothFilter(null)).toBe(false);
expect(shouldShowPhotoboothFilter(undefined)).toBe(false);
});
});

View File

@@ -1,35 +0,0 @@
import { describe, expect, it, afterEach } from 'vitest';
import { applyGuestTheme } from '../guestTheme';
const baseTheme = {
primary: '#ff3366',
secondary: '#ff99aa',
background: '#111111',
surface: '#222222',
mode: 'dark' as const,
};
describe('applyGuestTheme', () => {
afterEach(() => {
const root = document.documentElement;
root.classList.remove('guest-theme', 'dark');
root.style.removeProperty('color-scheme');
root.style.removeProperty('--guest-primary');
root.style.removeProperty('--guest-secondary');
root.style.removeProperty('--guest-background');
root.style.removeProperty('--guest-surface');
});
it('applies and restores guest theme settings', () => {
const cleanup = applyGuestTheme(baseTheme);
expect(document.documentElement.classList.contains('guest-theme')).toBe(true);
expect(document.documentElement.classList.contains('dark')).toBe(true);
expect(document.documentElement.style.colorScheme).toBe('dark');
expect(document.documentElement.style.getPropertyValue('--guest-background')).toBe('#111111');
cleanup();
expect(document.documentElement.classList.contains('guest-theme')).toBe(false);
});
});

View File

@@ -1,32 +0,0 @@
import { describe, expect, it } from 'vitest';
import { getHelpSlugForPathname } from '../helpRouting';
describe('getHelpSlugForPathname', () => {
it('returns a getting-started slug for home paths', () => {
expect(getHelpSlugForPathname('/')).toBe('getting-started');
expect(getHelpSlugForPathname('/e/demo')).toBe('getting-started');
});
it('returns null for help pages', () => {
expect(getHelpSlugForPathname('/help')).toBeNull();
expect(getHelpSlugForPathname('/help/gallery-and-sharing')).toBeNull();
expect(getHelpSlugForPathname('/e/demo/help/gallery-and-sharing')).toBeNull();
});
it('maps gallery related pages', () => {
expect(getHelpSlugForPathname('/e/demo/gallery')).toBe('gallery-and-sharing');
expect(getHelpSlugForPathname('/e/demo/photo/123')).toBe('gallery-and-sharing');
expect(getHelpSlugForPathname('/e/demo/slideshow')).toBe('gallery-and-sharing');
});
it('maps upload related pages', () => {
expect(getHelpSlugForPathname('/e/demo/upload')).toBe('uploading-photos');
expect(getHelpSlugForPathname('/e/demo/queue')).toBe('upload-troubleshooting');
});
it('maps tasks and achievements', () => {
expect(getHelpSlugForPathname('/e/demo/tasks')).toBe('tasks-and-missions');
expect(getHelpSlugForPathname('/e/demo/tasks/12')).toBe('tasks-and-missions');
expect(getHelpSlugForPathname('/e/demo/achievements')).toBe('achievements-and-badges');
});
});

View File

@@ -1,47 +1,41 @@
import { getMotionContainerPropsForNavigation, getMotionItemPropsForNavigation, STAGGER_FAST, FADE_UP } from '../motion'; import { describe, expect, it, vi } from 'vitest';
import { FADE_SCALE, FADE_UP, STAGGER_FAST, getMotionContainerProps, getMotionItemProps, prefersReducedMotion } from '../motion';
describe('getMotionContainerPropsForNavigation', () => { describe('motion helpers', () => {
it('returns initial hidden for POP navigation', () => { it('returns disabled props when motion is off', () => {
expect(getMotionContainerPropsForNavigation(true, STAGGER_FAST, 'POP')).toEqual({ const props = getMotionContainerProps(false, STAGGER_FAST);
variants: STAGGER_FAST, expect(props.initial).toBe(false);
initial: 'hidden', });
animate: 'show',
it('returns variants when motion is on', () => {
const containerProps = getMotionContainerProps(true, STAGGER_FAST);
const itemProps = getMotionItemProps(true, FADE_UP);
expect(containerProps.initial).toBe('hidden');
expect(containerProps.animate).toBe('show');
expect(itemProps.variants).toBe(FADE_UP);
});
it('detects reduced motion preference safely', () => {
const original = window.matchMedia;
Object.defineProperty(window, 'matchMedia', {
configurable: true,
value: undefined,
});
expect(prefersReducedMotion()).toBe(false);
Object.defineProperty(window, 'matchMedia', {
configurable: true,
value: vi.fn().mockReturnValue({ matches: true }),
});
expect(prefersReducedMotion()).toBe(true);
Object.defineProperty(window, 'matchMedia', {
configurable: true,
value: original,
}); });
}); });
it('skips initial animation for PUSH navigation', () => { it('exposes distinct base variants', () => {
expect(getMotionContainerPropsForNavigation(true, STAGGER_FAST, 'PUSH')).toEqual({ expect(FADE_UP).not.toBe(FADE_SCALE);
variants: STAGGER_FAST,
initial: false,
animate: 'show',
});
});
it('disables motion when not enabled', () => {
expect(getMotionContainerPropsForNavigation(false, STAGGER_FAST, 'POP')).toEqual({
initial: false,
});
});
});
describe('getMotionItemPropsForNavigation', () => {
it('returns animate props for POP navigation', () => {
expect(getMotionItemPropsForNavigation(true, FADE_UP, 'POP')).toEqual({
variants: FADE_UP,
initial: 'hidden',
animate: 'show',
});
});
it('skips initial animation for PUSH navigation', () => {
expect(getMotionItemPropsForNavigation(true, FADE_UP, 'PUSH')).toEqual({
variants: FADE_UP,
initial: false,
animate: 'show',
});
});
it('returns empty props when motion disabled', () => {
expect(getMotionItemPropsForNavigation(false, FADE_UP, 'POP')).toEqual({});
}); });
}); });

View File

@@ -1,22 +0,0 @@
import { dedupeTasksById } from '../taskUtils';
describe('dedupeTasksById', () => {
it('returns empty array for empty input', () => {
expect(dedupeTasksById([])).toEqual([]);
});
it('keeps the first occurrence and preserves order', () => {
const tasks = [
{ id: 1, title: 'A' },
{ id: 2, title: 'B' },
{ id: 1, title: 'A-dup' },
{ id: 3, title: 'C' },
];
expect(dedupeTasksById(tasks)).toEqual([
{ id: 1, title: 'A' },
{ id: 2, title: 'B' },
{ id: 3, title: 'C' },
]);
});
});

View File

@@ -1,5 +0,0 @@
import type { EventData } from '../services/eventApi';
export function shouldShowPhotoboothFilter(event?: EventData | null): boolean {
return Boolean(event?.photobooth_enabled);
}

View File

@@ -1,103 +0,0 @@
export type GuestThemePayload = {
primary: string;
secondary: string;
background: string;
surface: string;
mode?: 'light' | 'dark' | 'auto';
};
type GuestThemeCleanup = () => void;
const prefersDarkScheme = (): boolean => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
return false;
}
return window.matchMedia('(prefers-color-scheme: dark)').matches;
};
const applyColorScheme = (root: HTMLElement, theme: 'light' | 'dark') => {
if (theme === 'dark') {
root.classList.add('dark');
root.style.colorScheme = 'dark';
} else {
root.classList.remove('dark');
root.style.colorScheme = 'light';
}
};
export function applyGuestTheme(payload: GuestThemePayload): GuestThemeCleanup {
if (typeof document === 'undefined') {
return () => {};
}
const root = document.documentElement;
const hadGuestTheme = root.classList.contains('guest-theme');
const wasDark = root.classList.contains('dark');
const previousColorScheme = root.style.colorScheme;
const previousVars = {
primary: root.style.getPropertyValue('--guest-primary'),
secondary: root.style.getPropertyValue('--guest-secondary'),
background: root.style.getPropertyValue('--guest-background'),
surface: root.style.getPropertyValue('--guest-surface'),
};
root.classList.add('guest-theme');
root.style.setProperty('--guest-primary', payload.primary);
root.style.setProperty('--guest-secondary', payload.secondary);
root.style.setProperty('--guest-background', payload.background);
root.style.setProperty('--guest-surface', payload.surface);
const mode = payload.mode ?? 'auto';
if (mode === 'dark') {
applyColorScheme(root, 'dark');
} else if (mode === 'light') {
applyColorScheme(root, 'light');
} else {
applyColorScheme(root, prefersDarkScheme() ? 'dark' : 'light');
}
return () => {
if (hadGuestTheme) {
root.classList.add('guest-theme');
} else {
root.classList.remove('guest-theme');
}
if (wasDark) {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
if (previousColorScheme) {
root.style.colorScheme = previousColorScheme;
} else {
root.style.removeProperty('color-scheme');
}
if (previousVars.primary) {
root.style.setProperty('--guest-primary', previousVars.primary);
} else {
root.style.removeProperty('--guest-primary');
}
if (previousVars.secondary) {
root.style.setProperty('--guest-secondary', previousVars.secondary);
} else {
root.style.removeProperty('--guest-secondary');
}
if (previousVars.background) {
root.style.setProperty('--guest-background', previousVars.background);
} else {
root.style.removeProperty('--guest-background');
}
if (previousVars.surface) {
root.style.setProperty('--guest-surface', previousVars.surface);
} else {
root.style.removeProperty('--guest-surface');
}
};
}

View File

@@ -1,44 +0,0 @@
export function getHelpSlugForPathname(pathname: string): string | null {
if (!pathname) {
return null;
}
const normalized = pathname
.replace(/^\/e\/[^/]+/, '')
.replace(/\/+$/g, '')
.toLowerCase();
if (!normalized || normalized === '/') {
return 'getting-started';
}
if (normalized.startsWith('/help')) {
return null;
}
if (normalized.startsWith('/gallery') || normalized.startsWith('/photo') || normalized.startsWith('/slideshow')) {
return 'gallery-and-sharing';
}
if (normalized.startsWith('/upload')) {
return 'uploading-photos';
}
if (normalized.startsWith('/queue')) {
return 'upload-troubleshooting';
}
if (normalized.startsWith('/tasks')) {
return 'tasks-and-missions';
}
if (normalized.startsWith('/achievements')) {
return 'achievements-and-badges';
}
if (normalized.startsWith('/settings')) {
return 'settings-and-cache';
}
return 'how-fotospiel-works';
}

View File

@@ -56,31 +56,3 @@ export function getMotionContainerProps(enabled: boolean, variants: Variants) {
export function getMotionItemProps(enabled: boolean, variants: Variants) { export function getMotionItemProps(enabled: boolean, variants: Variants) {
return enabled ? { variants } : {}; return enabled ? { variants } : {};
} }
export function getMotionContainerPropsForNavigation(
enabled: boolean,
variants: Variants,
navigationType: 'POP' | 'PUSH' | 'REPLACE'
) {
if (!enabled) {
return { initial: false } as const;
}
const initial = navigationType === 'POP' ? 'hidden' : false;
return { variants, initial, animate: 'show' } as const;
}
export function getMotionItemPropsForNavigation(
enabled: boolean,
variants: Variants,
navigationType: 'POP' | 'PUSH' | 'REPLACE'
) {
if (!enabled) {
return {};
}
const initial = navigationType === 'POP' ? 'hidden' : false;
return { variants, initial, animate: 'show' } as const;
}

View File

@@ -1,18 +0,0 @@
export type TaskIdentity = {
id: number;
};
export function dedupeTasksById<T extends TaskIdentity>(tasks: T[]): T[] {
const seen = new Set<number>();
const unique: T[] = [];
tasks.forEach((task) => {
if (seen.has(task.id)) {
return;
}
seen.add(task.id);
unique.push(task);
});
return unique;
}

View File

@@ -1,6 +1,7 @@
import React, { Suspense } from 'react'; import React, { Suspense } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import '../../css/app.css'; import '../../css/app.css';
import { AppearanceProvider, initializeTheme } from '@/hooks/use-appearance';
import { enableGuestDemoMode, shouldEnableGuestDemoMode } from './demo/demoMode'; import { enableGuestDemoMode, shouldEnableGuestDemoMode } from './demo/demoMode';
import { Sentry, initSentry } from '@/lib/sentry'; import { Sentry, initSentry } from '@/lib/sentry';
@@ -10,6 +11,7 @@ const GuestFallback: React.FC<{ message: string }> = ({ message }) => (
</div> </div>
); );
initializeTheme();
initSentry('guest'); initSentry('guest');
if (shouldEnableGuestDemoMode()) { if (shouldEnableGuestDemoMode()) {
enableGuestDemoMode(); enableGuestDemoMode();
@@ -22,7 +24,9 @@ const shareRoot = async () => {
createRoot(rootEl).render( createRoot(rootEl).render(
<Sentry.ErrorBoundary fallback={<GuestFallback message="Dieses Foto kann gerade nicht geladen werden." />}> <Sentry.ErrorBoundary fallback={<GuestFallback message="Dieses Foto kann gerade nicht geladen werden." />}>
<React.StrictMode> <React.StrictMode>
<AppearanceProvider>
<SharedPhotoStandalone /> <SharedPhotoStandalone />
</AppearanceProvider>
</React.StrictMode> </React.StrictMode>
</Sentry.ErrorBoundary> </Sentry.ErrorBoundary>
); );
@@ -47,6 +51,7 @@ const appRoot = async () => {
createRoot(rootEl).render( createRoot(rootEl).render(
<Sentry.ErrorBoundary fallback={<GuestFallback message="Erlebnisse können nicht geladen werden." />}> <Sentry.ErrorBoundary fallback={<GuestFallback message="Erlebnisse können nicht geladen werden." />}>
<React.StrictMode> <React.StrictMode>
<AppearanceProvider>
<LocaleProvider> <LocaleProvider>
<ToastProvider> <ToastProvider>
<MatomoTracker config={matomoConfig} /> <MatomoTracker config={matomoConfig} />
@@ -56,6 +61,7 @@ const appRoot = async () => {
</Suspense> </Suspense>
</ToastProvider> </ToastProvider>
</LocaleProvider> </LocaleProvider>
</AppearanceProvider>
</React.StrictMode> </React.StrictMode>
</Sentry.ErrorBoundary> </Sentry.ErrorBoundary>
); );

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { Link, useNavigationType, useParams } from 'react-router-dom'; import { Link, useParams } from 'react-router-dom';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -24,7 +24,7 @@ import type { LocaleCode } from '../i18n/messages';
import { localizeTaskLabel } from '../lib/localizeTaskLabel'; import { localizeTaskLabel } from '../lib/localizeTaskLabel';
import { useEventData } from '../hooks/useEventData'; import { useEventData } from '../hooks/useEventData';
import { isTaskModeEnabled } from '../lib/engagement'; import { isTaskModeEnabled } from '../lib/engagement';
import { FADE_SCALE, FADE_UP, STAGGER_FAST, getMotionContainerPropsForNavigation, getMotionItemProps, prefersReducedMotion } from '../lib/motion'; import { FADE_SCALE, FADE_UP, STAGGER_FAST, getMotionContainerProps, getMotionItemProps, prefersReducedMotion } from '../lib/motion';
import PullToRefresh from '../components/PullToRefresh'; import PullToRefresh from '../components/PullToRefresh';
const GENERIC_ERROR = 'GENERIC_ERROR'; const GENERIC_ERROR = 'GENERIC_ERROR';
@@ -343,7 +343,6 @@ function PersonalActions({ token, t, tasksEnabled }: PersonalActionsProps) {
export default function AchievementsPage() { export default function AchievementsPage() {
const { token } = useParams<{ token: string }>(); const { token } = useParams<{ token: string }>();
const navigationType = useNavigationType();
const identity = useGuestIdentity(); const identity = useGuestIdentity();
const { t, locale } = useTranslation(); const { t, locale } = useTranslation();
const { event } = useEventData(); const { event } = useEventData();
@@ -394,7 +393,7 @@ export default function AchievementsPage() {
const hasPersonal = Boolean(data?.personal); const hasPersonal = Boolean(data?.personal);
const motionEnabled = !prefersReducedMotion(); const motionEnabled = !prefersReducedMotion();
const containerMotion = getMotionContainerPropsForNavigation(motionEnabled, STAGGER_FAST, navigationType); 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 tabMotion = motionEnabled const tabMotion = motionEnabled

View File

@@ -1,7 +1,7 @@
// @ts-nocheck // @ts-nocheck
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Page } from './_util'; import { Page } from './_util';
import { useNavigationType, useParams, useSearchParams } from 'react-router-dom'; import { useParams, useSearchParams } from 'react-router-dom';
import { usePollGalleryDelta } from '../polling/usePollGalleryDelta'; import { usePollGalleryDelta } from '../polling/usePollGalleryDelta';
import FiltersBar, { type GalleryFilter } from '../components/FiltersBar'; import FiltersBar, { type GalleryFilter } from '../components/FiltersBar';
import { Heart, Image as ImageIcon, Share2 } from 'lucide-react'; import { Heart, Image as ImageIcon, Share2 } from 'lucide-react';
@@ -12,19 +12,11 @@ import { fetchEvent, type EventData } from '../services/eventApi';
import { useTranslation } from '../i18n/useTranslation'; import { useTranslation } from '../i18n/useTranslation';
import { useToast } from '../components/ToastHost'; import { useToast } from '../components/ToastHost';
import { localizeTaskLabel } from '../lib/localizeTaskLabel'; import { localizeTaskLabel } from '../lib/localizeTaskLabel';
import { shouldShowPhotoboothFilter } from '../lib/galleryFilters';
import { createPhotoShareLink } from '../services/photosApi'; import { createPhotoShareLink } from '../services/photosApi';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useEventBranding } from '../context/EventBrandingContext'; import { useEventBranding } from '../context/EventBrandingContext';
import ShareSheet from '../components/ShareSheet'; import ShareSheet from '../components/ShareSheet';
import { import { FADE_SCALE, FADE_UP, STAGGER_FAST, getMotionContainerProps, getMotionItemProps, prefersReducedMotion } from '../lib/motion';
FADE_SCALE,
FADE_UP,
STAGGER_FAST,
getMotionContainerPropsForNavigation,
getMotionItemPropsForNavigation,
prefersReducedMotion,
} from '../lib/motion';
import PullToRefresh from '../components/PullToRefresh'; import PullToRefresh from '../components/PullToRefresh';
import { triggerHaptic } from '../lib/haptics'; import { triggerHaptic } from '../lib/haptics';
@@ -64,7 +56,6 @@ const normalizeImageUrl = (src?: string | null) => {
export default function GalleryPage() { export default function GalleryPage() {
const { token } = useParams<{ token?: string }>(); const { token } = useParams<{ token?: string }>();
const navigationType = useNavigationType();
const { t, locale } = useTranslation(); const { t, locale } = useTranslation();
const { branding } = useEventBranding(); const { branding } = useEventBranding();
const { photos, loading, newCount, acknowledgeNew, refreshNow } = usePollGalleryDelta(token ?? '', locale); const { photos, loading, newCount, acknowledgeNew, refreshNow } = usePollGalleryDelta(token ?? '', locale);
@@ -77,10 +68,10 @@ export default function GalleryPage() {
const bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined; const bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined;
const headingFont = branding.typography?.heading ?? branding.fontFamily ?? undefined; const headingFont = branding.typography?.heading ?? branding.fontFamily ?? undefined;
const motionEnabled = !prefersReducedMotion(); const motionEnabled = !prefersReducedMotion();
const containerMotion = getMotionContainerPropsForNavigation(motionEnabled, STAGGER_FAST, navigationType); const containerMotion = getMotionContainerProps(motionEnabled, STAGGER_FAST);
const fadeUpMotion = getMotionItemPropsForNavigation(motionEnabled, FADE_UP, navigationType); const fadeUpMotion = getMotionItemProps(motionEnabled, FADE_UP);
const fadeScaleMotion = getMotionItemPropsForNavigation(motionEnabled, FADE_SCALE, navigationType); const fadeScaleMotion = getMotionItemProps(motionEnabled, FADE_SCALE);
const gridMotion = getMotionContainerPropsForNavigation(motionEnabled, STAGGER_FAST, navigationType); const gridMotion = getMotionContainerProps(motionEnabled, STAGGER_FAST);
const [filter, setFilterState] = React.useState<GalleryFilter>('latest'); const [filter, setFilterState] = React.useState<GalleryFilter>('latest');
const [currentPhotoIndex, setCurrentPhotoIndex] = React.useState<number | null>(null); const [currentPhotoIndex, setCurrentPhotoIndex] = React.useState<number | null>(null);
const [hasOpenedPhoto, setHasOpenedPhoto] = useState(false); const [hasOpenedPhoto, setHasOpenedPhoto] = useState(false);
@@ -97,7 +88,10 @@ export default function GalleryPage() {
}); });
const typedPhotos = photos as GalleryPhoto[]; const typedPhotos = photos as GalleryPhoto[];
const showPhotoboothFilter = React.useMemo(() => shouldShowPhotoboothFilter(event), [event]); const showPhotoboothFilter = React.useMemo(
() => Boolean(event?.photobooth_enabled) || typedPhotos.some((p) => p.ingest_source === 'photobooth'),
[event?.photobooth_enabled, typedPhotos],
);
const allowedGalleryFilters = React.useMemo<GalleryFilter[]>( const allowedGalleryFilters = React.useMemo<GalleryFilter[]>(
() => (showPhotoboothFilter ? allGalleryFilters : ['latest', 'popular', 'mine']), () => (showPhotoboothFilter ? allGalleryFilters : ['latest', 'popular', 'mine']),
[showPhotoboothFilter], [showPhotoboothFilter],
@@ -307,62 +301,54 @@ export default function GalleryPage() {
return ( return (
<Page title=""> <Page title="">
<div className="relative">
<PullToRefresh <PullToRefresh
onRefresh={handleRefresh} onRefresh={handleRefresh}
pullLabel={t('common.pullToRefresh')} pullLabel={t('common.pullToRefresh')}
releaseLabel={t('common.releaseToRefresh')} releaseLabel={t('common.releaseToRefresh')}
refreshingLabel={t('common.refreshing')} refreshingLabel={t('common.refreshing')}
> >
<motion.div className="space-y-6 pb-24" {...containerMotion}> <motion.div className="space-y-2" style={bodyFont ? { fontFamily: bodyFont } : undefined} {...containerMotion}>
<motion.div className="space-y-2" style={bodyFont ? { fontFamily: bodyFont } : undefined} {...fadeUpMotion}> <motion.div className="flex items-center gap-3" {...fadeUpMotion}>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-pink-500/10 text-pink-500" style={{ borderRadius: radius }}> <div className="flex h-10 w-10 items-center justify-center rounded-full bg-pink-500/10 text-pink-500" style={{ borderRadius: radius }}>
<ImageIcon className="h-5 w-5" aria-hidden /> <ImageIcon className="h-5 w-5" aria-hidden />
</div> </div>
<div> <div>
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-2xl font-semibold text-foreground" style={headingFont ? { fontFamily: headingFont } : undefined}>{t('galleryPage.title')}</h1> <h1 className="text-2xl font-semibold text-foreground" style={headingFont ? { fontFamily: headingFont } : undefined}>{t('galleryPage.title')}</h1>
<span
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-[11px] font-semibold ${badgeEmphasisClass}`}
style={{ borderRadius: radius }}
>
{newPhotosBadgeText}
</span>
</div>
<p className="text-sm text-muted-foreground">{t('galleryPage.subtitle')}</p> <p className="text-sm text-muted-foreground">{t('galleryPage.subtitle')}</p>
</div> </div>
</div>
{newCount > 0 && ( {newCount > 0 ? (
<button <button
type="button" type="button"
onClick={acknowledgeNew} onClick={acknowledgeNew}
className="inline-flex items-center rounded-full px-2.5 py-0.5 text-[11px] font-semibold text-pink-600 transition hover:bg-pink-50" className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold transition ${badgeEmphasisClass}`}
style={{ borderRadius: radius }} style={{ borderRadius: radius }}
> >
{t('galleryPage.badge.markSeen', 'Gesehen')} {newPhotosBadgeText}
</button> </button>
) : (
<span className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold ${badgeEmphasisClass}`} style={{ borderRadius: radius }}>
{newPhotosBadgeText}
</span>
)} )}
</div> </motion.div>
</motion.div>
<motion.div {...fadeUpMotion}>
<FiltersBar <FiltersBar
value={filter} value={filter}
onChange={setFilter} onChange={setFilter}
className="mt-0" className="mt-2"
showPhotobooth={showPhotoboothFilter} showPhotobooth={showPhotoboothFilter}
styleOverride={{ borderRadius: radius, fontFamily: headingFont }} styleOverride={{ borderRadius: radius, fontFamily: headingFont }}
/> />
</motion.div> </motion.div>
{loading && ( {loading && (
<motion.p className="px-1" {...fadeUpMotion}> <motion.p className="px-4" style={bodyFont ? { fontFamily: bodyFont } : undefined} {...fadeUpMotion}>
{t('galleryPage.loading', 'Lade…')} {t('galleryPage.loading', 'Lade…')}
</motion.p> </motion.p>
)} )}
<motion.div className="grid grid-cols-2 gap-2 px-2 pb-16 sm:grid-cols-3 lg:grid-cols-4" {...gridMotion}>
<motion.div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4" {...gridMotion}>
{list.map((p: GalleryPhoto) => { {list.map((p: GalleryPhoto) => {
const imageUrl = normalizeImageUrl(p.thumbnail_path || p.file_path); const imageUrl = normalizeImageUrl(p.thumbnail_path || p.file_path);
const createdLabel = p.created_at const createdLabel = p.created_at
@@ -391,11 +377,10 @@ export default function GalleryPage() {
openPhoto(); openPhoto();
} }
}} }}
className="group flex flex-col overflow-hidden border border-border/60 bg-white shadow-sm ring-1 ring-black/5 transition duration-300 hover:-translate-y-0.5 hover:shadow-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-pink-400 dark:border-white/10 dark:bg-slate-950 dark:ring-white/10" className="group relative overflow-hidden border border-white/20 bg-gray-950 text-white shadow-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-pink-400"
style={{ borderRadius: radius }} style={{ borderRadius: radius }}
{...fadeScaleMotion} {...fadeScaleMotion}
> >
<div className="relative">
<img <img
src={imageUrl} src={imageUrl}
alt={altText} alt={altText}
@@ -405,38 +390,15 @@ export default function GalleryPage() {
}} }}
loading="lazy" loading="lazy"
/> />
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-20 bg-gradient-to-t from-black/55 via-black/0 to-transparent" aria-hidden /> <div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/85 via-black/20 to-transparent" aria-hidden />
</div> <div className="absolute inset-x-0 bottom-0 space-y-2 px-4 pb-4" style={bodyFont ? { fontFamily: bodyFont } : undefined}>
<div className="space-y-2 px-3 pb-3 pt-3" style={bodyFont ? { fontFamily: bodyFont } : undefined}> {localizedTaskTitle && <p className="text-sm font-medium leading-tight line-clamp-2 text-white" style={headingFont ? { fontFamily: headingFont } : undefined}>{localizedTaskTitle}</p>}
{localizedTaskTitle && ( <div className="flex items-center justify-between text-xs text-white/90" style={bodyFont ? { fontFamily: bodyFont } : undefined}>
<p
className="text-sm font-semibold leading-tight line-clamp-2 text-foreground"
style={headingFont ? { fontFamily: headingFont } : undefined}
>
{localizedTaskTitle}
</p>
)}
<div className="flex items-center justify-between gap-2 text-[11px] text-muted-foreground">
<span className="truncate">{createdLabel}</span> <span className="truncate">{createdLabel}</span>
<span className="truncate">{p.uploader_name || t('galleryPage.photo.anonymous', 'Gast')}</span> <span className="ml-3 truncate text-right">{p.uploader_name || t('galleryPage.photo.anonymous', 'Gast')}</span>
</div> </div>
<div className="flex items-center justify-between gap-2"> </div>
<button <div className="absolute right-3 top-3 z-10 flex items-center gap-2">
type="button"
onClick={(e) => {
e.stopPropagation();
onLike(p.id);
}}
className={cn(
'inline-flex items-center gap-1 rounded-full border border-border/60 px-3 py-1 text-xs font-semibold text-foreground transition',
liked.has(p.id) ? 'border-pink-200 bg-pink-50 text-pink-600' : 'hover:bg-muted/40'
)}
aria-label={t('galleryPage.photo.likeAria', 'Foto liken')}
style={{ borderRadius: radius }}
>
<Heart className={`h-3.5 w-3.5 ${liked.has(p.id) ? 'fill-current' : ''}`} aria-hidden />
{likeCount}
</button>
<button <button
type="button" type="button"
onClick={(e) => { onClick={(e) => {
@@ -444,17 +406,41 @@ export default function GalleryPage() {
onShare(p); onShare(p);
}} }}
className={cn( className={cn(
'inline-flex items-center gap-1 rounded-full border border-border/60 px-3 py-1 text-xs font-semibold text-foreground transition', 'flex h-9 w-9 items-center justify-center border text-white transition backdrop-blur',
shareTargetId === p.id ? 'opacity-60' : 'hover:bg-muted/40' shareTargetId === p.id ? 'opacity-60' : 'hover:bg-white/10'
)} )}
aria-label={t('galleryPage.photo.shareAria', 'Foto teilen')} aria-label={t('galleryPage.photo.shareAria', 'Foto teilen')}
disabled={shareTargetId === p.id} disabled={shareTargetId === p.id}
style={{ borderRadius: radius }} style={{
borderRadius: radius,
background: buttonStyle === 'outline' ? 'transparent' : '#00000066',
border: buttonStyle === 'outline' ? `1px solid ${linkColor}` : '1px solid rgba(255,255,255,0.4)',
color: buttonStyle === 'outline' ? linkColor : undefined,
}}
> >
<Share2 className="h-3.5 w-3.5" aria-hidden /> <Share2 className="h-4 w-4" aria-hidden />
{t('galleryPage.photo.shareLabel', 'Teilen')} </button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onLike(p.id);
}}
className={cn(
'flex items-center gap-1 px-3 py-1 text-sm font-medium transition backdrop-blur',
liked.has(p.id) ? 'text-pink-300' : 'text-white'
)}
aria-label={t('galleryPage.photo.likeAria', 'Foto liken')}
style={{
borderRadius: radius,
background: buttonStyle === 'outline' ? 'transparent' : '#00000066',
border: buttonStyle === 'outline' ? `1px solid ${linkColor}` : '1px solid rgba(255,255,255,0.4)',
color: buttonStyle === 'outline' ? linkColor : undefined,
}}
>
<Heart className={`h-4 w-4 ${liked.has(p.id) ? 'fill-current' : ''}`} aria-hidden />
{likeCount}
</button> </button>
</div>
</div> </div>
</motion.div> </motion.div>
); );
@@ -462,7 +448,7 @@ export default function GalleryPage() {
{list.length === 0 && Array.from({ length: 6 }).map((_, idx) => ( {list.length === 0 && Array.from({ length: 6 }).map((_, idx) => (
<motion.div <motion.div
key={`placeholder-${idx}`} key={`placeholder-${idx}`}
className="relative overflow-hidden border border-muted/40 bg-white shadow-sm ring-1 ring-black/5 dark:bg-slate-950 dark:ring-white/10" className="relative overflow-hidden border border-muted/40 bg-[var(--guest-surface,#f7f7f7)] shadow-sm"
style={{ borderRadius: radius }} style={{ borderRadius: radius }}
{...fadeScaleMotion} {...fadeScaleMotion}
> >
@@ -475,9 +461,7 @@ export default function GalleryPage() {
</motion.div> </motion.div>
))} ))}
</motion.div> </motion.div>
</motion.div>
</PullToRefresh> </PullToRefresh>
</div>
{currentPhotoIndex !== null && list.length > 0 && ( {currentPhotoIndex !== null && list.length > 0 && (
<PhotoLightbox <PhotoLightbox
photos={list} photos={list}

View File

@@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { Link, useParams } from 'react-router-dom'; import { Link, useParams } from 'react-router-dom';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { ArrowLeft, Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import { Page } from './_util'; import { Page } from './_util';
import { useLocale } from '../i18n/LocaleContext'; import { useLocale } from '../i18n/LocaleContext';
import { useTranslation } from '../i18n/useTranslation'; import { useTranslation } from '../i18n/useTranslation';
@@ -37,9 +37,7 @@ export default function HelpArticlePage() {
loadArticle(); loadArticle();
}, [loadArticle]); }, [loadArticle]);
const title = state === 'loading' const title = article?.title ?? t('help.article.unavailable');
? t('help.article.loadingTitle')
: (article?.title ?? t('help.article.unavailable'));
return ( return (
<Page title={title}> <Page title={title}>
@@ -50,30 +48,17 @@ export default function HelpArticlePage() {
refreshingLabel={t('common.refreshing')} refreshingLabel={t('common.refreshing')}
> >
<div className="mb-4"> <div className="mb-4">
<Button variant="outline" size="sm" className="rounded-full border-border/60 bg-background/70 px-3" asChild> <Button variant="ghost" size="sm" asChild>
<Link to={basePath}> <Link to={basePath}>
<span className="inline-flex items-center gap-2">
<ArrowLeft className="h-4 w-4" aria-hidden />
{t('help.article.back')} {t('help.article.back')}
</span>
</Link> </Link>
</Button> </Button>
</div> </div>
{state === 'loading' && ( {state === 'loading' && (
<div className="rounded-2xl border border-border/60 bg-card/70 p-5"> <div className="flex items-center gap-2 text-sm text-muted-foreground">
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
<div> {t('common.actions.loading')}
<div className="font-medium text-foreground">{t('help.article.loadingTitle')}</div>
<div className="text-xs text-muted-foreground">{t('help.article.loadingDescription')}</div>
</div>
</div>
<div className="mt-4 space-y-2 animate-pulse">
<div className="h-3 w-2/3 rounded-full bg-muted/60" />
<div className="h-3 w-5/6 rounded-full bg-muted/60" />
<div className="h-3 w-1/2 rounded-full bg-muted/60" />
</div>
</div> </div>
)} )}
@@ -92,6 +77,11 @@ export default function HelpArticlePage() {
{article.updated_at && ( {article.updated_at && (
<div>{t('help.article.updated', { date: formatDate(article.updated_at, locale) })}</div> <div>{t('help.article.updated', { date: formatDate(article.updated_at, locale) })}</div>
)} )}
<Button variant="ghost" size="sm" asChild>
<Link to={basePath}>
{t('help.article.back')}
</Link>
</Button>
</div> </div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<div <div

View File

@@ -18,7 +18,6 @@ export default function HelpCenterPage() {
const [query, setQuery] = React.useState(''); const [query, setQuery] = React.useState('');
const [state, setState] = React.useState<'idle' | 'loading' | 'ready' | 'error'>('loading'); const [state, setState] = React.useState<'idle' | 'loading' | 'ready' | 'error'>('loading');
const [servedFromCache, setServedFromCache] = React.useState(false); const [servedFromCache, setServedFromCache] = React.useState(false);
const [isOnline, setIsOnline] = React.useState(() => (typeof navigator !== 'undefined' ? navigator.onLine : true));
const basePath = params.token ? `/e/${encodeURIComponent(params.token)}/help` : '/help'; const basePath = params.token ? `/e/${encodeURIComponent(params.token)}/help` : '/help';
const loadArticles = React.useCallback(async (forceRefresh = false) => { const loadArticles = React.useCallback(async (forceRefresh = false) => {
@@ -38,24 +37,6 @@ export default function HelpCenterPage() {
loadArticles(); loadArticles();
}, [loadArticles]); }, [loadArticles]);
React.useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const handleOnline = () => setIsOnline(true);
const handleOffline = () => setIsOnline(false);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
const showOfflineBadge = servedFromCache && !isOnline;
const filteredArticles = React.useMemo(() => { const filteredArticles = React.useMemo(() => {
if (!query.trim()) { if (!query.trim()) {
return articles; return articles;
@@ -104,7 +85,7 @@ export default function HelpCenterPage() {
)} )}
</Button> </Button>
</div> </div>
{showOfflineBadge && ( {servedFromCache && (
<div className="flex items-center gap-2 rounded-lg bg-amber-50/70 px-3 py-2 text-xs text-amber-900 dark:bg-amber-400/10 dark:text-amber-200"> <div className="flex items-center gap-2 rounded-lg bg-amber-50/70 px-3 py-2 text-xs text-amber-900 dark:bg-amber-400/10 dark:text-amber-200">
<Badge variant="secondary" className="bg-amber-200/80 text-amber-900 dark:bg-amber-500/40 dark:text-amber-100"> <Badge variant="secondary" className="bg-amber-200/80 text-amber-900 dark:bg-amber-500/40 dark:text-amber-100">
{t('help.center.offlineBadge')} {t('help.center.offlineBadge')}

View File

@@ -9,8 +9,9 @@ import { useTranslation } from '../i18n/useTranslation';
import { DEFAULT_LOCALE, isLocaleCode } from '../i18n/messages'; import { DEFAULT_LOCALE, isLocaleCode } from '../i18n/messages';
import { AlertTriangle, Download, Loader2, Share, X } from 'lucide-react'; import { AlertTriangle, Download, Loader2, Share, X } from 'lucide-react';
import { createPhotoShareLink } from '../services/photosApi'; import { createPhotoShareLink } from '../services/photosApi';
import { Share } from 'lucide-react';
import { createPhotoShareLink } from '../services/photosApi';
import { getContrastingTextColor } from '../lib/color'; import { getContrastingTextColor } from '../lib/color';
import { applyGuestTheme } from '../lib/guestTheme';
interface GalleryState { interface GalleryState {
meta: GalleryMetaResponse | null; meta: GalleryMetaResponse | null;
@@ -94,34 +95,28 @@ export default function PublicGalleryPage(): React.ReactElement | null {
loadInitial(); loadInitial();
}, [loadInitial]); }, [loadInitial]);
const resolvedBranding = useMemo(() => {
if (!state.meta) {
return null;
}
const palette = state.meta.branding.palette ?? {};
const primary = palette.primary ?? state.meta.branding.primary_color ?? '#f43f5e';
const secondary = palette.secondary ?? state.meta.branding.secondary_color ?? '#fb7185';
const background = palette.background ?? state.meta.branding.background_color ?? '#ffffff';
const surface = palette.surface ?? state.meta.branding.surface_color ?? background;
const mode = state.meta.branding.mode ?? 'auto';
return {
primary,
secondary,
background,
surface,
mode,
};
}, [state.meta]);
useEffect(() => { useEffect(() => {
if (!resolvedBranding) { const mode = state.meta?.branding.mode;
if (!mode || typeof document === 'undefined') {
return; return;
} }
return applyGuestTheme(resolvedBranding); const wasDark = document.documentElement.classList.contains('dark');
}, [resolvedBranding]);
if (mode === 'dark') {
document.documentElement.classList.add('dark');
} else if (mode === 'light') {
document.documentElement.classList.remove('dark');
}
return () => {
if (wasDark) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
};
}, [state.meta?.branding.mode]);
const loadMore = useCallback(async () => { const loadMore = useCallback(async () => {
if (!token || !state.cursor || state.loadingMore) { if (!token || !state.cursor || state.loadingMore) {
@@ -169,46 +164,55 @@ export default function PublicGalleryPage(): React.ReactElement | null {
}, [state.cursor, loadMore]); }, [state.cursor, loadMore]);
const themeStyles = useMemo(() => { const themeStyles = useMemo(() => {
if (!resolvedBranding) { if (!state.meta) {
return {} as React.CSSProperties; return {} as React.CSSProperties;
} }
const palette = state.meta.branding.palette ?? {};
const primary = palette.primary ?? state.meta.branding.primary_color;
const secondary = palette.secondary ?? state.meta.branding.secondary_color;
const background = palette.background ?? state.meta.branding.background_color;
const surface = palette.surface ?? state.meta.branding.surface_color ?? background;
return { return {
'--gallery-primary': resolvedBranding.primary, '--gallery-primary': primary,
'--gallery-secondary': resolvedBranding.secondary, '--gallery-secondary': secondary,
'--gallery-background': resolvedBranding.background, '--gallery-background': background,
'--gallery-surface': resolvedBranding.surface, '--gallery-surface': surface,
} as React.CSSProperties & Record<string, string>; } as React.CSSProperties & Record<string, string>;
}, [resolvedBranding]); }, [state.meta]);
const headerStyle = useMemo(() => { const headerStyle = useMemo(() => {
if (!resolvedBranding) { if (!state.meta) {
return {}; return {};
} }
const textColor = getContrastingTextColor(resolvedBranding.primary, '#0f172a', '#ffffff'); const palette = state.meta.branding.palette ?? {};
const primary = palette.primary ?? state.meta.branding.primary_color;
const secondary = palette.secondary ?? state.meta.branding.secondary_color ?? primary;
const textColor = getContrastingTextColor(primary ?? '#f43f5e', '#0f172a', '#ffffff');
return { return {
background: `linear-gradient(135deg, ${resolvedBranding.primary}, ${resolvedBranding.secondary})`, background: `linear-gradient(135deg, ${primary}, ${secondary})`,
color: textColor, color: textColor,
} satisfies React.CSSProperties; } satisfies React.CSSProperties;
}, [resolvedBranding]); }, [state.meta]);
const accentStyle = useMemo(() => { const accentStyle = useMemo(() => {
if (!resolvedBranding) { if (!state.meta) {
return {}; return {};
} }
return { return {
color: resolvedBranding.primary, color: (state.meta.branding.palette?.primary ?? state.meta.branding.primary_color),
} satisfies React.CSSProperties; } satisfies React.CSSProperties;
}, [resolvedBranding]); }, [state.meta]);
const backgroundStyle = useMemo(() => { const backgroundStyle = useMemo(() => {
if (!resolvedBranding) { if (!state.meta) {
return {}; return {};
} }
return { return {
backgroundColor: resolvedBranding.background, backgroundColor: state.meta.branding.palette?.background ?? state.meta.branding.background_color,
} satisfies React.CSSProperties; } satisfies React.CSSProperties;
}, [resolvedBranding]); }, [state.meta]);
const openLightbox = useCallback((photo: GalleryPhotoResource) => { const openLightbox = useCallback((photo: GalleryPhotoResource) => {
setSelectedPhoto(photo); setSelectedPhoto(photo);

View File

@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { useNavigate, useNavigationType, useParams, useSearchParams } from 'react-router-dom'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Alert, AlertDescription } from '@/components/ui/alert'; import { Alert, AlertDescription } from '@/components/ui/alert';
import { Sparkles, RefreshCw, Smile, Camera, Timer as TimerIcon, Heart, ChevronRight, CheckCircle2 } from 'lucide-react'; import { Sparkles, RefreshCw, Smile, Camera, Timer as TimerIcon, Heart, ChevronRight, CheckCircle2 } from 'lucide-react';
@@ -18,10 +18,9 @@ import {
type EmotionTheme, type EmotionTheme,
} from '../lib/emotionTheme'; } from '../lib/emotionTheme';
import { getDeviceId } from '../lib/device'; import { getDeviceId } from '../lib/device';
import { FADE_SCALE, FADE_UP, STAGGER_FAST, getMotionContainerPropsForNavigation, getMotionItemProps, prefersReducedMotion } from '../lib/motion'; import { FADE_SCALE, FADE_UP, STAGGER_FAST, getMotionContainerProps, getMotionItemProps, prefersReducedMotion } from '../lib/motion';
import PullToRefresh from '../components/PullToRefresh'; import PullToRefresh from '../components/PullToRefresh';
import { triggerHaptic } from '../lib/haptics'; import { triggerHaptic } from '../lib/haptics';
import { dedupeTasksById } from '../lib/taskUtils';
interface Task { interface Task {
id: number; id: number;
@@ -56,7 +55,6 @@ export default function TaskPickerPage() {
const { token } = useParams<{ token: string }>(); const { token } = useParams<{ token: string }>();
const eventKey = token ?? ''; const eventKey = token ?? '';
const navigate = useNavigate(); const navigate = useNavigate();
const navigationType = useNavigationType();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const { branding } = useEventBranding(); const { branding } = useEventBranding();
const { t, locale } = useTranslation(); const { t, locale } = useTranslation();
@@ -135,10 +133,9 @@ export default function TaskPickerPage() {
? payload.tasks ? payload.tasks
: []; : [];
const uniqueTasks = dedupeTasksById(taskList); const entry = { data: taskList, etag: response.headers.get('ETag') };
const entry = { data: uniqueTasks, etag: response.headers.get('ETag') };
tasksCacheRef.current.set(cacheKey, entry); tasksCacheRef.current.set(cacheKey, entry);
setTasks(uniqueTasks); setTasks(taskList);
} catch (err) { } catch (err) {
console.error('Failed to load tasks', err); console.error('Failed to load tasks', err);
setError(err instanceof Error ? err.message : 'Unbekannter Fehler'); setError(err instanceof Error ? err.message : 'Unbekannter Fehler');
@@ -372,7 +369,7 @@ export default function TaskPickerPage() {
); );
const toggleValue = selectedEmotion === 'all' ? 'none' : 'recent'; const toggleValue = selectedEmotion === 'all' ? 'none' : 'recent';
const motionEnabled = !prefersReducedMotion(); const motionEnabled = !prefersReducedMotion();
const containerMotion = getMotionContainerPropsForNavigation(motionEnabled, STAGGER_FAST, navigationType); 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);

View File

@@ -1488,7 +1488,7 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[
</div> </div>
</div> </div>
) : ( ) : (
<div className="relative flex h-24 w-24 items-center justify-center"> <div className="relative h-24 w-24">
{!isCountdownActive && mode !== 'uploading' && ( {!isCountdownActive && mode !== 'uploading' && (
<span className="pointer-events-none absolute inset-0 rounded-full border border-white/30 opacity-60 animate-ping" /> <span className="pointer-events-none absolute inset-0 rounded-full border border-white/30 opacity-60 animate-ping" />
)} )}

View File

@@ -1,49 +0,0 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import HelpArticlePage from '../HelpArticlePage';
import type { HelpArticleDetail } from '../../services/helpApi';
vi.mock('../../i18n/LocaleContext', () => ({
useLocale: () => ({ locale: 'de' }),
}));
vi.mock('../../i18n/useTranslation', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
vi.mock('../../services/helpApi', () => ({
getHelpArticle: vi.fn(),
}));
const { getHelpArticle } = await import('../../services/helpApi');
describe('HelpArticlePage', () => {
it('renders a single back button after loading', async () => {
const article: HelpArticleDetail = {
slug: 'gallery-and-sharing',
title: 'Galerie & Teilen',
summary: 'Kurzfassung',
body_html: '<p>Inhalt</p>',
};
(getHelpArticle as ReturnType<typeof vi.fn>).mockResolvedValue({ article, servedFromCache: false });
render(
<MemoryRouter initialEntries={['/e/demo/help/gallery-and-sharing']}>
<Routes>
<Route path="/e/:token/help/:slug" element={<HelpArticlePage />} />
</Routes>
</MemoryRouter>,
);
await waitFor(() => {
expect(screen.getByText('Galerie & Teilen')).toBeInTheDocument();
});
expect(screen.getAllByText('help.article.back')).toHaveLength(1);
});
});

View File

@@ -1,6 +1,6 @@
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, waitFor } from '@testing-library/react'; import { render, waitFor } from '@testing-library/react';
import UploadPage from '../UploadPage'; import UploadPage from '../UploadPage';
vi.mock('react-router-dom', () => ({ vi.mock('react-router-dom', () => ({
@@ -73,15 +73,4 @@ describe('UploadPage immersive mode', () => {
expect(document.body.classList.contains('guest-immersive')).toBe(true); expect(document.body.classList.contains('guest-immersive')).toBe(true);
}); });
}); });
it('centers the capture button within the countdown ring', () => {
render(<UploadPage />);
const captureButton = screen.getByRole('button', { name: 'upload.buttons.startCamera' });
const wrapper = captureButton.parentElement;
expect(wrapper).not.toBeNull();
expect(wrapper?.className).toContain('items-center');
expect(wrapper?.className).toContain('justify-center');
});
}); });

View File

@@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { createBrowserRouter, useLocation, useParams, Link, Navigate } from 'react-router-dom'; import { createBrowserRouter, useParams, Link, Navigate } from 'react-router-dom';
import Header from './components/Header'; import Header from './components/Header';
import BottomNav from './components/BottomNav'; import BottomNav from './components/BottomNav';
import RouteTransition from './components/RouteTransition'; import RouteTransition from './components/RouteTransition';
@@ -101,7 +101,6 @@ export const router = createBrowserRouter([
function EventBoundary({ token }: { token: string }) { function EventBoundary({ token }: { token: string }) {
const identity = useOptionalGuestIdentity(); const identity = useOptionalGuestIdentity();
const { event, status, error, errorCode } = useEventData(); const { event, status, error, errorCode } = useEventData();
const location = useLocation();
if (status === 'loading') { if (status === 'loading') {
return <EventLoadingView />; return <EventLoadingView />;
@@ -119,9 +118,6 @@ function EventBoundary({ token }: { token: string }) {
const localeStorageKey = `guestLocale_event_${event.id ?? token}`; const localeStorageKey = `guestLocale_event_${event.id ?? token}`;
const branding = mapEventBranding(event.branding ?? (event as any)?.settings?.branding ?? null); const branding = mapEventBranding(event.branding ?? (event as any)?.settings?.branding ?? null);
const isGalleryRoute = /^\/e\/[^/]+\/gallery(?:\/|$)/.test(location.pathname);
const contentPaddingClass = isGalleryRoute ? 'px-0 py-0' : 'px-4 py-3';
return ( return (
<LocaleProvider defaultLocale={eventLocale} storageKey={localeStorageKey}> <LocaleProvider defaultLocale={eventLocale} storageKey={localeStorageKey}>
<EventBrandingProvider branding={branding}> <EventBrandingProvider branding={branding}>
@@ -129,7 +125,7 @@ function EventBoundary({ token }: { token: string }) {
<NotificationCenterProvider eventToken={token}> <NotificationCenterProvider eventToken={token}>
<div className="pb-16"> <div className="pb-16">
<Header eventToken={token} /> <Header eventToken={token} />
<div className={contentPaddingClass}> <div className="px-4 py-3">
<RouteTransition /> <RouteTransition />
</div> </div>
<BottomNav /> <BottomNav />

View File

@@ -128,25 +128,6 @@ return [
'benefit4' => 'Support durch das Die Fotospiel.App Team', 'benefit4' => 'Support durch das Die Fotospiel.App Team',
'footer' => 'Wir helfen Ihnen gern weiter, falls Fragen offen sind.', 'footer' => 'Wir helfen Ihnen gern weiter, falls Fragen offen sind.',
], ],
'photobooth_uploader' => [
'subject' => 'Fotospiel Uploader App für :event',
'preheader' => 'Download-Links für die Fotospiel Photobooth Uploader App.',
'hero_title' => 'Hallo :name,',
'hero_subtitle' => 'Ihre Uploader App für :event ist bereit.',
'body' => 'Hier finden Sie die Download-Links für die Fotospiel Photobooth Uploader App. Installieren Sie die passende Version auf dem Photobooth-PC, bevor Ihr Event startet.',
'downloads_title' => 'Download-Links',
'downloads' => [
'windows' => 'Windows (x64)',
'macos' => 'macOS (x64)',
'linux' => 'Linux (x64)',
],
'cta_windows' => 'Download für Windows',
'cta_macos' => 'Download für macOS',
'cta_linux' => 'Download für Linux',
'credentials_hint' => 'Die Zugangsdaten bleiben im Admin-Dashboard. Erstellen Sie einen Verbindungscode, sobald Sie die App koppeln möchten.',
'footer' => 'Wenn Sie Fragen haben, antworten Sie einfach auf diese E-Mail.',
'event_fallback' => 'Ihr Event',
],
'contact' => [ 'contact' => [
'subject' => 'Neue Kontakt-Anfrage', 'subject' => 'Neue Kontakt-Anfrage',

View File

@@ -127,25 +127,6 @@ return [
'benefit4' => 'Support from the Die Fotospiel.App team', 'benefit4' => 'Support from the Die Fotospiel.App team',
'footer' => 'Let us know if you need anything.', 'footer' => 'Let us know if you need anything.',
], ],
'photobooth_uploader' => [
'subject' => 'Fotospiel Uploader App for :event',
'preheader' => 'Download links for the Fotospiel Photobooth Uploader.',
'hero_title' => 'Hi :name,',
'hero_subtitle' => 'Your uploader app for :event is ready.',
'body' => 'Here are the download links for the Fotospiel Photobooth Uploader. Install the right version on the photobooth PC before your event starts.',
'downloads_title' => 'Download links',
'downloads' => [
'windows' => 'Windows (x64)',
'macos' => 'macOS (x64)',
'linux' => 'Linux (x64)',
],
'cta_windows' => 'Download for Windows',
'cta_macos' => 'Download for macOS',
'cta_linux' => 'Download for Linux',
'credentials_hint' => 'Connection credentials stay in the admin dashboard. Generate a connect code when you are ready to pair the app.',
'footer' => 'Questions? Reply to this email and we will help.',
'event_fallback' => 'your event',
],
'contact' => [ 'contact' => [
'subject' => 'New Contact Request', 'subject' => 'New Contact Request',

View File

@@ -1,66 +0,0 @@
@extends('emails.partials.layout')
@section('title', __('emails.photobooth_uploader.subject', ['event' => $eventName]))
@section('preheader', __('emails.photobooth_uploader.preheader', ['event' => $eventName]))
@section('hero_title', __('emails.photobooth_uploader.hero_title', ['name' => $recipientName]))
@section('hero_subtitle', __('emails.photobooth_uploader.hero_subtitle', ['event' => $eventName]))
@section('content')
<p style="margin:0 0 16px; font-size:15px; color:#1f2937;">
{{ __('emails.photobooth_uploader.body', ['event' => $eventName]) }}
</p>
<p style="margin:0 0 12px; font-size:14px; color:#6b7280;">
{{ __('emails.photobooth_uploader.downloads_title') }}
</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="margin-bottom:12px;">
<tr>
<td style="padding:4px 0; font-size:14px; color:#1f2937;">
<strong>{{ __('emails.photobooth_uploader.downloads.windows') }}</strong>
</td>
<td align="right" style="padding:4px 0; font-size:14px;">
<a href="{{ $links['windows'] }}" style="color:#1d4ed8; text-decoration:none;">
{{ $links['windows'] }}
</a>
</td>
</tr>
<tr>
<td style="padding:4px 0; font-size:14px; color:#1f2937;">
<strong>{{ __('emails.photobooth_uploader.downloads.macos') }}</strong>
</td>
<td align="right" style="padding:4px 0; font-size:14px;">
<a href="{{ $links['macos'] }}" style="color:#1d4ed8; text-decoration:none;">
{{ $links['macos'] }}
</a>
</td>
</tr>
<tr>
<td style="padding:4px 0; font-size:14px; color:#1f2937;">
<strong>{{ __('emails.photobooth_uploader.downloads.linux') }}</strong>
</td>
<td align="right" style="padding:4px 0; font-size:14px;">
<a href="{{ $links['linux'] }}" style="color:#1d4ed8; text-decoration:none;">
{{ $links['linux'] }}
</a>
</td>
</tr>
</table>
<p style="margin:0; font-size:14px; color:#6b7280;">
{{ __('emails.photobooth_uploader.credentials_hint') }}
</p>
@endsection
@section('cta')
<a href="{{ $links['windows'] }}" style="display:inline-block; background-color:#111827; color:#ffffff; text-decoration:none; padding:12px 20px; border-radius:999px; font-weight:600; font-size:14px; margin-right:8px;">
{{ __('emails.photobooth_uploader.cta_windows') }}
</a>
<a href="{{ $links['macos'] }}" style="display:inline-block; background-color:#f3f4f6; color:#111827; text-decoration:none; padding:12px 18px; border-radius:999px; font-weight:600; font-size:14px; margin-right:8px;">
{{ __('emails.photobooth_uploader.cta_macos') }}
</a>
<a href="{{ $links['linux'] }}" style="display:inline-block; background-color:#f3f4f6; color:#111827; text-decoration:none; padding:12px 18px; border-radius:999px; font-weight:600; font-size:14px;">
{{ __('emails.photobooth_uploader.cta_linux') }}
</a>
@endsection
@section('footer')
{!! __('emails.photobooth_uploader.footer') !!}
@endsection

View File

@@ -153,8 +153,8 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
->middleware('signed') ->middleware('signed')
->name('gallery.photos.asset'); ->name('gallery.photos.asset');
Route::post('/photobooth/upload', [SparkboothUploadController::class, 'store']) Route::post('/photobooth/sparkbooth/upload', [SparkboothUploadController::class, 'store'])
->name('photobooth.upload'); ->name('photobooth.sparkbooth.upload');
Route::post('/photobooth/connect', [PhotoboothConnectController::class, 'store']) Route::post('/photobooth/connect', [PhotoboothConnectController::class, 'store'])
->middleware('throttle:photobooth-connect') ->middleware('throttle:photobooth-connect')
->name('photobooth.connect'); ->name('photobooth.connect');
@@ -270,8 +270,6 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
Route::post('/disable', [PhotoboothController::class, 'disable'])->name('tenant.events.photobooth.disable'); Route::post('/disable', [PhotoboothController::class, 'disable'])->name('tenant.events.photobooth.disable');
Route::post('/connect-codes', [PhotoboothConnectCodeController::class, 'store']) Route::post('/connect-codes', [PhotoboothConnectCodeController::class, 'store'])
->name('tenant.events.photobooth.connect-codes.store'); ->name('tenant.events.photobooth.connect-codes.store');
Route::post('/uploader-email', [PhotoboothController::class, 'sendUploaderDownloadEmail'])
->name('tenant.events.photobooth.uploader-email');
}); });
Route::get('members', [EventMemberController::class, 'index']) Route::get('members', [EventMemberController::class, 'index'])

View File

@@ -1,69 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
WORKDIR=${WORKDIR:-/var/www/html}
SRC_DIR="${WORKDIR}/clients/photobooth-uploader/PhotoboothUploader"
OUT_DIR="${WORKDIR}/public/downloads"
WIN_FILE="${OUT_DIR}/PhotoboothUploader-win-x64.exe"
MAC_FILE="${OUT_DIR}/PhotoboothUploader-macos-x64"
LINUX_FILE="${OUT_DIR}/PhotoboothUploader-linux-x64"
STAMP_FILE="${OUT_DIR}/photobooth-uploader.hash"
if [[ ! -d "$SRC_DIR" ]]; then
echo "[photobooth-uploader] Source directory not found: ${SRC_DIR}"
exit 0
fi
mkdir -p "$OUT_DIR"
compute_hash() {
find "$SRC_DIR" -type f \
-not -path "*/bin/*" \
-not -path "*/obj/*" \
-print \
| LC_ALL=C sort \
| xargs sha256sum \
| sha256sum \
| awk '{print $1}'
}
HASH=$(compute_hash)
if [[ -f "$WIN_FILE" && -f "$MAC_FILE" && -f "$LINUX_FILE" && -f "$STAMP_FILE" ]]; then
CURRENT_HASH=$(cat "$STAMP_FILE" || true)
if [[ "$CURRENT_HASH" == "$HASH" ]]; then
echo "[photobooth-uploader] Up to date, skipping publish."
exit 0
fi
fi
publish_target() {
local rid="$1"
local output_file="$2"
local temp_dir
temp_dir=$(mktemp -d)
dotnet publish "${SRC_DIR}/PhotoboothUploader.csproj" \
-c Release \
-r "$rid" \
--self-contained true \
/p:PublishSingleFile=true \
/p:IncludeNativeLibrariesForSelfExtract=true \
-o "$temp_dir"
if [[ "$rid" == "win-x64" ]]; then
mv -f "$temp_dir/PhotoboothUploader.exe" "$output_file"
else
mv -f "$temp_dir/PhotoboothUploader" "$output_file"
fi
rm -rf "$temp_dir"
}
echo "[photobooth-uploader] Publishing uploader binaries..."
publish_target "win-x64" "$WIN_FILE"
publish_target "osx-x64" "$MAC_FILE"
publish_target "linux-x64" "$LINUX_FILE"
echo "$HASH" > "$STAMP_FILE"
echo "[photobooth-uploader] Published to ${OUT_DIR}"

View File

@@ -232,48 +232,6 @@ class EventControllerTest extends TenantTestCase
$this->assertSame('blur_last', data_get($settings, 'live_show.background_mode')); $this->assertSame('blur_last', data_get($settings, 'live_show.background_mode'));
} }
public function test_update_event_uploads_branding_logo_data_url(): void
{
Storage::fake('public');
$eventType = EventType::factory()->create();
$event = Event::factory()->for($this->tenant)->create([
'event_type_id' => $eventType->id,
'name' => 'Branding Event',
'slug' => 'branding-event',
'date' => now()->addDays(5),
]);
$logoFile = UploadedFile::fake()->image('logo.png', 64, 64);
$logoContents = file_get_contents($logoFile->getRealPath());
$this->assertIsString($logoContents);
$logoDataUrl = 'data:image/png;base64,'.base64_encode($logoContents);
$response = $this->authenticatedRequest('PUT', "/api/v1/tenant/events/{$event->slug}", [
'name' => 'Branding Event',
'event_date' => now()->addDays(5)->toDateString(),
'event_type_id' => $eventType->id,
'settings' => [
'branding' => [
'logo_data_url' => $logoDataUrl,
'logo' => [
'mode' => 'upload',
'value' => $logoDataUrl,
],
],
],
]);
$response->assertOk();
$event->refresh();
$logoPath = (string) data_get($event->settings, 'branding.logo_url');
$this->assertNotEmpty($logoPath);
Storage::disk('public')->assertExists($logoPath);
$this->assertSame($logoPath, data_get($event->settings, 'branding.logo.value'));
$this->assertNull(data_get($event->settings, 'branding.logo_data_url'));
}
public function test_upload_exceeds_package_limit_fails(): void public function test_upload_exceeds_package_limit_fails(): void
{ {
$tenant = $this->tenant; $tenant = $this->tenant;

View File

@@ -1,24 +0,0 @@
<?php
namespace Tests\Feature\Help;
use App\Services\Help\HelpSyncService;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class HelpSyncServiceTest extends TestCase
{
public function test_help_sync_writes_compiled_articles(): void
{
Storage::fake('local');
Config::set('help.disk', 'local');
$service = $this->app->make(HelpSyncService::class);
$result = $service->sync();
$this->assertNotEmpty($result);
Storage::disk('local')->assertExists('help/guest/en/articles.json');
Storage::disk('local')->assertExists('help/guest/de/articles.json');
}
}

View File

@@ -39,7 +39,6 @@ class PhotoboothConnectCodeTest extends TenantTestCase
{ {
$event = Event::factory()->for($this->tenant)->create([ $event = Event::factory()->for($this->tenant)->create([
'slug' => 'connect-code-redeem', 'slug' => 'connect-code-redeem',
'name' => 'Winterhochzeit',
]); ]);
EventPhotoboothSetting::factory() EventPhotoboothSetting::factory()
@@ -60,7 +59,6 @@ class PhotoboothConnectCodeTest extends TenantTestCase
]); ]);
$redeem->assertOk() $redeem->assertOk()
->assertJsonPath('data.event_name', 'Winterhochzeit')
->assertJsonPath('data.upload_url', fn ($value) => is_string($value) && $value !== '') ->assertJsonPath('data.upload_url', fn ($value) => is_string($value) && $value !== '')
->assertJsonPath('data.username', 'pbconnect') ->assertJsonPath('data.username', 'pbconnect')
->assertJsonPath('data.password', 'SECRET12'); ->assertJsonPath('data.password', 'SECRET12');

View File

@@ -1,28 +0,0 @@
<?php
namespace Tests\Feature\Photobooth;
use App\Mail\PhotoboothUploaderDownload;
use App\Models\Event;
use Illuminate\Support\Facades\Mail;
use PHPUnit\Framework\Attributes\Test;
use Tests\Feature\Tenant\TenantTestCase;
class PhotoboothUploaderDownloadEmailTest extends TenantTestCase
{
#[Test]
public function it_sends_the_photobooth_uploader_download_email(): void
{
Mail::fake();
$event = Event::factory()->for($this->tenant)->create([
'slug' => 'photobooth-email',
]);
$response = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/photobooth/uploader-email");
$response->assertOk();
Mail::assertQueued(PhotoboothUploaderDownload::class);
}
}