Compare commits
57 Commits
beads-sync
...
8634d16359
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8634d16359 | ||
|
|
81446b37c3 | ||
|
|
33e46b448d | ||
|
|
289ef70e53 | ||
|
|
d0559bf8c9 | ||
|
|
0ef4b32bf6 | ||
|
|
3612c97e86 | ||
|
|
c0510581c6 | ||
|
|
1ffd3e3b9d | ||
|
|
e05ee3b186 | ||
|
|
cf7b2e563a | ||
|
|
719afb6920 | ||
|
|
83c58358a1 | ||
|
|
2b888078a0 | ||
|
|
2f584162d6 | ||
|
|
0833ea6b36 | ||
|
|
5bdc15d399 | ||
|
|
693540f609 | ||
|
|
c0193c9581 | ||
|
|
03c7b20cae | ||
|
|
3a78c4f2c0 | ||
|
|
fa333deed9 | ||
|
|
a733df6221 | ||
|
|
5ee1baa7e2 | ||
|
|
2f19752199 | ||
|
|
7dd7ec14a4 | ||
|
|
d9568be579 | ||
|
|
9cf6e9d94d | ||
|
|
a23ce0c86f | ||
|
|
9efea136bd | ||
|
|
7a6f489b8b | ||
|
|
cc11e024f0 | ||
|
|
2089251a92 | ||
|
|
53094b8d36 | ||
|
|
0c33c1ddc1 | ||
|
|
ce0b7c951a | ||
|
|
fbbbbdac4c | ||
|
|
94d0713ec0 | ||
|
|
3e36354916 | ||
|
|
24a1319cc2 | ||
|
|
b1250c6246 | ||
|
|
fd7a3c846a | ||
|
|
1ca7545f86 | ||
|
|
9f4a202d2b | ||
|
|
fe0525e678 | ||
|
|
d62efdb55c | ||
|
|
be722f6e37 | ||
|
|
898ac9ff0e | ||
|
|
c8d1ac7971 | ||
|
|
3ee23f3a66 | ||
|
|
993c351832 | ||
|
|
2444a62a4d | ||
|
|
e52720a3cb | ||
|
|
93bed358ba | ||
|
|
a16bd9c498 | ||
|
|
e32b1fa45a | ||
|
|
6edc890e01 |
6
.beads/.gitignore
vendored
6
.beads/.gitignore
vendored
@@ -11,6 +11,12 @@ 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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -59,4 +59,4 @@
|
|||||||
# - linear.url
|
# - linear.url
|
||||||
# - linear.api-key
|
# - linear.api-key
|
||||||
# - github.org
|
# - github.org
|
||||||
# - github.repo
|
# - github.repo
|
||||||
@@ -17,12 +17,10 @@
|
|||||||
{"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"}]}
|
||||||
@@ -38,7 +36,6 @@
|
|||||||
{"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)"}
|
||||||
@@ -51,7 +48,6 @@
|
|||||||
{"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)"}
|
||||||
@@ -78,7 +74,6 @@
|
|||||||
{"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"}
|
||||||
@@ -140,7 +135,6 @@
|
|||||||
{"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"}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
fotospiel-app-29r
|
fotospiel-app-6yz
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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;
|
||||||
|
|
||||||
@@ -33,7 +34,8 @@ class PhotoboothConnectController extends Controller
|
|||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'data' => [
|
'data' => [
|
||||||
'upload_url' => route('api.v1.photobooth.sparkbooth.upload'),
|
'event_name' => $this->resolveEventName($event),
|
||||||
|
'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(),
|
||||||
@@ -42,4 +44,27 @@ 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -349,9 +349,14 @@ 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';
|
||||||
@@ -442,6 +447,68 @@ 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');
|
||||||
|
|||||||
@@ -3,12 +3,17 @@
|
|||||||
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
|
||||||
{
|
{
|
||||||
@@ -69,6 +74,39 @@ 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([
|
||||||
@@ -92,4 +130,30 @@ 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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.sparkbooth.upload') : null,
|
'upload_url' => $isSparkbooth ? route('api.v1.photobooth.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.sparkbooth.upload'),
|
'upload_url' => route('api.v1.photobooth.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,
|
||||||
],
|
],
|
||||||
|
|||||||
50
app/Mail/PhotoboothUploaderDownload.php
Normal file
50
app/Mail/PhotoboothUploaderDownload.php
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<?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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -77,6 +77,8 @@ 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();
|
||||||
|
|||||||
@@ -6,5 +6,85 @@
|
|||||||
|
|
||||||
<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>
|
||||||
|
|||||||
BIN
clients/photobooth-uploader/PhotoboothUploader/Assets/app.ico
Normal file
BIN
clients/photobooth-uploader/PhotoboothUploader/Assets/app.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.1 KiB |
BIN
clients/photobooth-uploader/PhotoboothUploader/Assets/logo.png
Normal file
BIN
clients/photobooth-uploader/PhotoboothUploader/Assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 67 B |
@@ -4,13 +4,27 @@
|
|||||||
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="520" Height="360"
|
Width="560" Height="420"
|
||||||
Title="Fotospiel Photobooth Uploader">
|
MinWidth="520" MinHeight="400"
|
||||||
<Grid Margin="24" ColumnDefinitions="*,8,*">
|
Title="Die Fotospiel.App - Photobooth Uploader">
|
||||||
<StackPanel Grid.Column="0" Spacing="12" MaxWidth="420">
|
<Grid Margin="24,32,24,24" RowDefinitions="Auto,*">
|
||||||
<TextBlock Text="Fotospiel Photobooth Uploader" FontSize="20" FontWeight="SemiBold" />
|
<StackPanel Grid.Row="0" Orientation="Horizontal" Spacing="12" VerticalAlignment="Center">
|
||||||
|
<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>
|
||||||
|
|
||||||
<Border Background="#1F000000" Padding="12" CornerRadius="8">
|
<Grid Grid.Row="1" ColumnDefinitions="*,16,*">
|
||||||
|
<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" />
|
||||||
@@ -19,45 +33,128 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<TextBlock Text="Gib den 6-stelligen Verbindungscode ein." TextWrapping="Wrap" />
|
<Border Padding="14" Classes="card">
|
||||||
<TextBox x:Name="CodeBox" MaxLength="6" Watermark="123456" />
|
<StackPanel Spacing="10">
|
||||||
<Button x:Name="ConnectButton" Content="Verbinden" Click="ConnectButton_Click" />
|
<TextBlock Text="Verbindungscode" FontWeight="SemiBold" />
|
||||||
|
<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>
|
||||||
|
|
||||||
<StackPanel Spacing="6">
|
<Border Padding="14" Classes="card">
|
||||||
<TextBlock Text="Upload-Ordner" FontWeight="SemiBold" />
|
<StackPanel Spacing="8">
|
||||||
<TextBlock x:Name="FolderText" Text="Noch nicht ausgewählt." TextWrapping="Wrap" />
|
<TextBlock Text="Upload-Ordner" FontWeight="SemiBold" />
|
||||||
<Button x:Name="PickFolderButton" Content="Ordner auswählen" Click="PickFolderButton_Click" IsEnabled="False" />
|
<TextBlock x:Name="FolderText" Text="Noch nicht ausgewählt." TextWrapping="Wrap" Classes="subtitle" />
|
||||||
</StackPanel>
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
|
<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 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="12" MaxWidth="380">
|
<StackPanel Grid.Column="2" Spacing="16" MaxWidth="380" Margin="0,6,0,0">
|
||||||
<Border Background="#1F000000" Padding="12" CornerRadius="8">
|
<Border Padding="14" Classes="card accent">
|
||||||
<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>
|
||||||
|
|
||||||
<StackPanel Spacing="6">
|
<Border Padding="14" Classes="card">
|
||||||
<TextBlock Text="Letzte Uploads" FontWeight="SemiBold" />
|
<StackPanel Spacing="6">
|
||||||
<ItemsControl x:Name="RecentUploadsList" ItemsSource="{Binding RecentUploads}">
|
<TextBlock Text="Details" FontWeight="SemiBold" />
|
||||||
<ItemsControl.ItemTemplate>
|
<TextBlock x:Name="EventNameText" Text="Event: —" TextWrapping="Wrap" />
|
||||||
<DataTemplate>
|
<TextBlock x:Name="BaseUrlText" Text="Basis-URL: —" TextWrapping="Wrap" />
|
||||||
<Border Background="#14000000" Padding="8" CornerRadius="6" Margin="0,0,0,6">
|
<TextBlock x:Name="VersionText" Text="App-Version: —" />
|
||||||
<Grid ColumnDefinitions="*,Auto" RowDefinitions="Auto,Auto">
|
<TextBlock x:Name="ConnectExpiryText" Text="Verbindungscode: —" TextWrapping="Wrap" />
|
||||||
<TextBlock Grid.Column="0" Grid.Row="0" Text="{Binding FileName}" />
|
<TextBlock x:Name="FolderHealthText" Text="Ordner: —" TextWrapping="Wrap" />
|
||||||
<TextBlock Grid.Column="1" Grid.Row="0" Text="{Binding StatusLabel}" />
|
<TextBlock x:Name="DiskFreeText" Text="Freier Speicher: —" TextWrapping="Wrap" />
|
||||||
<TextBlock Grid.Column="0" Grid.Row="1" Text="{Binding UpdatedLabel}" Opacity="0.7" FontSize="11" />
|
<TextBlock x:Name="LastSeenText" Text="Letzte Datei: —" TextWrapping="Wrap" />
|
||||||
</Grid>
|
<TextBlock x:Name="LastErrorText" Text="Letzter Fehler: —" TextWrapping="Wrap" />
|
||||||
</Border>
|
<Button x:Name="LogCopyButton" Content="Log kopieren" Click="LogCopyButton_Click" Classes="secondary" />
|
||||||
</DataTemplate>
|
</StackPanel>
|
||||||
</ItemsControl.ItemTemplate>
|
</Border>
|
||||||
</ItemsControl>
|
|
||||||
<Button x:Name="RetryFailedButton" Content="Fehlgeschlagene erneut senden" Click="RetryFailedButton_Click" IsEnabled="False" />
|
<Border Padding="14" Classes="card">
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
<TextBlock Text="Letzte Uploads" FontWeight="SemiBold" />
|
||||||
|
<ItemsControl x:Name="RecentUploadsList" ItemsSource="{Binding RecentUploads}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<Border Background="#14FFFFFF" Padding="10" CornerRadius="8" Margin="0,0,0,8">
|
||||||
|
<Grid ColumnDefinitions="*,Auto" RowDefinitions="Auto,Auto">
|
||||||
|
<TextBlock Grid.Column="0" Grid.Row="0" Text="{Binding FileName}" />
|
||||||
|
<TextBlock Grid.Column="1" Grid.Row="0" Text="{Binding StatusLabel}" />
|
||||||
|
<TextBlock Grid.Column="0" Grid.Row="1" Text="{Binding UpdatedLabel}" Opacity="0.7" FontSize="11" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
|
<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>
|
||||||
|
</Border>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</StackPanel>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Window>
|
</Window>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,9 @@ 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; }
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
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";
|
||||||
|
}
|
||||||
@@ -1,11 +1,29 @@
|
|||||||
|
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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
<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>
|
||||||
|
|
||||||
@@ -18,4 +19,10 @@
|
|||||||
<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>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
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;
|
||||||
@@ -10,41 +12,111 @@ 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)
|
public PhotoboothConnectClient(string baseUrl, string userAgent)
|
||||||
{
|
{
|
||||||
_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 response = await _httpClient.PostAsJsonAsync("/api/v1/photobooth/connect", new { code }, cancellationToken);
|
var request = new { code };
|
||||||
var payload = await response.Content.ReadFromJsonAsync<PhotoboothConnectResponse>(_jsonOptions, cancellationToken);
|
|
||||||
|
|
||||||
if (payload is null)
|
for (var attempt = 0; attempt <= MaxRetries; attempt++)
|
||||||
{
|
{
|
||||||
return new PhotoboothConnectResponse
|
try
|
||||||
{
|
{
|
||||||
Message = response.ReasonPhrase ?? "Verbindung fehlgeschlagen.",
|
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.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
return Fail("Verbindung fehlgeschlagen.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<PhotoboothConnectResponse?> ReadPayloadAsync(HttpResponseMessage response, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (response.Content.Headers.ContentLength == 0)
|
||||||
{
|
{
|
||||||
return new PhotoboothConnectResponse
|
return null;
|
||||||
{
|
|
||||||
Message = payload.Message ?? "Verbindung fehlgeschlagen.",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return payload;
|
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
|
||||||
|
{
|
||||||
|
Message = message,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ public sealed class SettingsStore
|
|||||||
};
|
};
|
||||||
|
|
||||||
public string SettingsPath { get; }
|
public string SettingsPath { get; }
|
||||||
|
public string LogPath { get; }
|
||||||
|
|
||||||
public SettingsStore()
|
public SettingsStore()
|
||||||
{
|
{
|
||||||
@@ -24,6 +25,7 @@ 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()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
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;
|
||||||
@@ -12,21 +13,38 @@ 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> onFailure)
|
Action<string, string> onFailure)
|
||||||
{
|
{
|
||||||
Stop();
|
Stop();
|
||||||
|
|
||||||
_cts = new CancellationTokenSource();
|
_cts = new CancellationTokenSource();
|
||||||
_ = Task.Run(() => WorkerAsync(settings, onQueued, onUploading, onSuccess, onFailure, _cts.Token));
|
var workerCount = GetWorkerCount(settings);
|
||||||
|
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()
|
||||||
@@ -34,6 +52,7 @@ 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)
|
||||||
@@ -52,7 +71,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> onFailure,
|
Action<string, string> onFailure,
|
||||||
CancellationToken token)
|
CancellationToken token)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(settings.UploadUrl))
|
if (string.IsNullOrWhiteSpace(settings.UploadUrl))
|
||||||
@@ -61,6 +80,9 @@ 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))
|
||||||
{
|
{
|
||||||
@@ -69,58 +91,72 @@ public sealed class UploadService
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
onUploading(path);
|
onUploading(path);
|
||||||
await WaitForFileReadyAsync(path, token);
|
var error = await UploadWithRetryAsync(client, settings, path, token);
|
||||||
await UploadAsync(client, settings, path, token);
|
if (error is null)
|
||||||
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 WaitForFileReadyAsync(string path, CancellationToken token)
|
private static async Task<string?> UploadWithRetryAsync(
|
||||||
|
HttpClient client,
|
||||||
|
PhotoboothSettings settings,
|
||||||
|
string path,
|
||||||
|
CancellationToken token)
|
||||||
{
|
{
|
||||||
var lastSize = -1L;
|
for (var attempt = 0; attempt <= MaxRetries; attempt++)
|
||||||
|
|
||||||
for (var attempts = 0; attempts < 10; attempts++)
|
|
||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
var attemptError = await UploadOnceAsync(client, settings, path, token);
|
||||||
|
if (attemptError.Success)
|
||||||
if (!File.Exists(path))
|
|
||||||
{
|
{
|
||||||
await Task.Delay(500, token);
|
return null;
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var info = new FileInfo(path);
|
if (!attemptError.Retryable || attempt >= MaxRetries)
|
||||||
var size = info.Length;
|
|
||||||
|
|
||||||
if (size > 0 && size == lastSize)
|
|
||||||
{
|
{
|
||||||
return;
|
return attemptError.Error ?? "Upload fehlgeschlagen.";
|
||||||
}
|
}
|
||||||
|
|
||||||
lastSize = size;
|
await Task.Delay(GetRetryDelay(attempt), token);
|
||||||
await Task.Delay(700, token);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return "Upload fehlgeschlagen.";
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task UploadAsync(HttpClient client, PhotoboothSettings settings, string path, CancellationToken token)
|
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;
|
return UploadAttempt.Fail("Datei nicht gefunden.", retryable: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
using var content = new MultipartFormDataContent();
|
using var content = new MultipartFormDataContent();
|
||||||
@@ -145,8 +181,61 @@ 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));
|
||||||
|
|
||||||
var response = await client.PostAsync(settings.UploadUrl, content, token);
|
try
|
||||||
response.EnsureSuccessStatusCode();
|
{
|
||||||
|
var response = await client.PostAsync(settings.UploadUrl, content, token);
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -158,4 +247,51 @@ 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,8 @@
|
|||||||
"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
154
composer.lock
generated
@@ -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": "c1a772e5fe6f8d5c92fdbbea232f9f78",
|
"content-hash": "5e1d60e650853d6113b01e1adaf49d65",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "anourvalar/eloquent-serialize",
|
"name": "anourvalar/eloquent-serialize",
|
||||||
@@ -10043,6 +10043,82 @@
|
|||||||
],
|
],
|
||||||
"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",
|
||||||
@@ -12852,82 +12928,6 @@
|
|||||||
],
|
],
|
||||||
"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",
|
||||||
|
|||||||
@@ -106,6 +106,24 @@ 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:
|
||||||
@@ -340,6 +358,7 @@ 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:
|
||||||
|
|||||||
@@ -53,6 +53,23 @@ 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
|
||||||
@@ -120,6 +137,7 @@ 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 "$@"
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/sparkbooth/upload`
|
- Endpoint: `POST /api/v1/photobooth/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/sparkbooth/upload \
|
curl -X POST https://app.example.com/api/v1/photobooth/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/sparkbooth/upload \
|
|||||||
Example cURL (request XML response):
|
Example cURL (request XML response):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST https://app.example.com/api/v1/photobooth/sparkbooth/upload \
|
curl -X POST https://app.example.com/api/v1/photobooth/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" \
|
||||||
|
|||||||
@@ -65,6 +65,25 @@ 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',
|
||||||
|
|||||||
@@ -65,6 +65,25 @@ 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',
|
||||||
|
|||||||
@@ -121,6 +121,7 @@
|
|||||||
--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;
|
||||||
@@ -511,6 +512,21 @@ 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;
|
||||||
|
|||||||
@@ -218,6 +218,11 @@ 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;
|
||||||
@@ -2041,6 +2046,35 @@ 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';
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ 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');
|
||||||
|
|||||||
@@ -1168,15 +1168,23 @@
|
|||||||
"mode": "Modus"
|
"mode": "Modus"
|
||||||
},
|
},
|
||||||
"mode": {
|
"mode": {
|
||||||
"title": "Photobooth-Typ auswählen",
|
"title": "Uploader-Verbindung",
|
||||||
"description": "Wähle zwischen klassischem FTP und Sparkbooth HTTP-Upload. Umschalten generiert neue Zugangsdaten.",
|
"description": "Nutze die Fotospiel-Uploader-App für HTTP-Uploads. Beim Zurücksetzen werden neue Zugangsdaten generiert.",
|
||||||
"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": "FTP-Zugangsdaten",
|
"heading": "Zugangsdaten für die Uploader-App",
|
||||||
"description": "Teile die Zugangsdaten mit eurer Photobooth-Software.",
|
"description": "Teile die Zugangsdaten mit der Fotospiel-Uploader-App.",
|
||||||
"sparkboothTitle": "Sparkbooth-Upload (HTTP)",
|
"uploaderTitle": "Uploader-App (HTTP)",
|
||||||
"sparkboothDescription": "Trage URL, Benutzername und Passwort in Sparkbooth ein. Antworten sind JSON (optional XML).",
|
"uploaderDescription": "Trage URL, Benutzername und Passwort in die Fotospiel-Uploader-App 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",
|
||||||
@@ -1185,6 +1193,44 @@
|
|||||||
"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",
|
||||||
@@ -1202,9 +1248,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 den FTP-Account für eure Photobooth-Software.",
|
"enableCopy": "Aktiviere die Verbindung für die Uploader-App.",
|
||||||
"share": "Zugang teilen",
|
"share": "Zugang teilen",
|
||||||
"shareCopy": "Übergib Host, Benutzer & Passwort an den Betreiber.",
|
"shareCopy": "Übergib URL, Benutzername & Passwort an den Betreiber.",
|
||||||
"monitor": "Uploads beobachten",
|
"monitor": "Uploads beobachten",
|
||||||
"monitorCopy": "Verfolge Uploads & Limits direkt im Dashboard."
|
"monitorCopy": "Verfolge Uploads & Limits direkt im Dashboard."
|
||||||
},
|
},
|
||||||
@@ -1431,7 +1477,7 @@
|
|||||||
"photobooth": {
|
"photobooth": {
|
||||||
"title": "Fotobox-Uploads",
|
"title": "Fotobox-Uploads",
|
||||||
"titleForEvent": "Fotobox-Uploads verwalten",
|
"titleForEvent": "Fotobox-Uploads verwalten",
|
||||||
"subtitle": "Erstelle FTP-Zugänge für Photobooth-Software und behalte Limits im Blick.",
|
"subtitle": "Erstelle Zugang für die Uploader-App und behalte Limits im Blick.",
|
||||||
"actions": {
|
"actions": {
|
||||||
"backToEvent": "Zur Detailansicht",
|
"backToEvent": "Zur Detailansicht",
|
||||||
"allEvents": "Zur Eventliste"
|
"allEvents": "Zur Eventliste"
|
||||||
@@ -1872,23 +1918,63 @@
|
|||||||
"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": "Lade ein Logo hoch, um Einladungen und QR-Poster zu branden.",
|
"logoHint": "Logo hochladen oder Emoji für den Guest-Header nutzen.",
|
||||||
"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.",
|
||||||
@@ -2346,7 +2432,7 @@
|
|||||||
"mobileProfile": {
|
"mobileProfile": {
|
||||||
"title": "Profil",
|
"title": "Profil",
|
||||||
"settings": "Einstellungen",
|
"settings": "Einstellungen",
|
||||||
"account": "Account & Sicherheit",
|
"account": "Account bearbeiten",
|
||||||
"language": "Sprache",
|
"language": "Sprache",
|
||||||
"languageDe": "Deutsch",
|
"languageDe": "Deutsch",
|
||||||
"languageEn": "Englisch",
|
"languageEn": "Englisch",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -881,15 +881,23 @@
|
|||||||
"mode": "Mode"
|
"mode": "Mode"
|
||||||
},
|
},
|
||||||
"mode": {
|
"mode": {
|
||||||
"title": "Choose your photobooth type",
|
"title": "Uploader connection",
|
||||||
"description": "Pick classic FTP or Sparkbooth HTTP upload. Switching regenerates credentials.",
|
"description": "Use the Fotospiel uploader app for live HTTP uploads. Rotating access 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": "FTP credentials",
|
"heading": "Uploader app credentials",
|
||||||
"description": "Share these credentials with your photobooth software.",
|
"description": "Share these credentials with the Fotospiel uploader app.",
|
||||||
"sparkboothTitle": "Sparkbooth upload (HTTP)",
|
"uploaderTitle": "Uploader App (HTTP)",
|
||||||
"sparkboothDescription": "Enter URL, username and password in Sparkbooth. Responses default to JSON (XML optional).",
|
"uploaderDescription": "Enter URL, username and password in the Fotospiel uploader app. 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",
|
||||||
@@ -898,6 +906,44 @@
|
|||||||
"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",
|
||||||
@@ -915,9 +961,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 FTP account in your photobooth software.",
|
"enableCopy": "Enable the uploader app connection for this event.",
|
||||||
"share": "Share credentials",
|
"share": "Share credentials",
|
||||||
"shareCopy": "Hand over host, user, and password to the operator.",
|
"shareCopy": "Share URL, username, and password with the operator.",
|
||||||
"monitor": "Monitor uploads",
|
"monitor": "Monitor uploads",
|
||||||
"monitorCopy": "Watch uploads & limits in the dashboard."
|
"monitorCopy": "Watch uploads & limits in the dashboard."
|
||||||
},
|
},
|
||||||
@@ -1424,11 +1470,11 @@
|
|||||||
"submit": "Save emotion"
|
"submit": "Save emotion"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"management": {
|
"management": {
|
||||||
"photobooth": {
|
"photobooth": {
|
||||||
"title": "Photobooth uploads",
|
"title": "Photobooth uploads",
|
||||||
"titleForEvent": "Manage photobooth uploads",
|
"titleForEvent": "Manage photobooth uploads",
|
||||||
"subtitle": "Create FTP access for photobooth software and keep limits in sight.",
|
"subtitle": "Create uploader access for photobooth apps 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"
|
||||||
@@ -1876,23 +1922,63 @@
|
|||||||
"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 to brand guest invites and QR posters.",
|
"logoHint": "Upload a logo or use an emoji for the guest header.",
|
||||||
"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.",
|
||||||
@@ -2350,7 +2436,7 @@
|
|||||||
"mobileProfile": {
|
"mobileProfile": {
|
||||||
"title": "Profile",
|
"title": "Profile",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"account": "Account & security",
|
"account": "Edit account",
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
"languageDe": "Deutsch",
|
"languageDe": "Deutsch",
|
||||||
"languageEn": "English",
|
"languageEn": "English",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"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",
|
||||||
|
|||||||
123
resources/js/admin/lib/__tests__/brandingForm.test.ts
Normal file
123
resources/js/admin/lib/__tests__/brandingForm.test.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
149
resources/js/admin/lib/brandingForm.ts
Normal file
149
resources/js/admin/lib/brandingForm.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -48,6 +48,29 @@ 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;
|
||||||
|
|||||||
@@ -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, TenantFont, WatermarkSettings, trackOnboarding } from '../api';
|
import { TenantEvent, getEvent, updateEvent, getTenantFonts, getTenantSettings, 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,13 +16,45 @@ 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';
|
||||||
|
|
||||||
type BrandingForm = {
|
const BRANDING_FORM_DEFAULTS = {
|
||||||
primary: string;
|
primary: ADMIN_COLORS.primary,
|
||||||
accent: string;
|
accent: ADMIN_COLORS.accent,
|
||||||
headingFont: string;
|
background: '#ffffff',
|
||||||
bodyFont: string;
|
surface: '#ffffff',
|
||||||
logoDataUrl: string;
|
mode: 'auto' as const,
|
||||||
|
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 =
|
||||||
@@ -58,13 +90,7 @@ export default function MobileBrandingPage() {
|
|||||||
const { textStrong, muted, subtle, border, primary, accentSoft, danger, surfaceMuted, surface } = useAdminTheme();
|
const { textStrong, muted, subtle, border, primary, accentSoft, danger, surfaceMuted, surface } = useAdminTheme();
|
||||||
|
|
||||||
const [event, setEvent] = React.useState<TenantEvent | null>(null);
|
const [event, setEvent] = React.useState<TenantEvent | null>(null);
|
||||||
const [form, setForm] = React.useState<BrandingForm>({
|
const [form, setForm] = React.useState<BrandingFormValues>(BRANDING_FORM_BASE);
|
||||||
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: '',
|
||||||
@@ -86,6 +112,8 @@ 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;
|
||||||
@@ -94,7 +122,7 @@ export default function MobileBrandingPage() {
|
|||||||
try {
|
try {
|
||||||
const data = await getEvent(slug);
|
const data = await getEvent(slug);
|
||||||
setEvent(data);
|
setEvent(data);
|
||||||
setForm(extractBranding(data));
|
setForm(extractBrandingForm(data.settings ?? {}, BRANDING_FORM_DEFAULTS));
|
||||||
setWatermarkForm(extractWatermark(data));
|
setWatermarkForm(extractWatermark(data));
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -119,12 +147,42 @@ 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 = form.headingFont || 'Fraunces';
|
const previewHeadingFont = previewForm.headingFont || 'Fraunces';
|
||||||
const previewBodyFont = form.bodyFont || 'Manrope';
|
const previewBodyFont = previewForm.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;
|
||||||
@@ -155,18 +213,38 @@ 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'
|
||||||
@@ -174,16 +252,31 @@ 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) {
|
||||||
@@ -217,7 +310,7 @@ export default function MobileBrandingPage() {
|
|||||||
|
|
||||||
function handleReset() {
|
function handleReset() {
|
||||||
if (event) {
|
if (event) {
|
||||||
setForm(extractBranding(event));
|
setForm(extractBrandingForm(event.settings ?? {}, BRANDING_FORM_DEFAULTS));
|
||||||
setWatermarkForm(extractWatermark(event));
|
setWatermarkForm(extractWatermark(event));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -435,25 +528,143 @@ 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={surfaceMuted} padding="$3" space="$2" alignItems="center">
|
<YStack borderRadius={16} borderWidth={1} borderColor={border} backgroundColor={previewForm.background} padding="$3" space="$2" alignItems="center">
|
||||||
<YStack width="100%" borderRadius={12} backgroundColor={surface} borderWidth={1} borderColor={border} overflow="hidden">
|
<YStack width="100%" borderRadius={12} backgroundColor={previewForm.surface} borderWidth={1} borderColor={border} overflow="hidden">
|
||||||
<YStack backgroundColor={form.primary} height={64} />
|
<YStack
|
||||||
<YStack padding="$3" space="$1.5">
|
height={64}
|
||||||
<Text fontSize="$md" fontWeight="800" color={textStrong} style={{ fontFamily: previewHeadingFont }}>
|
style={{ background: `linear-gradient(135deg, ${previewForm.primary}, ${previewForm.accent})` }}
|
||||||
{previewTitle}
|
/>
|
||||||
</Text>
|
<YStack padding="$3" space="$2">
|
||||||
<Text fontSize="$sm" color={muted} style={{ fontFamily: previewBodyFont }}>
|
<XStack
|
||||||
{t('events.branding.previewSubtitle', 'Aktuelle Farben & Schriften')}
|
alignItems="center"
|
||||||
</Text>
|
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}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
color={previewSurfaceText}
|
||||||
|
style={{ fontFamily: previewBodyFont, opacity: 0.7, fontSize: 13 * previewScale }}
|
||||||
|
>
|
||||||
|
{t('events.branding.previewSubtitle', 'Aktuelle Farben & Schriften')}
|
||||||
|
</Text>
|
||||||
|
</YStack>
|
||||||
|
</XStack>
|
||||||
<XStack space="$2" marginTop="$1">
|
<XStack space="$2" marginTop="$1">
|
||||||
<ColorSwatch color={form.primary} label={t('events.branding.primary', 'Primary')} />
|
<ColorSwatch color={previewForm.primary} label={t('events.branding.primary', 'Primary')} />
|
||||||
<ColorSwatch color={form.accent} label={t('events.branding.accent', 'Accent')} />
|
<ColorSwatch color={previewForm.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')}
|
||||||
@@ -462,11 +673,25 @@ 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>
|
||||||
|
|
||||||
@@ -483,6 +708,7 @@ 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')}
|
||||||
@@ -493,105 +719,259 @@ 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>
|
||||||
<YStack
|
<Text fontSize="$sm" color={muted}>
|
||||||
borderRadius={14}
|
{t('events.branding.logoHint', 'Upload a logo or use an emoji for the guest header.')}
|
||||||
borderWidth={1}
|
</Text>
|
||||||
borderColor={border}
|
<XStack space="$2">
|
||||||
backgroundColor={surfaceMuted}
|
<ModeButton
|
||||||
padding="$3"
|
label={t('events.branding.logoModeUpload', 'Upload')}
|
||||||
alignItems="center"
|
active={form.logoMode === 'upload'}
|
||||||
justifyContent="center"
|
onPress={() => setForm((prev) => ({ ...prev, logoMode: 'upload' }))}
|
||||||
space="$2"
|
disabled={brandingDisabled}
|
||||||
>
|
/>
|
||||||
{form.logoDataUrl ? (
|
<ModeButton
|
||||||
<>
|
label={t('events.branding.logoModeEmoticon', 'Emoticon')}
|
||||||
<img
|
active={form.logoMode === 'emoticon'}
|
||||||
src={form.logoDataUrl}
|
onPress={() => setForm((prev) => ({ ...prev, logoMode: 'emoticon' }))}
|
||||||
alt={t('events.branding.logoAlt', 'Logo')}
|
disabled={brandingDisabled}
|
||||||
style={{ maxHeight: 80, maxWidth: '100%', objectFit: 'contain' }}
|
/>
|
||||||
/>
|
</XStack>
|
||||||
<XStack space="$2">
|
|
||||||
<CTAButton
|
{form.logoMode === 'emoticon' ? (
|
||||||
label={t('events.branding.replaceLogo', 'Replace logo')}
|
<InputField
|
||||||
onPress={() => document.getElementById('branding-logo-input')?.click()}
|
label={t('events.branding.logoValue', 'Emoticon')}
|
||||||
|
value={form.logoValue}
|
||||||
|
placeholder={t('events.branding.logoValuePlaceholder', '🎉')}
|
||||||
|
onChange={(value) => setForm((prev) => ({ ...prev, logoValue: value }))}
|
||||||
|
disabled={brandingDisabled}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<YStack
|
||||||
|
borderRadius={14}
|
||||||
|
borderWidth={1}
|
||||||
|
borderColor={border}
|
||||||
|
backgroundColor={surfaceMuted}
|
||||||
|
padding="$3"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
space="$2"
|
||||||
|
>
|
||||||
|
{form.logoDataUrl ? (
|
||||||
|
<>
|
||||||
|
<img
|
||||||
|
src={form.logoDataUrl}
|
||||||
|
alt={t('events.branding.logoAlt', 'Logo')}
|
||||||
|
style={{ maxHeight: 80, maxWidth: '100%', objectFit: 'contain' }}
|
||||||
/>
|
/>
|
||||||
<Pressable onPress={() => setForm((prev) => ({ ...prev, logoDataUrl: '' }))}>
|
<XStack space="$2">
|
||||||
|
<CTAButton
|
||||||
|
label={t('events.branding.replaceLogo', 'Replace logo')}
|
||||||
|
onPress={() => document.getElementById('branding-logo-input')?.click()}
|
||||||
|
disabled={brandingDisabled}
|
||||||
|
/>
|
||||||
|
<Pressable
|
||||||
|
disabled={brandingDisabled}
|
||||||
|
onPress={() => setForm((prev) => ({ ...prev, logoDataUrl: '' }))}
|
||||||
|
>
|
||||||
|
<XStack
|
||||||
|
alignItems="center"
|
||||||
|
space="$1.5"
|
||||||
|
paddingHorizontal="$3"
|
||||||
|
paddingVertical="$2"
|
||||||
|
borderRadius={12}
|
||||||
|
borderWidth={1}
|
||||||
|
borderColor={border}
|
||||||
|
>
|
||||||
|
<Trash2 size={16} color={danger} />
|
||||||
|
<Text fontSize="$sm" color={danger} fontWeight="700">
|
||||||
|
{t('events.branding.removeLogo', 'Remove')}
|
||||||
|
</Text>
|
||||||
|
</XStack>
|
||||||
|
</Pressable>
|
||||||
|
</XStack>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ImageIcon size={28} color={subtle} />
|
||||||
|
<Pressable
|
||||||
|
disabled={brandingDisabled}
|
||||||
|
onPress={() => document.getElementById('branding-logo-input')?.click()}
|
||||||
|
>
|
||||||
<XStack
|
<XStack
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
space="$1.5"
|
space="$2"
|
||||||
paddingHorizontal="$3"
|
paddingHorizontal="$3.5"
|
||||||
paddingVertical="$2"
|
paddingVertical="$2.5"
|
||||||
borderRadius={12}
|
borderRadius={12}
|
||||||
borderWidth={1}
|
borderWidth={1}
|
||||||
borderColor={border}
|
borderColor={border}
|
||||||
|
backgroundColor={surface}
|
||||||
>
|
>
|
||||||
<Trash2 size={16} color={danger} />
|
<UploadCloud size={18} color={primary} />
|
||||||
<Text fontSize="$sm" color={danger} fontWeight="700">
|
<Text fontSize="$sm" color={primary} fontWeight="700">
|
||||||
{t('events.branding.removeLogo', 'Remove')}
|
{t('events.branding.uploadLogo', 'Upload logo (max. 1 MB)')}
|
||||||
</Text>
|
</Text>
|
||||||
</XStack>
|
</XStack>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</XStack>
|
</>
|
||||||
</>
|
)}
|
||||||
) : (
|
<input
|
||||||
<>
|
id="branding-logo-input"
|
||||||
<ImageIcon size={28} color={subtle} />
|
type="file"
|
||||||
<Text fontSize="$sm" color={muted} textAlign="center">
|
accept="image/*"
|
||||||
{t('events.branding.logoHint', 'Upload a logo to brand guest invites and QR posters.')}
|
style={{ display: 'none' }}
|
||||||
</Text>
|
disabled={brandingDisabled}
|
||||||
<Pressable onPress={() => document.getElementById('branding-logo-input')?.click()}>
|
onChange={(event) => {
|
||||||
<XStack
|
const file = event.target.files?.[0];
|
||||||
alignItems="center"
|
if (!file) return;
|
||||||
space="$2"
|
if (file.size > 1024 * 1024) {
|
||||||
paddingHorizontal="$3.5"
|
setError(t('events.branding.logoTooLarge', 'Logo must be under 1 MB.'));
|
||||||
paddingVertical="$2.5"
|
return;
|
||||||
borderRadius={12}
|
}
|
||||||
borderWidth={1}
|
const reader = new FileReader();
|
||||||
borderColor={border}
|
reader.onload = () => {
|
||||||
backgroundColor={surface}
|
const nextLogo =
|
||||||
>
|
typeof reader.result === 'string'
|
||||||
<UploadCloud size={18} color={primary} />
|
? reader.result
|
||||||
<Text fontSize="$sm" color={primary} fontWeight="700">
|
: typeof reader.result === 'object' && reader.result !== null
|
||||||
{t('events.branding.uploadLogo', 'Upload logo (max. 1 MB)')}
|
? String(reader.result)
|
||||||
</Text>
|
: '';
|
||||||
</XStack>
|
setForm((prev) => ({ ...prev, logoDataUrl: nextLogo }));
|
||||||
</Pressable>
|
setError(null);
|
||||||
</>
|
};
|
||||||
)}
|
reader.readAsDataURL(file);
|
||||||
<input
|
}}
|
||||||
id="branding-logo-input"
|
/>
|
||||||
type="file"
|
</YStack>
|
||||||
accept="image/*"
|
)}
|
||||||
style={{ display: 'none' }}
|
|
||||||
onChange={(event) => {
|
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||||
const file = event.target.files?.[0];
|
{t('events.branding.logoPosition', 'Position')}
|
||||||
if (!file) return;
|
</Text>
|
||||||
if (file.size > 1024 * 1024) {
|
<XStack space="$2">
|
||||||
setError(t('events.branding.logoTooLarge', 'Logo must be under 1 MB.'));
|
<ModeButton
|
||||||
return;
|
label={t('events.branding.positionLeft', 'Left')}
|
||||||
}
|
active={form.logoPosition === 'left'}
|
||||||
const reader = new FileReader();
|
onPress={() => setForm((prev) => ({ ...prev, logoPosition: 'left' }))}
|
||||||
reader.onload = () => {
|
disabled={brandingDisabled}
|
||||||
const nextLogo =
|
|
||||||
typeof reader.result === 'string'
|
|
||||||
? reader.result
|
|
||||||
: typeof reader.result === 'object' && reader.result !== null
|
|
||||||
? String(reader.result)
|
|
||||||
: '';
|
|
||||||
setForm((prev) => ({ ...prev, logoDataUrl: nextLogo }));
|
|
||||||
setError(null);
|
|
||||||
};
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</YStack>
|
<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>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -671,26 +1051,6 @@ 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>) ?? {};
|
||||||
@@ -762,10 +1122,47 @@ function renderName(name: TenantEvent['name']): string {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function ColorField({ label, value, onChange }: { label: string; value: string; onChange: (next: string) => void }) {
|
function normalizeBrandingPath(value: string): string {
|
||||||
|
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">
|
<YStack space="$2" opacity={disabled ? 0.6 : 1}>
|
||||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -774,6 +1171,7 @@ function ColorField({ label, value, onChange }: { label: string; value: string;
|
|||||||
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}>
|
||||||
@@ -803,6 +1201,7 @@ function InputField({
|
|||||||
onChange,
|
onChange,
|
||||||
onPicker,
|
onPicker,
|
||||||
children,
|
children,
|
||||||
|
disabled,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
@@ -810,10 +1209,11 @@ 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">
|
<YStack space="$2" opacity={disabled ? 0.6 : 1}>
|
||||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -834,6 +1234,7 @@ 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%',
|
||||||
@@ -846,7 +1247,7 @@ function InputField({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{onPicker ? (
|
{onPicker ? (
|
||||||
<Pressable onPress={onPicker}>
|
<Pressable onPress={onPicker} disabled={disabled}>
|
||||||
<ChevronDown size={16} color={primary} />
|
<ChevronDown size={16} color={primary} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -1078,3 +1479,34 @@ 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
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 } from 'lucide-react';
|
import { RefreshCcw, PlugZap, RefreshCw, ShieldCheck, Copy, Power, Clock3, Mail, Download } 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';
|
||||||
@@ -14,12 +13,14 @@ 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 } from '../lib/events';
|
import { formatEventDate, formatEventDateTime } 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';
|
||||||
@@ -34,10 +35,14 @@ 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';
|
||||||
@@ -50,7 +55,6 @@ 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.')));
|
||||||
@@ -64,20 +68,14 @@ export default function MobileEventPhotoboothPage() {
|
|||||||
void load();
|
void load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (status?.mode) {
|
|
||||||
setSelectedMode(status.mode);
|
|
||||||
}
|
|
||||||
}, [status?.mode]);
|
|
||||||
|
|
||||||
const handleEnable = async (mode?: 'ftp' | 'sparkbooth') => {
|
const handleEnable = async () => {
|
||||||
if (!slug) return;
|
if (!slug) return;
|
||||||
const nextMode = mode ?? selectedMode ?? status?.mode ?? 'ftp';
|
const nextMode = 'sparkbooth';
|
||||||
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)) {
|
||||||
@@ -90,12 +88,11 @@ export default function MobileEventPhotoboothPage() {
|
|||||||
|
|
||||||
const handleDisable = async () => {
|
const handleDisable = async () => {
|
||||||
if (!slug) return;
|
if (!slug) return;
|
||||||
const mode = status?.mode ?? selectedMode ?? 'ftp';
|
const mode = 'sparkbooth';
|
||||||
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)) {
|
||||||
@@ -108,12 +105,11 @@ export default function MobileEventPhotoboothPage() {
|
|||||||
|
|
||||||
const handleRotate = async () => {
|
const handleRotate = async () => {
|
||||||
if (!slug) return;
|
if (!slug) return;
|
||||||
const mode = selectedMode ?? status?.mode ?? 'ftp';
|
const mode = 'sparkbooth';
|
||||||
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)) {
|
||||||
@@ -124,39 +120,57 @@ export default function MobileEventPhotoboothPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const activeMode = selectedMode ?? status?.mode ?? 'ftp';
|
const handleGenerateConnectCode = async () => {
|
||||||
const isSpark = activeMode === 'sparkbooth';
|
if (!slug) return;
|
||||||
|
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 ftp = status?.ftp ?? null;
|
const metrics = spark?.metrics ?? null;
|
||||||
const metrics = isSpark ? spark?.metrics ?? null : status?.metrics ?? null;
|
const expiresAt = spark?.expires_at ?? status?.expires_at;
|
||||||
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 connectionPath = status?.path ?? '—';
|
const uploadUrl = spark?.upload_url ?? status?.upload_url;
|
||||||
const ftpUrl = status?.ftp_url ?? '—';
|
const username = spark?.username ?? status?.username ?? null;
|
||||||
const uploadUrl = isSpark ? spark?.upload_url ?? status?.upload_url : null;
|
const password = spark?.password ?? status?.password ?? 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 =
|
const modeLabel = t('photobooth.mode.uploader', 'Uploader App (HTTP)');
|
||||||
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"
|
||||||
@@ -185,143 +199,159 @@ export default function MobileEventPhotoboothPage() {
|
|||||||
) : (
|
) : (
|
||||||
<YStack space="$2">
|
<YStack space="$2">
|
||||||
<MobileCard space="$3">
|
<MobileCard space="$3">
|
||||||
<XStack justifyContent="space-between" alignItems="center" space="$3" flexWrap="wrap">
|
|
||||||
<YStack space="$1" flex={1} minWidth={0}>
|
|
||||||
<Text fontSize="$md" fontWeight="800" color={text}>
|
|
||||||
{t('photobooth.title', 'Photobooth')}
|
|
||||||
</Text>
|
|
||||||
<Text fontSize="$xs" color={muted}>
|
|
||||||
{t('photobooth.credentials.description', 'Share these credentials with your photobooth software.')}
|
|
||||||
</Text>
|
|
||||||
<Text fontSize="$xs" color={muted}>
|
|
||||||
{t('photobooth.mode.active', 'Current: {{mode}}', { mode: modeLabel })}
|
|
||||||
</Text>
|
|
||||||
</YStack>
|
|
||||||
<YStack alignItems="flex-end" space="$2">
|
|
||||||
<PillBadge tone={isActive ? 'success' : 'warning'}>
|
|
||||||
{isActive ? t('photobooth.status.badgeActive', 'ACTIVE') : t('photobooth.status.badgeInactive', 'INACTIVE')}
|
|
||||||
</PillBadge>
|
|
||||||
<XStack alignItems="center" space="$2">
|
|
||||||
<Text fontSize="$xs" color={muted}>
|
|
||||||
{isActive ? t('common.enabled', 'Enabled') : t('common.disabled', 'Disabled')}
|
|
||||||
</Text>
|
|
||||||
<Switch
|
|
||||||
size="$4"
|
|
||||||
checked={isActive}
|
|
||||||
disabled={updating}
|
|
||||||
onCheckedChange={handleToggle}
|
|
||||||
aria-label={t('photobooth.actions.toggle', 'Toggle photobooth access')}
|
|
||||||
>
|
|
||||||
<Switch.Thumb />
|
|
||||||
</Switch>
|
|
||||||
</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 space="$2">
|
|
||||||
<Text fontSize="$sm" fontWeight="700" color={text}>
|
|
||||||
{t('photobooth.selector.title', 'Choose adapter')}
|
|
||||||
</Text>
|
|
||||||
<Text fontSize="$xs" color={muted}>
|
|
||||||
{t(
|
|
||||||
'photobooth.selector.description',
|
|
||||||
'FTP (Classic) works with most booths. Sparkbooth uses HTTP POST without FTP.'
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
<XStack space="$2" marginTop="$2" flexWrap="nowrap">
|
|
||||||
<XStack flex={1} minWidth={0}>
|
|
||||||
<CTAButton
|
|
||||||
label={t('photobooth.mode.ftp', 'FTP (Classic)')}
|
|
||||||
tone={activeMode === 'ftp' ? 'primary' : 'ghost'}
|
|
||||||
onPress={() => setSelectedMode('ftp')}
|
|
||||||
disabled={updating}
|
|
||||||
style={{ width: '100%', paddingHorizontal: 10, paddingVertical: 10 }}
|
|
||||||
/>
|
|
||||||
</XStack>
|
|
||||||
<XStack flex={1} minWidth={0}>
|
|
||||||
<CTAButton
|
|
||||||
label={t('photobooth.mode.sparkbooth', 'Sparkbooth (HTTP POST)')}
|
|
||||||
tone={activeMode === 'sparkbooth' ? 'primary' : 'ghost'}
|
|
||||||
onPress={() => setSelectedMode('sparkbooth')}
|
|
||||||
disabled={updating}
|
|
||||||
style={{ width: '100%', paddingHorizontal: 10, paddingVertical: 10 }}
|
|
||||||
/>
|
|
||||||
</XStack>
|
|
||||||
</XStack>
|
|
||||||
</MobileCard>
|
|
||||||
|
|
||||||
<MobileCard space="$2">
|
|
||||||
<XStack alignItems="center" justifyContent="space-between">
|
|
||||||
<Text fontSize="$sm" fontWeight="700" color={text}>
|
|
||||||
{isSpark ? t('photobooth.credentials.sparkboothTitle', 'Sparkbooth upload (HTTP)') : t('photobooth.credentials.heading', 'FTP credentials')}
|
|
||||||
</Text>
|
|
||||||
{!isSpark && ftp?.require_ftps ? <PillBadge tone="warning">{t('photobooth.credentials.ftps', 'FTPS required')}</PillBadge> : null}
|
|
||||||
</XStack>
|
|
||||||
<YStack space="$1">
|
<YStack space="$1">
|
||||||
{isSpark ? (
|
<Text fontSize="$sm" fontWeight="800" color={text}>
|
||||||
<>
|
{t('photobooth.steps.activate.title', '1. Photobooth aktivieren')}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="$xs" color={muted}>
|
||||||
|
{t('photobooth.steps.activate.description', 'Schalte den Upload-Zugang fuer dieses Event frei.')}
|
||||||
|
</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}>
|
||||||
|
{t('photobooth.mode.active', 'Current: {{mode}}', { mode: modeLabel })}
|
||||||
|
</Text>
|
||||||
|
</XStack>
|
||||||
|
<XStack space="$2" marginTop="$2">
|
||||||
|
<CTAButton
|
||||||
|
label={isActive ? t('photobooth.actions.disable', 'Disable uploads') : t('photobooth.actions.enable', 'Enable uploads')}
|
||||||
|
onPress={() => (isActive ? handleDisable() : handleEnable())}
|
||||||
|
tone={isActive ? 'ghost' : 'primary'}
|
||||||
|
iconLeft={isActive ? <Power size={14} color={text} /> : <PlugZap size={14} color={surface} />}
|
||||||
|
disabled={updating}
|
||||||
|
fullWidth={false}
|
||||||
|
/>
|
||||||
|
{isActive ? (
|
||||||
|
<CTAButton
|
||||||
|
label={t('photobooth.actions.rotate', 'Regenerate access')}
|
||||||
|
onPress={() => handleRotate()}
|
||||||
|
tone="ghost"
|
||||||
|
iconLeft={<RefreshCw size={14} color={text} />}
|
||||||
|
disabled={updating}
|
||||||
|
fullWidth={false}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</XStack>
|
||||||
|
</MobileCard>
|
||||||
|
|
||||||
|
<MobileCard space="$3">
|
||||||
|
<YStack space="$1">
|
||||||
|
<Text fontSize="$sm" fontWeight="800" color={text}>
|
||||||
|
{t('photobooth.steps.download.title', '2. Uploader App herunterladen')}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="$xs" color={muted}>
|
||||||
|
{t(
|
||||||
|
'photobooth.uploaderDownload.description',
|
||||||
|
'Die Fotospiel Uploader App ist verpflichtend, damit Uploads stabil laufen, die Zugangsdaten geschuetzt bleiben und keine Dateien verloren gehen.'
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</YStack>
|
||||||
|
<XStack space="$2" marginTop="$2" flexWrap="wrap">
|
||||||
|
<CTAButton
|
||||||
|
label={t('photobooth.uploaderDownload.actionWindows', 'Uploader herunterladen (Windows)')}
|
||||||
|
onPress={() => {
|
||||||
|
const url = new URL('/downloads/PhotoboothUploader-win-x64.exe', window.location.origin).toString();
|
||||||
|
window.open(url, '_blank', 'noopener,noreferrer');
|
||||||
|
}}
|
||||||
|
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 space="$2" marginTop="$2">
|
||||||
|
<CTAButton
|
||||||
|
label={
|
||||||
|
sendingEmail
|
||||||
|
? t('common.processing', '...')
|
||||||
|
: t('photobooth.uploaderDownload.emailAction', 'Download-Links per E-Mail senden')
|
||||||
|
}
|
||||||
|
tone="ghost"
|
||||||
|
onPress={handleSendDownloadEmail}
|
||||||
|
iconLeft={<Mail size={14} color={text} />}
|
||||||
|
disabled={sendingEmail}
|
||||||
|
fullWidth={false}
|
||||||
|
/>
|
||||||
|
</XStack>
|
||||||
|
</MobileCard>
|
||||||
|
|
||||||
|
<MobileCard space="$3">
|
||||||
|
<YStack space="$1">
|
||||||
|
<Text fontSize="$sm" fontWeight="800" color={text}>
|
||||||
|
{t('photobooth.steps.access.title', '3. Verbindungscode erstellen')}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="$xs" color={muted}>
|
||||||
|
{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>
|
||||||
|
<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">
|
||||||
<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 />
|
||||||
<CredentialRow label={t('photobooth.sparkbooth.format', 'Response format')} value={responseFormat.toUpperCase()} border={border} />
|
</YStack>
|
||||||
<Text fontSize="$xs" color={muted}>
|
|
||||||
{t('photobooth.sparkbooth.hint', 'POST with media file or base64 "media" field; username/password required.')}
|
|
||||||
</Text>
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<Text fontSize="$xs" color={muted}>
|
||||||
<CredentialRow label={t('photobooth.credentials.host', 'Host')} value={ftp?.host ?? '—'} border={border} />
|
{t('photobooth.credentials.hidden', 'Credentials are hidden. Tap to show them.')}
|
||||||
<CredentialRow label={t('photobooth.credentials.port', 'Port')} value={String(ftp?.port ?? '—')} border={border} />
|
</Text>
|
||||||
<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}>
|
|
||||||
{t('photobooth.credentials.ftpsHint', 'Use FTPS if required; uploads go into the target folder for this event.')}
|
|
||||||
</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">
|
||||||
|
|||||||
302
resources/js/admin/mobile/ProfileAccountPage.tsx
Normal file
302
resources/js/admin/mobile/ProfileAccountPage.tsx
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 } from '../constants';
|
import { adminPath, ADMIN_DATA_EXPORTS_PATH, ADMIN_PROFILE_ACCOUNT_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(adminPath('/mobile/profile/security'))}>
|
<Pressable onPress={() => navigate(ADMIN_PROFILE_ACCOUNT_PATH)}>
|
||||||
<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 & security')}
|
{t('mobileProfile.account', 'Account bearbeiten')}
|
||||||
</Text>
|
</Text>
|
||||||
}
|
}
|
||||||
iconAfter={<Settings size={18} color={subtle} />}
|
iconAfter={<Settings size={18} color={subtle} />}
|
||||||
@@ -216,4 +216,4 @@ export default function MobileProfilePage() {
|
|||||||
/>
|
/>
|
||||||
</MobileShell>
|
</MobileShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 } from '../constants';
|
import { adminPath, ADMIN_HOME_PATH, ADMIN_PROFILE_ACCOUNT_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(adminPath('/mobile/profile'))} />
|
<CTAButton label={t('settings.profile.actions.openProfile', 'Profil bearbeiten')} onPress={() => navigate(ADMIN_PROFILE_ACCOUNT_PATH)} />
|
||||||
<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>
|
||||||
|
|||||||
141
resources/js/admin/mobile/__tests__/ProfileAccountPage.test.tsx
Normal file
141
resources/js/admin/mobile/__tests__/ProfileAccountPage.test.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -35,6 +35,7 @@ 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'));
|
||||||
@@ -212,6 +213,7 @@ 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> },
|
||||||
|
|||||||
@@ -37,27 +37,36 @@ export default function FiltersBar({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex gap-2 overflow-x-auto px-4 pb-2 text-sm font-medium [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
|
'flex overflow-x-auto px-1 pb-2 text-xs font-semibold [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
style={styleOverride}
|
style={styleOverride}
|
||||||
>
|
>
|
||||||
{filters.map((filter) => (
|
<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">
|
||||||
<button
|
{filters.map((filter, index) => {
|
||||||
key={filter.value}
|
const isActive = value === filter.value;
|
||||||
type="button"
|
return (
|
||||||
onClick={() => onChange(filter.value)}
|
<div key={filter.value} className="flex items-center">
|
||||||
className={cn(
|
<button
|
||||||
'inline-flex items-center gap-2 rounded-full border px-4 py-2 transition',
|
type="button"
|
||||||
value === filter.value
|
onClick={() => onChange(filter.value)}
|
||||||
? 'border-pink-500 bg-gradient-to-r from-pink-500 to-pink-600 text-white shadow'
|
className={cn(
|
||||||
: 'border-transparent bg-white/70 text-muted-foreground hover:border-pink-200',
|
'inline-flex items-center gap-1 rounded-full px-3 py-1.5 transition',
|
||||||
)}
|
isActive
|
||||||
>
|
? 'bg-pink-500 text-white shadow'
|
||||||
{filter.icon}
|
: 'text-muted-foreground hover:bg-pink-50 hover:text-pink-600',
|
||||||
{t(filter.labelKey)}
|
)}
|
||||||
</button>
|
>
|
||||||
))}
|
{React.cloneElement(filter.icon as React.ReactElement, { className: 'h-3.5 w-3.5' })}
|
||||||
|
<span className="whitespace-nowrap">{t(filter.labelKey)}</span>
|
||||||
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,32 +111,32 @@ export default function GalleryPreview({ token }: Props) {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 overflow-x-auto pb-1 text-sm font-medium [-ms-overflow-style:none] [scrollbar-width:none]">
|
<div className="flex overflow-x-auto pb-1 text-xs font-semibold [-ms-overflow-style:none] [scrollbar-width:none]">
|
||||||
{filters.map((filter) => {
|
<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">
|
||||||
const isActive = mode === filter.value;
|
{filters.map((filter, index) => {
|
||||||
|
const isActive = mode === filter.value;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<div key={filter.value} className="flex items-center">
|
||||||
key={filter.value}
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setMode(filter.value)}
|
onClick={() => setMode(filter.value)}
|
||||||
style={{
|
className={cn(
|
||||||
borderRadius: radius,
|
'inline-flex items-center rounded-full px-3 py-1.5 transition',
|
||||||
border: isActive ? `1px solid ${branding.primaryColor}` : `1px solid ${branding.primaryColor}22`,
|
isActive
|
||||||
background: isActive ? branding.primaryColor : undefined,
|
? 'bg-pink-500 text-white shadow'
|
||||||
boxShadow: isActive ? `0 8px 18px ${branding.primaryColor}33` : 'none',
|
: 'text-muted-foreground hover:bg-pink-50 hover:text-pink-600',
|
||||||
}}
|
)}
|
||||||
className={cn(
|
>
|
||||||
'px-4 py-1 transition',
|
<span className="whitespace-nowrap">{filter.label}</span>
|
||||||
isActive
|
</button>
|
||||||
? 'text-white'
|
{index < filters.length - 1 && (
|
||||||
: 'bg-[var(--guest-surface)] text-foreground dark:bg-slate-950/70 dark:text-slate-100',
|
<span className="mx-1 h-4 w-px bg-border/60 dark:bg-white/10" aria-hidden />
|
||||||
)}
|
)}
|
||||||
>
|
</div>
|
||||||
{filter.label}
|
);
|
||||||
</button>
|
})}
|
||||||
);
|
</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>}
|
||||||
@@ -147,43 +147,35 @@ export default function GalleryPreview({ token }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid gap-2 grid-cols-2 md:grid-cols-3">
|
<div className="grid gap-3 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 relative block overflow-hidden bg-[var(--guest-surface)] text-foreground dark:bg-slate-950/70"
|
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"
|
||||||
style={{
|
style={{ borderRadius: radius }}
|
||||||
borderRadius: radius,
|
>
|
||||||
border: `1px solid ${branding.primaryColor}22`,
|
<div className="relative">
|
||||||
boxShadow: `0 12px 26px ${branding.primaryColor}22`,
|
<img
|
||||||
}}
|
src={p.thumbnail_path || p.file_path}
|
||||||
>
|
alt={p.title || 'Foto'}
|
||||||
<img
|
className="aspect-[3/4] w-full object-cover transition duration-300 group-hover:scale-105"
|
||||||
src={p.thumbnail_path || p.file_path}
|
loading="lazy"
|
||||||
alt={p.title || 'Foto'}
|
/>
|
||||||
className="h-40 w-full object-cover transition duration-300 group-hover:scale-105"
|
<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 />
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="absolute inset-0"
|
|
||||||
style={{
|
|
||||||
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>
|
|
||||||
<div className="flex items-center gap-1 text-xs text-foreground/80">
|
|
||||||
<Heart className="h-4 w-4" style={{ color: branding.primaryColor }} aria-hidden />
|
|
||||||
{p.likes_count ?? 0}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="space-y-2 px-3 pb-3 pt-3">
|
||||||
</Link>
|
<p className="text-sm font-semibold leading-tight line-clamp-2 text-foreground" style={headingFont ? { fontFamily: headingFont } : undefined}>
|
||||||
))}
|
{p.title || getPhotoTitle(p)}
|
||||||
</div>
|
</p>
|
||||||
|
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
|
<Heart className="h-3.5 w-3.5 text-pink-500" aria-hidden />
|
||||||
|
{p.likes_count ?? 0}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
<p className="text-center text-sm text-muted-foreground">
|
<p className="text-center text-sm text-muted-foreground">
|
||||||
Lust auf mehr?{' '}
|
Lust auf mehr?{' '}
|
||||||
|
|||||||
@@ -38,6 +38,18 @@ 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,
|
||||||
@@ -69,18 +81,25 @@ function getInitials(name: string): string {
|
|||||||
return name.substring(0, 2).toUpperCase();
|
return name.substring(0, 2).toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderEventAvatar(name: string, icon: unknown, accentColor: string, textColor: string, logo?: { mode: 'emoticon' | 'upload'; value: string | null }) {
|
function renderEventAvatar(
|
||||||
|
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 h-10 w-10 items-center justify-center rounded-full bg-white shadow-sm">
|
<div className={`flex items-center justify-center rounded-full bg-white shadow-sm ${sizes.container}`}>
|
||||||
<img src={logo.value} alt={name} className="h-9 w-9 rounded-full object-contain" />
|
<img src={logo.value} alt={name} className={`rounded-full object-contain ${sizes.image}`} />
|
||||||
</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 h-10 w-10 items-center justify-center rounded-full text-xl shadow-sm"
|
className={`flex items-center justify-center rounded-full shadow-sm ${sizes.container} ${sizes.emoji}`}
|
||||||
style={{ backgroundColor: accentColor, color: textColor }}
|
style={{ backgroundColor: accentColor, color: textColor }}
|
||||||
>
|
>
|
||||||
<span aria-hidden>{logo.value}</span>
|
<span aria-hidden>{logo.value}</span>
|
||||||
@@ -94,21 +113,21 @@ function renderEventAvatar(name: string, icon: unknown, accentColor: string, tex
|
|||||||
if (trimmed) {
|
if (trimmed) {
|
||||||
const normalized = trimmed.toLowerCase();
|
const normalized = trimmed.toLowerCase();
|
||||||
const IconComponent = EVENT_ICON_COMPONENTS[normalized];
|
const IconComponent = EVENT_ICON_COMPONENTS[normalized];
|
||||||
if (IconComponent) {
|
if (IconComponent) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex h-10 w-10 items-center justify-center rounded-full shadow-sm"
|
className={`flex items-center justify-center rounded-full shadow-sm ${sizes.container}`}
|
||||||
style={{ backgroundColor: accentColor, color: textColor }}
|
style={{ backgroundColor: accentColor, color: textColor }}
|
||||||
>
|
>
|
||||||
<IconComponent className="h-5 w-5" aria-hidden />
|
<IconComponent className={sizes.icon} aria-hidden />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLikelyEmoji(trimmed)) {
|
if (isLikelyEmoji(trimmed)) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex h-10 w-10 items-center justify-center rounded-full text-xl shadow-sm"
|
className={`flex items-center justify-center rounded-full shadow-sm ${sizes.container} ${sizes.emoji}`}
|
||||||
style={{ backgroundColor: accentColor, color: textColor }}
|
style={{ backgroundColor: accentColor, color: textColor }}
|
||||||
>
|
>
|
||||||
<span aria-hidden>{trimmed}</span>
|
<span aria-hidden>{trimmed}</span>
|
||||||
@@ -188,13 +207,7 @@ 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 basePath = eventToken ? `/e/${encodeURIComponent(eventToken)}` : '';
|
const logoPosition = branding.logo?.position ?? 'left';
|
||||||
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,
|
||||||
@@ -226,9 +239,20 @@ 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 className="flex items-center gap-3">
|
<div
|
||||||
|
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 className="flex flex-col" style={headerFont ? { fontFamily: headerFont } : undefined}>
|
<div
|
||||||
|
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 && (
|
||||||
@@ -259,15 +283,6 @@ 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>
|
||||||
|
|||||||
@@ -72,17 +72,17 @@ export default function RouteTransition({ children }: { children?: React.ReactNo
|
|||||||
};
|
};
|
||||||
|
|
||||||
const tabVariants = {
|
const tabVariants = {
|
||||||
enter: { opacity: 0, scale: 0.985 },
|
enter: { opacity: 0, y: 8 },
|
||||||
center: { opacity: 1, scale: 1 },
|
center: { opacity: 1, y: 0 },
|
||||||
exit: { opacity: 0, scale: 0.985 },
|
exit: { opacity: 0, y: -8 },
|
||||||
};
|
};
|
||||||
|
|
||||||
const transition = kind === 'tab'
|
const transition = kind === 'tab'
|
||||||
? { duration: 0.18, ease: [0.22, 0.61, 0.36, 1] }
|
? { duration: 0.22, ease: [0.22, 0.61, 0.36, 1] }
|
||||||
: { duration: 0.24, ease: [0.25, 0.8, 0.25, 1] };
|
: { duration: 0.28, ease: [0.25, 0.8, 0.25, 1] };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence initial={false}>
|
<AnimatePresence initial={false} mode="wait">
|
||||||
<motion.div
|
<motion.div
|
||||||
key={location.pathname}
|
key={location.pathname}
|
||||||
custom={{ direction }}
|
custom={{ direction }}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Link, useParams } from 'react-router-dom';
|
import { Link, useLocation, 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,6 +23,7 @@ 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' },
|
||||||
@@ -53,12 +54,15 @@ 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 helpHref = params?.token ? `/e/${encodeURIComponent(params.token)}/help` : '/help';
|
const helpSlug = getHelpSlugForPathname(location.pathname);
|
||||||
|
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) {
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ 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);
|
||||||
|
|
||||||
@@ -62,7 +67,8 @@ 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 sizePreset = input.typography?.sizePreset ?? 'm';
|
const rawSize = 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;
|
||||||
@@ -116,6 +122,7 @@ 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;
|
||||||
@@ -149,6 +156,7 @@ 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');
|
||||||
@@ -160,48 +168,32 @@ function applyThemeMode(mode: EventBranding['mode']) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
const prefersDark = typeof window !== 'undefined'
|
const prefersDark = typeof window !== 'undefined' && typeof window.matchMedia === 'function'
|
||||||
? 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();
|
||||||
return;
|
root.style.colorScheme = 'light';
|
||||||
}
|
|
||||||
|
|
||||||
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({
|
||||||
@@ -214,6 +206,9 @@ 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');
|
||||||
@@ -225,6 +220,7 @@ 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);
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -746,6 +746,8 @@ 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',
|
||||||
},
|
},
|
||||||
@@ -1481,6 +1483,8 @@ 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',
|
||||||
},
|
},
|
||||||
|
|||||||
13
resources/js/guest/lib/__tests__/galleryFilters.test.ts
Normal file
13
resources/js/guest/lib/__tests__/galleryFilters.test.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
35
resources/js/guest/lib/__tests__/guestTheme.test.ts
Normal file
35
resources/js/guest/lib/__tests__/guestTheme.test.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
32
resources/js/guest/lib/__tests__/helpRouting.test.ts
Normal file
32
resources/js/guest/lib/__tests__/helpRouting.test.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,41 +1,47 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { getMotionContainerPropsForNavigation, getMotionItemPropsForNavigation, STAGGER_FAST, FADE_UP } from '../motion';
|
||||||
import { FADE_SCALE, FADE_UP, STAGGER_FAST, getMotionContainerProps, getMotionItemProps, prefersReducedMotion } from '../motion';
|
|
||||||
|
|
||||||
describe('motion helpers', () => {
|
describe('getMotionContainerPropsForNavigation', () => {
|
||||||
it('returns disabled props when motion is off', () => {
|
it('returns initial hidden for POP navigation', () => {
|
||||||
const props = getMotionContainerProps(false, STAGGER_FAST);
|
expect(getMotionContainerPropsForNavigation(true, STAGGER_FAST, 'POP')).toEqual({
|
||||||
expect(props.initial).toBe(false);
|
variants: STAGGER_FAST,
|
||||||
});
|
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('exposes distinct base variants', () => {
|
it('skips initial animation for PUSH navigation', () => {
|
||||||
expect(FADE_UP).not.toBe(FADE_SCALE);
|
expect(getMotionContainerPropsForNavigation(true, STAGGER_FAST, 'PUSH')).toEqual({
|
||||||
|
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({});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
22
resources/js/guest/lib/__tests__/taskUtils.test.ts
Normal file
22
resources/js/guest/lib/__tests__/taskUtils.test.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
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' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
5
resources/js/guest/lib/galleryFilters.ts
Normal file
5
resources/js/guest/lib/galleryFilters.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import type { EventData } from '../services/eventApi';
|
||||||
|
|
||||||
|
export function shouldShowPhotoboothFilter(event?: EventData | null): boolean {
|
||||||
|
return Boolean(event?.photobooth_enabled);
|
||||||
|
}
|
||||||
103
resources/js/guest/lib/guestTheme.ts
Normal file
103
resources/js/guest/lib/guestTheme.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
44
resources/js/guest/lib/helpRouting.ts
Normal file
44
resources/js/guest/lib/helpRouting.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
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';
|
||||||
|
}
|
||||||
@@ -56,3 +56,31 @@ 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;
|
||||||
|
}
|
||||||
|
|||||||
18
resources/js/guest/lib/taskUtils.ts
Normal file
18
resources/js/guest/lib/taskUtils.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
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';
|
||||||
|
|
||||||
@@ -11,7 +10,6 @@ const GuestFallback: React.FC<{ message: string }> = ({ message }) => (
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
initializeTheme();
|
|
||||||
initSentry('guest');
|
initSentry('guest');
|
||||||
if (shouldEnableGuestDemoMode()) {
|
if (shouldEnableGuestDemoMode()) {
|
||||||
enableGuestDemoMode();
|
enableGuestDemoMode();
|
||||||
@@ -24,9 +22,7 @@ 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>
|
||||||
);
|
);
|
||||||
@@ -51,17 +47,15 @@ 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} />
|
<PwaManager />
|
||||||
<PwaManager />
|
<Suspense fallback={<GuestFallback message="Erlebnisse werden geladen …" />}>
|
||||||
<Suspense fallback={<GuestFallback message="Erlebnisse werden geladen …" />}>
|
<RouterProvider router={router} />
|
||||||
<RouterProvider router={router} />
|
</Suspense>
|
||||||
</Suspense>
|
</ToastProvider>
|
||||||
</ToastProvider>
|
</LocaleProvider>
|
||||||
</LocaleProvider>
|
|
||||||
</AppearanceProvider>
|
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
</Sentry.ErrorBoundary>
|
</Sentry.ErrorBoundary>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { Link, useParams } from 'react-router-dom';
|
import { Link, useNavigationType, 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, getMotionContainerProps, getMotionItemProps, prefersReducedMotion } from '../lib/motion';
|
import { FADE_SCALE, FADE_UP, STAGGER_FAST, getMotionContainerPropsForNavigation, 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,6 +343,7 @@ 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();
|
||||||
@@ -393,7 +394,7 @@ export default function AchievementsPage() {
|
|||||||
|
|
||||||
const hasPersonal = Boolean(data?.personal);
|
const hasPersonal = Boolean(data?.personal);
|
||||||
const motionEnabled = !prefersReducedMotion();
|
const motionEnabled = !prefersReducedMotion();
|
||||||
const containerMotion = getMotionContainerProps(motionEnabled, STAGGER_FAST);
|
const containerMotion = getMotionContainerPropsForNavigation(motionEnabled, STAGGER_FAST, navigationType);
|
||||||
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
|
||||||
|
|||||||
@@ -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 { useParams, useSearchParams } from 'react-router-dom';
|
import { useNavigationType, 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,11 +12,19 @@ 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 { FADE_SCALE, FADE_UP, STAGGER_FAST, getMotionContainerProps, getMotionItemProps, prefersReducedMotion } from '../lib/motion';
|
import {
|
||||||
|
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';
|
||||||
|
|
||||||
@@ -56,6 +64,7 @@ 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);
|
||||||
@@ -68,10 +77,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 = getMotionContainerProps(motionEnabled, STAGGER_FAST);
|
const containerMotion = getMotionContainerPropsForNavigation(motionEnabled, STAGGER_FAST, navigationType);
|
||||||
const fadeUpMotion = getMotionItemProps(motionEnabled, FADE_UP);
|
const fadeUpMotion = getMotionItemPropsForNavigation(motionEnabled, FADE_UP, navigationType);
|
||||||
const fadeScaleMotion = getMotionItemProps(motionEnabled, FADE_SCALE);
|
const fadeScaleMotion = getMotionItemPropsForNavigation(motionEnabled, FADE_SCALE, navigationType);
|
||||||
const gridMotion = getMotionContainerProps(motionEnabled, STAGGER_FAST);
|
const gridMotion = getMotionContainerPropsForNavigation(motionEnabled, STAGGER_FAST, navigationType);
|
||||||
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);
|
||||||
@@ -88,10 +97,7 @@ export default function GalleryPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const typedPhotos = photos as GalleryPhoto[];
|
const typedPhotos = photos as GalleryPhoto[];
|
||||||
const showPhotoboothFilter = React.useMemo(
|
const showPhotoboothFilter = React.useMemo(() => shouldShowPhotoboothFilter(event), [event]);
|
||||||
() => 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],
|
||||||
@@ -301,167 +307,177 @@ export default function GalleryPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Page title="">
|
<Page title="">
|
||||||
<PullToRefresh
|
<div className="relative">
|
||||||
onRefresh={handleRefresh}
|
<PullToRefresh
|
||||||
pullLabel={t('common.pullToRefresh')}
|
onRefresh={handleRefresh}
|
||||||
releaseLabel={t('common.releaseToRefresh')}
|
pullLabel={t('common.pullToRefresh')}
|
||||||
refreshingLabel={t('common.refreshing')}
|
releaseLabel={t('common.releaseToRefresh')}
|
||||||
>
|
refreshingLabel={t('common.refreshing')}
|
||||||
<motion.div className="space-y-2" style={bodyFont ? { fontFamily: bodyFont } : undefined} {...containerMotion}>
|
>
|
||||||
<motion.div className="flex items-center gap-3" {...fadeUpMotion}>
|
<motion.div className="space-y-6 pb-24" {...containerMotion}>
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-pink-500/10 text-pink-500" style={{ borderRadius: radius }}>
|
<motion.div className="space-y-2" style={bodyFont ? { fontFamily: bodyFont } : undefined} {...fadeUpMotion}>
|
||||||
<ImageIcon className="h-5 w-5" aria-hidden />
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
</div>
|
<div className="flex items-center gap-3">
|
||||||
<div>
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-pink-500/10 text-pink-500" style={{ borderRadius: radius }}>
|
||||||
<h1 className="text-2xl font-semibold text-foreground" style={headingFont ? { fontFamily: headingFont } : undefined}>{t('galleryPage.title')}</h1>
|
<ImageIcon className="h-5 w-5" aria-hidden />
|
||||||
<p className="text-sm text-muted-foreground">{t('galleryPage.subtitle')}</p>
|
</div>
|
||||||
</div>
|
<div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{newCount > 0 ? (
|
<h1 className="text-2xl font-semibold text-foreground" style={headingFont ? { fontFamily: headingFont } : undefined}>{t('galleryPage.title')}</h1>
|
||||||
<button
|
<span
|
||||||
type="button"
|
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-[11px] font-semibold ${badgeEmphasisClass}`}
|
||||||
onClick={acknowledgeNew}
|
style={{ borderRadius: radius }}
|
||||||
className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold transition ${badgeEmphasisClass}`}
|
>
|
||||||
style={{ borderRadius: radius }}
|
{newPhotosBadgeText}
|
||||||
>
|
</span>
|
||||||
{newPhotosBadgeText}
|
</div>
|
||||||
</button>
|
<p className="text-sm text-muted-foreground">{t('galleryPage.subtitle')}</p>
|
||||||
) : (
|
|
||||||
<span className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold ${badgeEmphasisClass}`} style={{ borderRadius: radius }}>
|
|
||||||
{newPhotosBadgeText}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<motion.div {...fadeUpMotion}>
|
|
||||||
<FiltersBar
|
|
||||||
value={filter}
|
|
||||||
onChange={setFilter}
|
|
||||||
className="mt-2"
|
|
||||||
showPhotobooth={showPhotoboothFilter}
|
|
||||||
styleOverride={{ borderRadius: radius, fontFamily: headingFont }}
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
{loading && (
|
|
||||||
<motion.p className="px-4" style={bodyFont ? { fontFamily: bodyFont } : undefined} {...fadeUpMotion}>
|
|
||||||
{t('galleryPage.loading', 'Lade…')}
|
|
||||||
</motion.p>
|
|
||||||
)}
|
|
||||||
<motion.div className="grid grid-cols-2 gap-2 px-2 pb-16 sm:grid-cols-3 lg:grid-cols-4" {...gridMotion}>
|
|
||||||
{list.map((p: GalleryPhoto) => {
|
|
||||||
const imageUrl = normalizeImageUrl(p.thumbnail_path || p.file_path);
|
|
||||||
const createdLabel = p.created_at
|
|
||||||
? new Date(p.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
|
||||||
: t('galleryPage.photo.justNow', 'Gerade eben');
|
|
||||||
const likeCount = counts[p.id] ?? (p.likes_count || 0);
|
|
||||||
const localizedTaskTitle = localizeTaskLabel(p.task_title ?? null, locale);
|
|
||||||
const altSuffix = localizedTaskTitle
|
|
||||||
? t('galleryPage.photo.altTaskSuffix', { task: localizedTaskTitle })
|
|
||||||
: '';
|
|
||||||
const altText = t('galleryPage.photo.alt', { id: p.id, suffix: altSuffix }, `Foto ${p.id}${altSuffix}`);
|
|
||||||
|
|
||||||
const openPhoto = () => {
|
|
||||||
const index = list.findIndex((photo) => photo.id === p.id);
|
|
||||||
setCurrentPhotoIndex(index >= 0 ? index : null);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
key={p.id}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onClick={openPhoto}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
openPhoto();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
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 }}
|
|
||||||
{...fadeScaleMotion}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={imageUrl}
|
|
||||||
alt={altText}
|
|
||||||
className="aspect-[3/4] w-full object-cover transition duration-500 group-hover:scale-105"
|
|
||||||
onError={(e) => {
|
|
||||||
(e.target as HTMLImageElement).src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjRjNGNEY2Ii8+PHRleHQgeD0iNTAlIiB5PSI1MCUiIGZvbnQtZmFtaWx5PSJBcmlhbCIgZm9udC1zaXplPSIxNCIgZmlsbD0iIzk5OSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZHk9Ii4zZW0iPk5vIEltYWdlPC90ZXh0Pjwvc3ZnPg==';
|
|
||||||
}}
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/85 via-black/20 to-transparent" aria-hidden />
|
|
||||||
<div className="absolute inset-x-0 bottom-0 space-y-2 px-4 pb-4" 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>}
|
|
||||||
<div className="flex items-center justify-between text-xs text-white/90" style={bodyFont ? { fontFamily: bodyFont } : undefined}>
|
|
||||||
<span className="truncate">{createdLabel}</span>
|
|
||||||
<span className="ml-3 truncate text-right">{p.uploader_name || t('galleryPage.photo.anonymous', 'Gast')}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute right-3 top-3 z-10 flex items-center gap-2">
|
|
||||||
|
{newCount > 0 && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={acknowledgeNew}
|
||||||
e.stopPropagation();
|
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"
|
||||||
onShare(p);
|
style={{ borderRadius: radius }}
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
'flex h-9 w-9 items-center justify-center border text-white transition backdrop-blur',
|
|
||||||
shareTargetId === p.id ? 'opacity-60' : 'hover:bg-white/10'
|
|
||||||
)}
|
|
||||||
aria-label={t('galleryPage.photo.shareAria', 'Foto teilen')}
|
|
||||||
disabled={shareTargetId === p.id}
|
|
||||||
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-4 w-4" aria-hidden />
|
{t('galleryPage.badge.markSeen', 'Gesehen')}
|
||||||
</button>
|
</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>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{list.length === 0 && Array.from({ length: 6 }).map((_, idx) => (
|
|
||||||
<motion.div
|
|
||||||
key={`placeholder-${idx}`}
|
|
||||||
className="relative overflow-hidden border border-muted/40 bg-[var(--guest-surface,#f7f7f7)] shadow-sm"
|
|
||||||
style={{ borderRadius: radius }}
|
|
||||||
{...fadeScaleMotion}
|
|
||||||
>
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-white/60 via-white/30 to-transparent dark:from-white/5 dark:via-white/0" aria-hidden />
|
|
||||||
<div className="flex aspect-[3/4] items-center justify-center gap-2 p-4 text-muted-foreground/70">
|
|
||||||
<ImageIcon className="h-6 w-6" aria-hidden />
|
|
||||||
<div className="h-2 w-10 rounded-full bg-muted/40" />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute inset-0 animate-pulse bg-white/30 dark:bg-white/5" aria-hidden />
|
|
||||||
|
<FiltersBar
|
||||||
|
value={filter}
|
||||||
|
onChange={setFilter}
|
||||||
|
className="mt-0"
|
||||||
|
showPhotobooth={showPhotoboothFilter}
|
||||||
|
styleOverride={{ borderRadius: radius, fontFamily: headingFont }}
|
||||||
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
))}
|
|
||||||
</motion.div>
|
{loading && (
|
||||||
</PullToRefresh>
|
<motion.p className="px-1" {...fadeUpMotion}>
|
||||||
|
{t('galleryPage.loading', 'Lade…')}
|
||||||
|
</motion.p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<motion.div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4" {...gridMotion}>
|
||||||
|
{list.map((p: GalleryPhoto) => {
|
||||||
|
const imageUrl = normalizeImageUrl(p.thumbnail_path || p.file_path);
|
||||||
|
const createdLabel = p.created_at
|
||||||
|
? new Date(p.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||||
|
: t('galleryPage.photo.justNow', 'Gerade eben');
|
||||||
|
const likeCount = counts[p.id] ?? (p.likes_count || 0);
|
||||||
|
const localizedTaskTitle = localizeTaskLabel(p.task_title ?? null, locale);
|
||||||
|
const altSuffix = localizedTaskTitle
|
||||||
|
? t('galleryPage.photo.altTaskSuffix', { task: localizedTaskTitle })
|
||||||
|
: '';
|
||||||
|
const altText = t('galleryPage.photo.alt', { id: p.id, suffix: altSuffix }, `Foto ${p.id}${altSuffix}`);
|
||||||
|
|
||||||
|
const openPhoto = () => {
|
||||||
|
const index = list.findIndex((photo) => photo.id === p.id);
|
||||||
|
setCurrentPhotoIndex(index >= 0 ? index : null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={p.id}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={openPhoto}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
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"
|
||||||
|
style={{ borderRadius: radius }}
|
||||||
|
{...fadeScaleMotion}
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
<img
|
||||||
|
src={imageUrl}
|
||||||
|
alt={altText}
|
||||||
|
className="aspect-[3/4] w-full object-cover transition duration-500 group-hover:scale-105"
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjRjNGNEY2Ii8+PHRleHQgeD0iNTAlIiB5PSI1MCUiIGZvbnQtZmFtaWx5PSJBcmlhbCIgZm9udC1zaXplPSIxNCIgZmlsbD0iIzk5OSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZHk9Ii4zZW0iPk5vIEltYWdlPC90ZXh0Pjwvc3ZnPg==';
|
||||||
|
}}
|
||||||
|
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>
|
||||||
|
<div className="space-y-2 px-3 pb-3 pt-3" style={bodyFont ? { fontFamily: bodyFont } : undefined}>
|
||||||
|
{localizedTaskTitle && (
|
||||||
|
<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">{p.uploader_name || t('galleryPage.photo.anonymous', 'Gast')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<button
|
||||||
|
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
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onShare(p);
|
||||||
|
}}
|
||||||
|
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',
|
||||||
|
shareTargetId === p.id ? 'opacity-60' : 'hover:bg-muted/40'
|
||||||
|
)}
|
||||||
|
aria-label={t('galleryPage.photo.shareAria', 'Foto teilen')}
|
||||||
|
disabled={shareTargetId === p.id}
|
||||||
|
style={{ borderRadius: radius }}
|
||||||
|
>
|
||||||
|
<Share2 className="h-3.5 w-3.5" aria-hidden />
|
||||||
|
{t('galleryPage.photo.shareLabel', 'Teilen')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{list.length === 0 && Array.from({ length: 6 }).map((_, idx) => (
|
||||||
|
<motion.div
|
||||||
|
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"
|
||||||
|
style={{ borderRadius: radius }}
|
||||||
|
{...fadeScaleMotion}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-white/60 via-white/30 to-transparent dark:from-white/5 dark:via-white/0" aria-hidden />
|
||||||
|
<div className="flex aspect-[3/4] items-center justify-center gap-2 p-4 text-muted-foreground/70">
|
||||||
|
<ImageIcon className="h-6 w-6" aria-hidden />
|
||||||
|
<div className="h-2 w-10 rounded-full bg-muted/40" />
|
||||||
|
</div>
|
||||||
|
<div className="absolute inset-0 animate-pulse bg-white/30 dark:bg-white/5" aria-hidden />
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
</PullToRefresh>
|
||||||
|
</div>
|
||||||
{currentPhotoIndex !== null && list.length > 0 && (
|
{currentPhotoIndex !== null && list.length > 0 && (
|
||||||
<PhotoLightbox
|
<PhotoLightbox
|
||||||
photos={list}
|
photos={list}
|
||||||
|
|||||||
@@ -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 { Loader2 } from 'lucide-react';
|
import { ArrowLeft, 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,7 +37,9 @@ export default function HelpArticlePage() {
|
|||||||
loadArticle();
|
loadArticle();
|
||||||
}, [loadArticle]);
|
}, [loadArticle]);
|
||||||
|
|
||||||
const title = article?.title ?? t('help.article.unavailable');
|
const title = state === 'loading'
|
||||||
|
? t('help.article.loadingTitle')
|
||||||
|
: (article?.title ?? t('help.article.unavailable'));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page title={title}>
|
<Page title={title}>
|
||||||
@@ -48,17 +50,30 @@ export default function HelpArticlePage() {
|
|||||||
refreshingLabel={t('common.refreshing')}
|
refreshingLabel={t('common.refreshing')}
|
||||||
>
|
>
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<Button variant="ghost" size="sm" asChild>
|
<Button variant="outline" size="sm" className="rounded-full border-border/60 bg-background/70 px-3" asChild>
|
||||||
<Link to={basePath}>
|
<Link to={basePath}>
|
||||||
{t('help.article.back')}
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<ArrowLeft className="h-4 w-4" aria-hidden />
|
||||||
|
{t('help.article.back')}
|
||||||
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{state === 'loading' && (
|
{state === 'loading' && (
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
<div className="rounded-2xl border border-border/60 bg-card/70 p-5">
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||||
{t('common.actions.loading')}
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
<div>
|
||||||
|
<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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -77,11 +92,6 @@ 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
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ 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) => {
|
||||||
@@ -37,6 +38,24 @@ 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;
|
||||||
@@ -85,7 +104,7 @@ export default function HelpCenterPage() {
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{servedFromCache && (
|
{showOfflineBadge && (
|
||||||
<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')}
|
||||||
|
|||||||
@@ -9,9 +9,8 @@ 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;
|
||||||
@@ -95,28 +94,34 @@ 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(() => {
|
||||||
const mode = state.meta?.branding.mode;
|
if (!resolvedBranding) {
|
||||||
if (!mode || typeof document === 'undefined') {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const wasDark = document.documentElement.classList.contains('dark');
|
return applyGuestTheme(resolvedBranding);
|
||||||
|
}, [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) {
|
||||||
@@ -164,55 +169,46 @@ export default function PublicGalleryPage(): React.ReactElement | null {
|
|||||||
}, [state.cursor, loadMore]);
|
}, [state.cursor, loadMore]);
|
||||||
|
|
||||||
const themeStyles = useMemo(() => {
|
const themeStyles = useMemo(() => {
|
||||||
if (!state.meta) {
|
if (!resolvedBranding) {
|
||||||
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': primary,
|
'--gallery-primary': resolvedBranding.primary,
|
||||||
'--gallery-secondary': secondary,
|
'--gallery-secondary': resolvedBranding.secondary,
|
||||||
'--gallery-background': background,
|
'--gallery-background': resolvedBranding.background,
|
||||||
'--gallery-surface': surface,
|
'--gallery-surface': resolvedBranding.surface,
|
||||||
} as React.CSSProperties & Record<string, string>;
|
} as React.CSSProperties & Record<string, string>;
|
||||||
}, [state.meta]);
|
}, [resolvedBranding]);
|
||||||
|
|
||||||
const headerStyle = useMemo(() => {
|
const headerStyle = useMemo(() => {
|
||||||
if (!state.meta) {
|
if (!resolvedBranding) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
const palette = state.meta.branding.palette ?? {};
|
const textColor = getContrastingTextColor(resolvedBranding.primary, '#0f172a', '#ffffff');
|
||||||
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, ${primary}, ${secondary})`,
|
background: `linear-gradient(135deg, ${resolvedBranding.primary}, ${resolvedBranding.secondary})`,
|
||||||
color: textColor,
|
color: textColor,
|
||||||
} satisfies React.CSSProperties;
|
} satisfies React.CSSProperties;
|
||||||
}, [state.meta]);
|
}, [resolvedBranding]);
|
||||||
|
|
||||||
const accentStyle = useMemo(() => {
|
const accentStyle = useMemo(() => {
|
||||||
if (!state.meta) {
|
if (!resolvedBranding) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
color: (state.meta.branding.palette?.primary ?? state.meta.branding.primary_color),
|
color: resolvedBranding.primary,
|
||||||
} satisfies React.CSSProperties;
|
} satisfies React.CSSProperties;
|
||||||
}, [state.meta]);
|
}, [resolvedBranding]);
|
||||||
|
|
||||||
const backgroundStyle = useMemo(() => {
|
const backgroundStyle = useMemo(() => {
|
||||||
if (!state.meta) {
|
if (!resolvedBranding) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
backgroundColor: state.meta.branding.palette?.background ?? state.meta.branding.background_color,
|
backgroundColor: resolvedBranding.background,
|
||||||
} satisfies React.CSSProperties;
|
} satisfies React.CSSProperties;
|
||||||
}, [state.meta]);
|
}, [resolvedBranding]);
|
||||||
|
|
||||||
const openLightbox = useCallback((photo: GalleryPhotoResource) => {
|
const openLightbox = useCallback((photo: GalleryPhotoResource) => {
|
||||||
setSelectedPhoto(photo);
|
setSelectedPhoto(photo);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useNavigationType, 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,9 +18,10 @@ 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, getMotionContainerProps, getMotionItemProps, prefersReducedMotion } from '../lib/motion';
|
import { FADE_SCALE, FADE_UP, STAGGER_FAST, getMotionContainerPropsForNavigation, 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;
|
||||||
@@ -55,6 +56,7 @@ 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();
|
||||||
@@ -133,9 +135,10 @@ export default function TaskPickerPage() {
|
|||||||
? payload.tasks
|
? payload.tasks
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const entry = { data: taskList, etag: response.headers.get('ETag') };
|
const uniqueTasks = dedupeTasksById(taskList);
|
||||||
|
const entry = { data: uniqueTasks, etag: response.headers.get('ETag') };
|
||||||
tasksCacheRef.current.set(cacheKey, entry);
|
tasksCacheRef.current.set(cacheKey, entry);
|
||||||
setTasks(taskList);
|
setTasks(uniqueTasks);
|
||||||
} 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');
|
||||||
@@ -369,7 +372,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 = getMotionContainerProps(motionEnabled, STAGGER_FAST);
|
const containerMotion = getMotionContainerPropsForNavigation(motionEnabled, STAGGER_FAST, navigationType);
|
||||||
const fadeUpMotion = getMotionItemProps(motionEnabled, FADE_UP);
|
const fadeUpMotion = getMotionItemProps(motionEnabled, FADE_UP);
|
||||||
const fadeScaleMotion = getMotionItemProps(motionEnabled, FADE_SCALE);
|
const fadeScaleMotion = getMotionItemProps(motionEnabled, FADE_SCALE);
|
||||||
|
|
||||||
|
|||||||
@@ -1488,7 +1488,7 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="relative h-24 w-24">
|
<div className="relative flex h-24 w-24 items-center justify-center">
|
||||||
{!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" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
49
resources/js/guest/pages/__tests__/HelpArticlePage.test.tsx
Normal file
49
resources/js/guest/pages/__tests__/HelpArticlePage.test.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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, waitFor } from '@testing-library/react';
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
import UploadPage from '../UploadPage';
|
import UploadPage from '../UploadPage';
|
||||||
|
|
||||||
vi.mock('react-router-dom', () => ({
|
vi.mock('react-router-dom', () => ({
|
||||||
@@ -73,4 +73,15 @@ 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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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, useParams, Link, Navigate } from 'react-router-dom';
|
import { createBrowserRouter, useLocation, 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,6 +101,7 @@ 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 />;
|
||||||
@@ -118,6 +119,9 @@ 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}>
|
||||||
@@ -125,7 +129,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="px-4 py-3">
|
<div className={contentPaddingClass}>
|
||||||
<RouteTransition />
|
<RouteTransition />
|
||||||
</div>
|
</div>
|
||||||
<BottomNav />
|
<BottomNav />
|
||||||
|
|||||||
@@ -128,6 +128,25 @@ 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',
|
||||||
|
|||||||
@@ -127,6 +127,25 @@ 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',
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
@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
|
||||||
@@ -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/sparkbooth/upload', [SparkboothUploadController::class, 'store'])
|
Route::post('/photobooth/upload', [SparkboothUploadController::class, 'store'])
|
||||||
->name('photobooth.sparkbooth.upload');
|
->name('photobooth.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,6 +270,8 @@ 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'])
|
||||||
|
|||||||
69
scripts/build-photobooth-uploader.sh
Normal file
69
scripts/build-photobooth-uploader.sh
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
#!/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}"
|
||||||
@@ -232,6 +232,48 @@ 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;
|
||||||
|
|||||||
24
tests/Feature/Help/HelpSyncServiceTest.php
Normal file
24
tests/Feature/Help/HelpSyncServiceTest.php
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,7 @@ 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()
|
||||||
@@ -59,6 +60,7 @@ 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');
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user