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
|
||||
bd.sock
|
||||
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
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
# This setting persists across clones (unlike database config which is gitignored).
|
||||
# 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>'.
|
||||
# sync-branch: "beads-sync"
|
||||
sync-branch: "beads-sync"
|
||||
|
||||
# Multi-repo configuration (experimental - bd-307)
|
||||
# Allows hydrating from multiple repositories and routing writes to the correct JSONL
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
{"id":"fotospiel-app-25q","title":"Security review: payments/webhooks code audit (signatures, idempotency, linkage)","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:05:25.747336642+01:00","created_by":"soeren","updated_at":"2026-01-01T16:05:25.747336642+01:00"}
|
||||
{"id":"fotospiel-app-29o","title":"Paddle catalog sync: PackageResource sync status badges + timestamp","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:01:10.009385187+01:00","created_by":"soeren","updated_at":"2026-01-01T16:01:15.639525807+01:00","closed_at":"2026-01-01T16:01:15.639525807+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-29r","title":"Photobooth uploader: add watch-folder upload pipeline + persist creds","status":"closed","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-12T16:51:27.198056063+01:00","created_by":"Codex Agent","updated_at":"2026-01-12T17:07:04.06719869+01:00","closed_at":"2026-01-12T17:07:04.06719869+01:00","close_reason":"Closed"}
|
||||
{"id":"fotospiel-app-2b5","title":"Uploader: connect code expiry countdown","description":"Part of epic fotospiel-app-5aa. Show time-to-expiry for the active connect code in the client.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:04:05.74962406+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:04:05.74962406+01:00"}
|
||||
{"id":"fotospiel-app-2hq","title":"Security review: marketing/API controller+validation review","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:05:08.862737923+01:00","created_by":"soeren","updated_at":"2026-01-01T16:05:08.862737923+01:00"}
|
||||
{"id":"fotospiel-app-2yn","title":"Event-Admin: Reset link routing + notifications + tests","description":"Point password reset emails to event-admin reset page; add rate limiting and tests for the new flow.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T10:45:09.279245468+01:00","created_by":"soeren","updated_at":"2026-01-06T11:01:49.083154811+01:00","closed_at":"2026-01-06T11:01:49.083154811+01:00","close_reason":"Closed"}
|
||||
{"id":"fotospiel-app-33m","title":"Security review checklist: Guest PWA dynamic tests","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:04:40.730459361+01:00","created_by":"soeren","updated_at":"2026-01-01T16:04:40.730459361+01:00"}
|
||||
@@ -29,6 +30,7 @@
|
||||
{"id":"fotospiel-app-574","title":"Paddle catalog sync: extend PaddleClient tests/mocks for catalog endpoints","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:59:03.486301225+01:00","created_by":"soeren","updated_at":"2026-01-02T21:11:39.626820206+01:00","closed_at":"2026-01-02T21:11:39.626820206+01:00","close_reason":"Deprioritized"}
|
||||
{"id":"fotospiel-app-576","title":"Tenant admin onboarding: legacy asset audit + component inventory","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:07:59.996563146+01:00","created_by":"soeren","updated_at":"2026-01-01T16:08:05.599274641+01:00","closed_at":"2026-01-01T16:08:05.599274641+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-579","title":"Live Show: tests (backend + UI smoke)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-05T11:11:57.246607374+01:00","created_by":"soeren","updated_at":"2026-01-05T19:37:35.590123482+01:00","closed_at":"2026-01-05T19:37:35.590123482+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-579","depends_on_id":"fotospiel-app-539","type":"blocks","created_at":"2026-01-05T11:13:27.729131522+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-579","depends_on_id":"fotospiel-app-xg5","type":"blocks","created_at":"2026-01-05T11:13:37.425191011+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-579","depends_on_id":"fotospiel-app-qne","type":"blocks","created_at":"2026-01-05T11:13:46.257175231+01:00","created_by":"soeren"}]}
|
||||
{"id":"fotospiel-app-5aa","title":"Photobooth uploader: reliability + UX upgrades","status":"open","priority":2,"issue_type":"epic","owner":"codex-agent@example.com","created_at":"2026-01-13T11:01:29.745168595+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:01:29.745168595+01:00"}
|
||||
{"id":"fotospiel-app-5dl","title":"Paddle catalog sync: PaddleCatalogService scaffold","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:00:24.916655836+01:00","created_by":"soeren","updated_at":"2026-01-01T16:00:30.566084195+01:00","closed_at":"2026-01-01T16:00:30.566084195+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-5hk","title":"Fix staging coupon seed 500 for E2E","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-03T15:12:53.643644221+01:00","created_by":"soeren","updated_at":"2026-01-04T16:21:46.441797374+01:00","closed_at":"2026-01-04T16:21:46.441797374+01:00","close_reason":"Resolved elsewhere; staging coupon seed 500 no longer reproducible after recent backend changes."}
|
||||
{"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"}]}
|
||||
@@ -39,10 +41,14 @@
|
||||
{"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-6oj","title":"Security review: media pipeline code audit (AV/EXIF, signed URLs, storage separation)","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:05:31.390878341+01:00","created_by":"soeren","updated_at":"2026-01-01T16:05:31.390878341+01:00"}
|
||||
{"id":"fotospiel-app-6yt","title":"Paddle migration: register sandbox webhooks + document events consumed","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:56:34.333714988+01:00","created_by":"soeren","updated_at":"2026-01-02T22:23:52.212191068+01:00","closed_at":"2026-01-02T22:23:52.212191068+01:00","close_reason":"Completed"}
|
||||
{"id":"fotospiel-app-6yz","title":"Uploader: activity log export","description":"Part of epic fotospiel-app-5aa. Add in-app log view and export/copy diagnostics for support.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:04:27.73767403+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:04:27.73767403+01:00"}
|
||||
{"id":"fotospiel-app-6zc","title":"Live Show: Admin app settings \u0026 effect presets","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-01-05T11:11:27.038815978+01:00","created_by":"soeren","updated_at":"2026-01-05T15:02:42.035082497+01:00","closed_at":"2026-01-05T15:02:42.035082497+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-6zc","depends_on_id":"fotospiel-app-vro","type":"blocks","created_at":"2026-01-05T11:12:50.048055484+01:00","created_by":"soeren"}]}
|
||||
{"id":"fotospiel-app-7bu","title":"Paddle migration: extend config/env handling for Paddle keys/webhook secrets","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:57:27.242854801+01:00","created_by":"soeren","updated_at":"2026-01-01T15:57:32.890355888+01:00","closed_at":"2026-01-01T15:57:32.890355888+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-7u1","title":"Paddle catalog sync: PaddlePackagePull job","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:00:47.468892178+01:00","created_by":"soeren","updated_at":"2026-01-01T16:00:53.126602817+01:00","closed_at":"2026-01-01T16:00:53.126602817+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"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-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-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-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-99o","title":"Fix German welcome phrasing with article-safe app_name","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-04T11:50:17.410390085+01:00","created_by":"soeren","updated_at":"2026-01-04T12:19:55.741616753+01:00","closed_at":"2026-01-04T12:19:55.741616753+01:00","close_reason":"Closed"}
|
||||
@@ -63,9 +69,11 @@
|
||||
{"id":"fotospiel-app-bqm","title":"Paddle catalog sync: unit tests for service + jobs","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:01:22.090498843+01:00","created_by":"soeren","updated_at":"2026-01-01T16:01:27.71412122+01:00","closed_at":"2026-01-01T16:01:27.71412122+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-bxu","title":"Checkout refactor: Stripe/Paddle payment integration + webhooks","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:06:32.279485614+01:00","created_by":"soeren","updated_at":"2026-01-01T16:06:37.876950599+01:00","closed_at":"2026-01-01T16:06:37.876950599+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-bzb","title":"Paddle catalog sync: migration for paddle sync columns","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:00:02.362257158+01:00","created_by":"soeren","updated_at":"2026-01-01T16:00:08.018770606+01:00","closed_at":"2026-01-01T16:00:08.018770606+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-cht","title":"Uploader: disk space low warning","description":"Part of epic fotospiel-app-5aa. Highlight low disk space thresholds in UI.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:03:32.710631234+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:03:32.710631234+01:00"}
|
||||
{"id":"fotospiel-app-ci5","title":"Paddle catalog sync: configure log channel/Slack hook for sync outcomes","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:59:20.543083527+01:00","created_by":"soeren","updated_at":"2026-01-02T22:02:15.857149244+01:00","closed_at":"2026-01-02T22:02:15.857149244+01:00","close_reason":"Completed"}
|
||||
{"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-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-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-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"}
|
||||
@@ -86,6 +94,7 @@
|
||||
{"id":"fotospiel-app-iyh","title":"Security review follow-ups: signed URL TTLs, guest asset throttles, CORS allowlist, logging hygiene","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:05:42.642109576+01:00","created_by":"soeren","updated_at":"2026-01-01T16:05:42.642109576+01:00"}
|
||||
{"id":"fotospiel-app-jk4","title":"Checkout refactor: CheckoutController + marketing route alignment","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:06:21.088319132+01:00","created_by":"soeren","updated_at":"2026-01-01T16:06:26.663419594+01:00","closed_at":"2026-01-01T16:06:26.663419594+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-jqy","title":"Tenant admin onboarding: Playwright skeleton for welcome flow","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:08:11.226297707+01:00","created_by":"soeren","updated_at":"2026-01-01T16:08:16.827679424+01:00","closed_at":"2026-01-01T16:08:16.827679424+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-jy1","title":"Uploader: clear failed uploads UI","description":"Part of epic fotospiel-app-5aa. Add action to clear/reset failed items and counters.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:03:13.134661157+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:03:13.134661157+01:00"}
|
||||
{"id":"fotospiel-app-ko0","title":"Security review checklist: Webhooks/Billing dynamic tests","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:04:51.987093237+01:00","created_by":"soeren","updated_at":"2026-01-01T16:04:51.987093237+01:00"}
|
||||
{"id":"fotospiel-app-kry","title":"Paddle catalog sync: add DTO helpers for Paddle product/price responses","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:58:57.817750548+01:00","created_by":"soeren","updated_at":"2026-01-02T21:11:27.970220923+01:00","closed_at":"2026-01-02T21:11:27.970220923+01:00","close_reason":"Deprioritized"}
|
||||
{"id":"fotospiel-app-kso","title":"SEC-MS-02 Streaming upload refactor + tests","description":"Current state (code scan)\n- Guest uploads: App\\\\Http\\\\Controllers\\\\Api\\\\EventPublicController@upload uses Storage::disk()-\u003eputFile (stream-friendly) but still does watermark/thumbnail work inline.\n- Tenant admin uploads: App\\\\Http\\\\Controllers\\\\Api\\\\Tenant\\\\PhotoController@store and @uploadDirect use Storage::disk()-\u003eput($path, file_get_contents(...)) which loads entire file into memory.\n- Photobooth ingest already streams from import disk via readStream -\u003e Storage::disk()-\u003eput($path, $stream).\n- Presigned upload flow is stubbed to a local upload-direct endpoint; no true presigned S3 handling yet.\n- No tenant upload feature tests exist; guest upload tests exist and cover limits/security.\n\nGoal\n- Stream uploads to disk (avoid full in-memory buffers) for tenant-admin upload endpoints and keep behavior consistent across sources.\n\nPlan\n1) Introduce a small streaming upload helper/service\n - New service (e.g. App\\\\Services\\\\Storage\\\\UploadStreamService) that accepts UploadedFile + disk + destination path.\n - Use fopen on UploadedFile::getRealPath (or $file-\u003egetStream()) and Storage::disk($disk)-\u003eput($path, $stream) / writeStream.\n - Always close stream; return stored size and checksum (hash_file on stored path) for asset metadata.\n\n2) Refactor tenant upload endpoints to use streaming\n - Update PhotoController@store and @uploadDirect to use the helper instead of file_get_contents.\n - Use Storage::disk()-\u003eputFileAs (or helper) to preserve deterministic paths without buffering.\n - Keep existing validation, watermark, thumbnail, asset recording, and package usage logic.\n\n3) Optional consistency pass on guest upload\n - Consider routing EventPublicController@upload through the same helper for consistent storage + checksum handling, while keeping current validation/limits.\n\n4) Tests\n - Add Feature tests for tenant upload endpoints:\n - /api/v1/tenant/events/{slug}/photos (store) uploads a fake image and persists Photo + EventMediaAsset with expected path/size.\n - /api/v1/tenant/events/{slug}/upload-direct (presigned) uploads a fake image and stores asset + thumbnail.\n - Ensure existing guest upload tests still pass (no behavioral changes).\n\n5) Safety/ops\n - Verify streaming logic handles empty/invalid files gracefully and still reports errors via ApiError.\n - Keep request-time processing (thumb/watermark) unchanged for now; consider queuing in a follow-up if CPU spikes persist.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:53:03.729137616+01:00","created_by":"soeren","updated_at":"2026-01-02T20:51:17.752365339+01:00","closed_at":"2026-01-02T20:51:17.752365339+01:00","close_reason":"Closed"}
|
||||
@@ -93,6 +102,8 @@
|
||||
{"id":"fotospiel-app-l3n","title":"Session changes 2025-09-08 (PRP split, PWA scaffolding, Filament resources, API)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:10:18.204088457+01:00","created_by":"soeren","updated_at":"2026-01-01T16:10:23.815135505+01:00","closed_at":"2026-01-01T16:10:23.815135505+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-l6a","title":"Registration flow fixes: JSON redirect, error clearing, role handling","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:07:16.253760139+01:00","created_by":"soeren","updated_at":"2026-01-01T16:07:21.964843904+01:00","closed_at":"2026-01-01T16:07:21.964843904+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-l8q","title":"SEC-GT-02 Join-token analytics dashboard (Grafana)","description":"Logging + Filament summaries exist; Grafana dashboard still missing.","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:52:12.920875329+01:00","created_by":"soeren","updated_at":"2026-01-01T15:52:12.920875329+01:00"}
|
||||
{"id":"fotospiel-app-lj6","title":"Uploader: folder health enhancements","description":"Part of epic fotospiel-app-5aa. Track last file seen, write permissions, and show clearer folder status.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:03:22.843330813+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:03:22.843330813+01:00"}
|
||||
{"id":"fotospiel-app-llq","title":"Uploader: lock settings after connect","description":"Part of epic fotospiel-app-5aa. Prevent accidental changes to base URL/credentials unless explicitly unlocked.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:03:43.40971185+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:03:43.40971185+01:00"}
|
||||
{"id":"fotospiel-app-ln3","title":"Paddle catalog sync: announce workflow change to admin users","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:59:49.021233635+01:00","created_by":"soeren","updated_at":"2026-01-02T21:11:09.349495631+01:00","closed_at":"2026-01-02T21:11:09.349495631+01:00","close_reason":"Deprioritized"}
|
||||
{"id":"fotospiel-app-lnb","title":"SEC-GT-01 Hash join tokens + data migration","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:52:01.658868778+01:00","created_by":"soeren","updated_at":"2026-01-01T15:52:07.314317124+01:00","closed_at":"2026-01-01T15:52:07.314317124+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-lnf","title":"Remove legacy registration page assets","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-06T08:37:39.419274918+01:00","created_by":"soeren","updated_at":"2026-01-06T08:37:39.419274918+01:00"}
|
||||
@@ -102,6 +113,7 @@
|
||||
{"id":"fotospiel-app-ml7","title":"SEC-GT-03 Tighten gallery/photo rate limits + alerting","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:52:18.593415508+01:00","created_by":"soeren","updated_at":"2026-01-01T15:52:18.593415508+01:00"}
|
||||
{"id":"fotospiel-app-mol","title":"Coupon ops: wire analytics into Matomo dashboard","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:09:27.722458747+01:00","created_by":"soeren","updated_at":"2026-01-02T23:28:18.178704873+01:00","closed_at":"2026-01-02T23:28:18.178704873+01:00","close_reason":"Closed"}
|
||||
{"id":"fotospiel-app-mpu","title":"Checkout refactor: test coverage + rollout notes","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:06:43.488302531+01:00","created_by":"soeren","updated_at":"2026-01-01T16:06:49.13645691+01:00","closed_at":"2026-01-01T16:06:49.13645691+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-mwi","title":"Uploader: duplicate detection / upload cache","description":"Part of epic fotospiel-app-5aa. Track uploaded files (path/hash) to avoid re-uploads after restart.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:02:06.432781468+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:02:06.432781468+01:00"}
|
||||
{"id":"fotospiel-app-mx5","title":"Localized SEO: sitemap updated with locale alternates","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:02:15.177013722+01:00","created_by":"soeren","updated_at":"2026-01-01T16:02:20.812287917+01:00","closed_at":"2026-01-01T16:02:20.812287917+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-mxw","title":"Security review: configure env assumptions for dynamic testing","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:04:29.498402235+01:00","created_by":"soeren","updated_at":"2026-01-01T16:04:29.498402235+01:00"}
|
||||
{"id":"fotospiel-app-n8q","title":"Paddle migration: draft production cutover procedure","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:56:51.427425262+01:00","created_by":"soeren","updated_at":"2026-01-02T22:28:41.469357437+01:00","closed_at":"2026-01-02T22:28:41.469357437+01:00","close_reason":"Completed"}
|
||||
@@ -119,11 +131,15 @@
|
||||
{"id":"fotospiel-app-qlj","title":"Paddle catalog sync: verify legacy packages mapped before auto-sync","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:59:43.333792314+01:00","created_by":"soeren","updated_at":"2026-01-02T21:46:52.797515024+01:00","closed_at":"2026-01-02T21:46:52.797515024+01:00","close_reason":"Completed"}
|
||||
{"id":"fotospiel-app-qne","title":"Live Show: realtime delivery channel (WS/SSE) + fallback polling","acceptance_criteria":"- Public Live Show endpoints exist for state, updates, and SSE stream\\n- Updates endpoint supports cursor (after_approved_at + after_id)\\n- SSE emits photo.approved and ping, with settings updates when version changes\\n- Feature tests cover state, updates, invalid token","notes":"Added LiveShowController with public endpoints: /api/v1/live-show/{token} (state), /updates (polling), /stream (SSE). Provides live-show settings (defaults + event.settings.live_show merge), settings_version hash, ordered approved photo feed with cursor. SSE emits photo.approved, settings.updated, ping. Added routes in routes/api.php. Added Photo live_status default. Tests: tests/Feature/LiveShowRealtimeTest.php. Ran Pint + test.","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-01-05T11:11:06.028871737+01:00","created_by":"soeren","updated_at":"2026-01-05T13:08:33.936740582+01:00","closed_at":"2026-01-05T13:08:33.936740582+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-qne","depends_on_id":"fotospiel-app-t1k","type":"blocks","created_at":"2026-01-05T11:12:30.363982215+01:00","created_by":"soeren"}]}
|
||||
{"id":"fotospiel-app-qtn","title":"Security review kickoff mitigations (CORS allowlist, headers, upload hardening, signed URLs)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:09:46.310873311+01:00","created_by":"soeren","updated_at":"2026-01-01T16:09:51.914359487+01:00","closed_at":"2026-01-01T16:09:51.914359487+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-rpv","title":"Uploader: connection test (no upload)","description":"Part of epic fotospiel-app-5aa. Add lightweight ping/test for upload URL + credentials.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:02:39.061938692+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:02:39.061938692+01:00"}
|
||||
{"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-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-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-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-tqg","title":"Tenant admin onboarding: staging E2E validation","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:08:57.448899354+01:00","created_by":"soeren","updated_at":"2026-01-01T16:08:57.448899354+01:00"}
|
||||
{"id":"fotospiel-app-tsb","title":"Uploader: upload throttling presets","description":"Part of epic fotospiel-app-5aa. Add optional delay/presets to smooth upload bursts.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:02:27.111436345+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:02:27.111436345+01:00"}
|
||||
{"id":"fotospiel-app-ty9","title":"Security review: data classes \u0026 retention baseline","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:03:09.595870306+01:00","created_by":"soeren","updated_at":"2026-01-01T16:03:15.211042718+01:00","closed_at":"2026-01-01T16:03:15.211042718+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-tym","title":"Ops health dashboard (queues, storage, upload pipeline)","description":"Superadmin ops dashboard showing queue backlog, failed jobs, storage thresholds, and upload pipeline health.","notes":"Implemented Ops Health dashboard with storage+queue widgets, new translations, and navigation wiring.","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-01-01T14:20:04.991351193+01:00","updated_at":"2026-01-02T17:34:10.326367902+01:00","closed_at":"2026-01-02T17:34:10.326367902+01:00","close_reason":"Closed"}
|
||||
{"id":"fotospiel-app-ugk","title":"Paddle catalog sync: feature test for artisan command","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:01:33.309716868+01:00","created_by":"soeren","updated_at":"2026-01-01T16:01:38.940407157+01:00","closed_at":"2026-01-01T16:01:38.940407157+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
@@ -142,6 +158,7 @@
|
||||
{"id":"fotospiel-app-wku","title":"Security review: run dynamic testing harness (identities, DAST, fuzz uploads)","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:05:37.008239379+01:00","created_by":"soeren","updated_at":"2026-01-01T16:05:37.008239379+01:00"}
|
||||
{"id":"fotospiel-app-xg5","title":"Live Show: Admin app moderation queue UI","acceptance_criteria":"- Dedicated Live Show moderation API endpoints exist for list + approve/reject/clear\\n- Admin mobile UI exposes Live Show queue with status filter and actions\\n- PhotoResource includes live_* fields for admin UI\\n- Feature tests cover list + approve/reject/clear workflows","notes":"Added dedicated Live Show moderation API (tenant admin): /events/{slug}/live-show/photos + approve/reject/clear actions. Added LiveShowPhotoController + FormRequests. PhotoResource now exposes live_* fields. Admin app: new Live Show queue page, route, and Event detail shortcut tile. Admin API updated with Live Show functions + types. Added translations (EN/DE) for Live Show queue UI. Tests: tests/Feature/LiveShowPhotoControllerTest.php.","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-01-05T11:11:15.006484132+01:00","created_by":"soeren","updated_at":"2026-01-05T14:03:41.410176482+01:00","closed_at":"2026-01-05T14:03:41.410176482+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-xg5","depends_on_id":"fotospiel-app-t1k","type":"blocks","created_at":"2026-01-05T11:12:38.94145573+01:00","created_by":"soeren"}]}
|
||||
{"id":"fotospiel-app-xht","title":"Paddle migration: tenant ↔ Paddle customer sync + webhook handlers","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:58:01.028435913+01:00","created_by":"soeren","updated_at":"2026-01-01T15:58:06.685122343+01:00","closed_at":"2026-01-01T15:58:06.685122343+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-xik","title":"Uploader: richer error details","description":"Part of epic fotospiel-app-5aa. Surface HTTP status/body summary in last error and recent uploads.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:02:49.591107008+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:02:49.591107008+01:00"}
|
||||
{"id":"fotospiel-app-y1f","title":"Compliance tools: superadmin data export + retention override UI","description":"Add superadmin compliance tools for data exports and retention overrides.\nScope: list export requests, status, expiry, and allow manual retry/cancel; add per-tenant/event retention override UI with audit logging.\nEnsure access is restricted to superadmins and no PII is exposed beyond existing export metadata.\n","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-02T17:34:29.825347299+01:00","created_by":"soeren","updated_at":"2026-01-02T22:49:53.586758621+01:00","closed_at":"2026-01-02T22:49:53.586758621+01:00","close_reason":"Closed"}
|
||||
{"id":"fotospiel-app-yii","title":"Implement 'Upgrade to Premium' flow for Analytics Upsell","description":"The Analytics page currently has an upsell screen for non-premium users. The 'Upgrade to Premium' button redirects to the billing page, but the actual upgrade/purchase flow needs to be fully implemented and verified to allow users to unlock the feature.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T16:13:55.446495378+01:00","created_by":"soeren","updated_at":"2026-01-06T16:35:41.968964977+01:00","closed_at":"2026-01-06T16:35:41.968970147+01:00"}
|
||||
{"id":"fotospiel-app-z2k","title":"Ops health widget visual polish","description":"Replace Tailwind utility styling in ops health widget with Filament components and icon-driven layout.","notes":"Updated queue health widget layout to use Filament cards, badges, empty states, and grid utilities; added status strip and alert rail.","status":"closed","priority":3,"issue_type":"task","created_at":"2026-01-01T21:34:39.851728527+01:00","created_by":"soeren","updated_at":"2026-01-01T21:34:59.834597413+01:00","closed_at":"2026-01-01T21:34:59.834597413+01:00","close_reason":"completed"}
|
||||
|
||||
@@ -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\Requests\Photobooth\PhotoboothConnectRedeemRequest;
|
||||
use App\Models\Event;
|
||||
use App\Services\Photobooth\PhotoboothConnectCodeService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
@@ -33,7 +34,8 @@ class PhotoboothConnectController extends Controller
|
||||
|
||||
return response()->json([
|
||||
'data' => [
|
||||
'upload_url' => route('api.v1.photobooth.sparkbooth.upload'),
|
||||
'event_name' => $this->resolveEventName($event),
|
||||
'upload_url' => route('api.v1.photobooth.upload'),
|
||||
'username' => $setting->username,
|
||||
'password' => $setting->password,
|
||||
'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;
|
||||
|
||||
$settings = $validated['settings'];
|
||||
$branding = Arr::get($settings, 'branding', []);
|
||||
$watermark = Arr::get($settings, 'watermark', []);
|
||||
$existingWatermark = is_array($watermark) ? $watermark : [];
|
||||
|
||||
if (is_array($branding)) {
|
||||
$settings['branding'] = $this->normalizeBrandingSettings($branding, $event, $brandingAllowed);
|
||||
}
|
||||
|
||||
if (is_array($watermark)) {
|
||||
$mode = $watermark['mode'] ?? 'base';
|
||||
$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
|
||||
{
|
||||
$tenantId = $request->attributes->get('tenant_id');
|
||||
|
||||
@@ -3,12 +3,17 @@
|
||||
namespace App\Http\Controllers\Api\Tenant;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Photobooth\PhotoboothSendUploaderDownloadRequest;
|
||||
use App\Http\Resources\Tenant\PhotoboothStatusResource;
|
||||
use App\Mail\PhotoboothUploaderDownload;
|
||||
use App\Models\Event;
|
||||
use App\Models\PhotoboothSetting;
|
||||
use App\Services\Photobooth\PhotoboothProvisioner;
|
||||
use App\Support\LocaleConfig;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
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
|
||||
{
|
||||
return PhotoboothStatusResource::make([
|
||||
@@ -92,4 +130,30 @@ class PhotoboothController extends Controller
|
||||
|
||||
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,
|
||||
'path' => $eventSetting?->path,
|
||||
'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(),
|
||||
'rate_limit_per_minute' => (int) $settings->rate_limit_per_minute,
|
||||
'ftp' => [
|
||||
@@ -62,7 +62,7 @@ class PhotoboothStatusResource extends JsonResource
|
||||
'username' => $mode === 'sparkbooth' ? $eventSetting?->username : null,
|
||||
'password' => $mode === 'sparkbooth' ? $password : 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'),
|
||||
'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) {
|
||||
[$audience, $locale] = explode('::', $key);
|
||||
$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));
|
||||
Cache::forget($this->cacheKey($audience, $locale));
|
||||
$written[$audience][$locale] = $group->count();
|
||||
|
||||
@@ -6,5 +6,85 @@
|
||||
|
||||
<Application.Styles>
|
||||
<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>
|
||||
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"
|
||||
mc:Ignorable="d" d:DesignWidth="520" d:DesignHeight="360"
|
||||
x:Class="PhotoboothUploader.MainWindow"
|
||||
Width="520" Height="360"
|
||||
Title="Fotospiel Photobooth Uploader">
|
||||
<Grid Margin="24" ColumnDefinitions="*,8,*">
|
||||
<StackPanel Grid.Column="0" Spacing="12" MaxWidth="420">
|
||||
<TextBlock Text="Fotospiel Photobooth Uploader" FontSize="20" FontWeight="SemiBold" />
|
||||
Width="560" Height="420"
|
||||
MinWidth="520" MinHeight="400"
|
||||
Title="Die Fotospiel.App - Photobooth Uploader">
|
||||
<Grid Margin="24,32,24,24" RowDefinitions="Auto,*">
|
||||
<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">
|
||||
<TextBlock Text="Schritte" FontWeight="SemiBold" />
|
||||
<TextBlock x:Name="StepCodeText" Text="1. Code eingeben" />
|
||||
@@ -19,34 +33,112 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<TextBlock Text="Gib den 6-stelligen Verbindungscode ein." TextWrapping="Wrap" />
|
||||
<TextBox x:Name="CodeBox" MaxLength="6" Watermark="123456" />
|
||||
<Button x:Name="ConnectButton" Content="Verbinden" Click="ConnectButton_Click" />
|
||||
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="Upload-Ordner" FontWeight="SemiBold" />
|
||||
<TextBlock x:Name="FolderText" Text="Noch nicht ausgewählt." TextWrapping="Wrap" />
|
||||
<Button x:Name="PickFolderButton" Content="Ordner auswählen" Click="PickFolderButton_Click" IsEnabled="False" />
|
||||
<Border Padding="14" Classes="card">
|
||||
<StackPanel Spacing="10">
|
||||
<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>
|
||||
|
||||
<Border Padding="14" Classes="card">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="Upload-Ordner" FontWeight="SemiBold" />
|
||||
<TextBlock x:Name="FolderText" Text="Noch nicht ausgewählt." TextWrapping="Wrap" Classes="subtitle" />
|
||||
<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)" />
|
||||
|
||||
<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 Grid.Column="2" Spacing="12" MaxWidth="380">
|
||||
<Border Background="#1F000000" Padding="12" CornerRadius="8">
|
||||
<StackPanel Grid.Column="2" Spacing="16" MaxWidth="380" Margin="0,6,0,0">
|
||||
<Border Padding="14" Classes="card accent">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="Status" FontWeight="SemiBold" />
|
||||
<TextBlock x:Name="StatusText" Text="Nicht verbunden." TextWrapping="Wrap" />
|
||||
<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>
|
||||
</Border>
|
||||
|
||||
<Border Padding="14" Classes="card">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="Details" FontWeight="SemiBold" />
|
||||
<TextBlock x:Name="EventNameText" Text="Event: —" TextWrapping="Wrap" />
|
||||
<TextBlock x:Name="BaseUrlText" Text="Basis-URL: —" TextWrapping="Wrap" />
|
||||
<TextBlock x:Name="VersionText" Text="App-Version: —" />
|
||||
<TextBlock x:Name="ConnectExpiryText" Text="Verbindungscode: —" TextWrapping="Wrap" />
|
||||
<TextBlock x:Name="FolderHealthText" Text="Ordner: —" TextWrapping="Wrap" />
|
||||
<TextBlock x:Name="DiskFreeText" Text="Freier Speicher: —" TextWrapping="Wrap" />
|
||||
<TextBlock x:Name="LastSeenText" Text="Letzte Datei: —" TextWrapping="Wrap" />
|
||||
<TextBlock x:Name="LastErrorText" Text="Letzter Fehler: —" TextWrapping="Wrap" />
|
||||
<Button x:Name="LogCopyButton" Content="Log kopieren" Click="LogCopyButton_Click" Classes="secondary" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Padding="14" Classes="card">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="Letzte Uploads" FontWeight="SemiBold" />
|
||||
<ItemsControl x:Name="RecentUploadsList" ItemsSource="{Binding RecentUploads}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border Background="#14000000" Padding="8" CornerRadius="6" Margin="0,0,0,6">
|
||||
<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}" />
|
||||
@@ -56,8 +148,13 @@
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<Button x:Name="RetryFailedButton" Content="Fehlgeschlagene erneut senden" Click="RetryFailedButton_Click" IsEnabled="False" />
|
||||
<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>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Window>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,9 @@ public sealed class PhotoboothConnectResponse
|
||||
|
||||
public sealed class PhotoboothConnectPayload
|
||||
{
|
||||
[JsonPropertyName("event_name")]
|
||||
public string? EventName { get; set; }
|
||||
|
||||
[JsonPropertyName("upload_url")]
|
||||
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;
|
||||
|
||||
public sealed class PhotoboothSettings
|
||||
{
|
||||
public string? BaseUrl { get; set; }
|
||||
public string? EventName { 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 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>
|
||||
<Nullable>enable</Nullable>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<ApplicationIcon>Assets\app.ico</ApplicationIcon>
|
||||
<AvaloniaUseCompiledBindingsByDefault>false</AvaloniaUseCompiledBindingsByDefault>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -18,4 +19,10 @@
|
||||
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="Assets\app.ico" />
|
||||
<AvaloniaResource Include="Assets\logo.png" />
|
||||
<AvaloniaResource Include="Assets\sample-upload.png" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
@@ -10,41 +12,111 @@ namespace PhotoboothUploader.Services;
|
||||
|
||||
public sealed class PhotoboothConnectClient
|
||||
{
|
||||
private const int MaxRetries = 2;
|
||||
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(10);
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly JsonSerializerOptions _jsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
public PhotoboothConnectClient(string baseUrl)
|
||||
public PhotoboothConnectClient(string baseUrl, string userAgent)
|
||||
{
|
||||
_httpClient = new HttpClient
|
||||
{
|
||||
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)
|
||||
{
|
||||
var response = await _httpClient.PostAsJsonAsync("/api/v1/photobooth/connect", new { code }, cancellationToken);
|
||||
var payload = await response.Content.ReadFromJsonAsync<PhotoboothConnectResponse>(_jsonOptions, cancellationToken);
|
||||
var request = new { code };
|
||||
|
||||
if (payload is null)
|
||||
for (var attempt = 0; attempt <= MaxRetries; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var response = await _httpClient.PostAsJsonAsync("/api/v1/photobooth/connect", request, cancellationToken);
|
||||
var payload = await ReadPayloadAsync(response, cancellationToken);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return payload ?? Fail(response.ReasonPhrase ?? "Verbindung fehlgeschlagen.");
|
||||
}
|
||||
|
||||
if (response.StatusCode is HttpStatusCode.UnprocessableEntity or HttpStatusCode.Conflict or HttpStatusCode.Unauthorized)
|
||||
{
|
||||
return payload ?? Fail(response.ReasonPhrase ?? "Verbindung fehlgeschlagen.");
|
||||
}
|
||||
|
||||
if (attempt < MaxRetries && IsTransientStatus(response.StatusCode))
|
||||
{
|
||||
await Task.Delay(GetRetryDelay(attempt), cancellationToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
return payload ?? Fail(response.ReasonPhrase ?? "Verbindung fehlgeschlagen.");
|
||||
}
|
||||
catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
if (attempt < MaxRetries)
|
||||
{
|
||||
await Task.Delay(GetRetryDelay(attempt), cancellationToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
return Fail("Zeitüberschreitung bei der Verbindung.");
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
if (attempt < MaxRetries)
|
||||
{
|
||||
await Task.Delay(GetRetryDelay(attempt), cancellationToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
return Fail("Netzwerkfehler. Bitte Verbindung prüfen.");
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return Fail("Serverantwort konnte nicht gelesen werden.");
|
||||
}
|
||||
}
|
||||
|
||||
return Fail("Verbindung fehlgeschlagen.");
|
||||
}
|
||||
|
||||
private async Task<PhotoboothConnectResponse?> ReadPayloadAsync(HttpResponseMessage response, CancellationToken cancellationToken)
|
||||
{
|
||||
if (response.Content.Headers.ContentLength == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<PhotoboothConnectResponse>(_jsonOptions, cancellationToken);
|
||||
}
|
||||
|
||||
private static bool IsTransientStatus(HttpStatusCode statusCode)
|
||||
{
|
||||
return statusCode is HttpStatusCode.RequestTimeout or HttpStatusCode.TooManyRequests
|
||||
or HttpStatusCode.BadGateway or HttpStatusCode.ServiceUnavailable or HttpStatusCode.GatewayTimeout
|
||||
or HttpStatusCode.InternalServerError;
|
||||
}
|
||||
|
||||
private static TimeSpan GetRetryDelay(int attempt)
|
||||
{
|
||||
return TimeSpan.FromMilliseconds(500 * (attempt + 1));
|
||||
}
|
||||
|
||||
private static PhotoboothConnectResponse Fail(string message)
|
||||
{
|
||||
return new PhotoboothConnectResponse
|
||||
{
|
||||
Message = response.ReasonPhrase ?? "Verbindung fehlgeschlagen.",
|
||||
Message = message,
|
||||
};
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return new PhotoboothConnectResponse
|
||||
{
|
||||
Message = payload.Message ?? "Verbindung fehlgeschlagen.",
|
||||
};
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ public sealed class SettingsStore
|
||||
};
|
||||
|
||||
public string SettingsPath { get; }
|
||||
public string LogPath { get; }
|
||||
|
||||
public SettingsStore()
|
||||
{
|
||||
@@ -24,6 +25,7 @@ public sealed class SettingsStore
|
||||
|
||||
Directory.CreateDirectory(basePath);
|
||||
SettingsPath = Path.Combine(basePath, "settings.json");
|
||||
LogPath = Path.Combine(basePath, "uploader.log");
|
||||
}
|
||||
|
||||
public PhotoboothSettings Load()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
@@ -12,21 +13,38 @@ namespace PhotoboothUploader.Services;
|
||||
|
||||
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 ConcurrentDictionary<string, byte> _pending = new(StringComparer.OrdinalIgnoreCase);
|
||||
private string _userAgent = "FotospielPhotoboothUploader";
|
||||
private CancellationTokenSource? _cts;
|
||||
private readonly List<Task> _workers = new();
|
||||
|
||||
public void Configure(string userAgent)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(userAgent))
|
||||
{
|
||||
_userAgent = userAgent;
|
||||
}
|
||||
}
|
||||
|
||||
public void Start(
|
||||
PhotoboothSettings settings,
|
||||
Action<string> onQueued,
|
||||
Action<string> onUploading,
|
||||
Action<string> onSuccess,
|
||||
Action<string> onFailure)
|
||||
Action<string, string> onFailure)
|
||||
{
|
||||
Stop();
|
||||
|
||||
_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()
|
||||
@@ -34,6 +52,7 @@ public sealed class UploadService
|
||||
_cts?.Cancel();
|
||||
_cts = null;
|
||||
_pending.Clear();
|
||||
_workers.Clear();
|
||||
}
|
||||
|
||||
public void Enqueue(string path, Action<string> onQueued)
|
||||
@@ -52,7 +71,7 @@ public sealed class UploadService
|
||||
Action<string> onQueued,
|
||||
Action<string> onUploading,
|
||||
Action<string> onSuccess,
|
||||
Action<string> onFailure,
|
||||
Action<string, string> onFailure,
|
||||
CancellationToken token)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(settings.UploadUrl))
|
||||
@@ -61,6 +80,9 @@ public sealed class UploadService
|
||||
}
|
||||
|
||||
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))
|
||||
{
|
||||
@@ -69,58 +91,72 @@ public sealed class UploadService
|
||||
try
|
||||
{
|
||||
onUploading(path);
|
||||
await WaitForFileReadyAsync(path, token);
|
||||
await UploadAsync(client, settings, path, token);
|
||||
var error = await UploadWithRetryAsync(client, settings, path, token);
|
||||
if (error is null)
|
||||
{
|
||||
onSuccess(path);
|
||||
}
|
||||
else
|
||||
{
|
||||
onFailure(path, error);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch
|
||||
{
|
||||
onFailure(path);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_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++)
|
||||
{
|
||||
var attemptError = await UploadOnceAsync(client, settings, path, token);
|
||||
if (attemptError.Success)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
for (var attempts = 0; attempts < 10; attempts++)
|
||||
if (!attemptError.Retryable || attempt >= MaxRetries)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
return attemptError.Error ?? "Upload fehlgeschlagen.";
|
||||
}
|
||||
|
||||
await Task.Delay(GetRetryDelay(attempt), token);
|
||||
}
|
||||
|
||||
return "Upload fehlgeschlagen.";
|
||||
}
|
||||
|
||||
private static async Task<UploadAttempt> UploadOnceAsync(
|
||||
HttpClient client,
|
||||
PhotoboothSettings settings,
|
||||
string path,
|
||||
CancellationToken token)
|
||||
{
|
||||
var readyError = await WaitForFileReadyAsync(path, token);
|
||||
if (readyError is not null)
|
||||
{
|
||||
return UploadAttempt.Fail(readyError, retryable: false);
|
||||
}
|
||||
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
await Task.Delay(500, token);
|
||||
continue;
|
||||
}
|
||||
|
||||
var info = new FileInfo(path);
|
||||
var size = info.Length;
|
||||
|
||||
if (size > 0 && size == lastSize)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lastSize = size;
|
||||
await Task.Delay(700, token);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task UploadAsync(HttpClient client, PhotoboothSettings settings, string path, CancellationToken token)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return;
|
||||
return UploadAttempt.Fail("Datei nicht gefunden.", retryable: false);
|
||||
}
|
||||
|
||||
using var content = new MultipartFormDataContent();
|
||||
@@ -145,8 +181,61 @@ public sealed class UploadService
|
||||
fileContent.Headers.ContentType = new MediaTypeHeaderValue(ResolveContentType(path));
|
||||
content.Add(fileContent, "media", Path.GetFileName(path));
|
||||
|
||||
try
|
||||
{
|
||||
var response = await client.PostAsync(settings.UploadUrl, content, token);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return UploadAttempt.Ok();
|
||||
}
|
||||
|
||||
var body = await ReadResponseBodyAsync(response, token);
|
||||
var status = $"{(int)response.StatusCode} {response.ReasonPhrase}".Trim();
|
||||
var message = string.IsNullOrWhiteSpace(body) ? status : $"{status} – {body}";
|
||||
return UploadAttempt.Fail(message, IsRetryableStatus(response.StatusCode));
|
||||
}
|
||||
catch (TaskCanceledException) when (!token.IsCancellationRequested)
|
||||
{
|
||||
return UploadAttempt.Fail("Zeitüberschreitung beim Upload.", retryable: true);
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
return UploadAttempt.Fail("Netzwerkfehler beim Upload.", retryable: true);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return UploadAttempt.Fail("Datei konnte nicht gelesen werden.", retryable: false);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<string?> WaitForFileReadyAsync(string path, CancellationToken token)
|
||||
{
|
||||
var lastSize = -1L;
|
||||
|
||||
for (var attempts = 0; attempts < 10; attempts++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
await Task.Delay(500, token);
|
||||
continue;
|
||||
}
|
||||
|
||||
var info = new FileInfo(path);
|
||||
var size = info.Length;
|
||||
|
||||
if (size > 0 && size == lastSize)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
lastSize = size;
|
||||
await Task.Delay(700, token);
|
||||
}
|
||||
|
||||
return "Datei ist noch in Bearbeitung.";
|
||||
}
|
||||
|
||||
private static string ResolveContentType(string path)
|
||||
@@ -158,4 +247,51 @@ public sealed class UploadService
|
||||
_ => "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",
|
||||
"spatie/laravel-translatable": "^6.11",
|
||||
"staudenmeir/belongs-to-through": "^2.17",
|
||||
"stripe/stripe-php": "*"
|
||||
"stripe/stripe-php": "*",
|
||||
"symfony/yaml": "^7.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"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",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "c1a772e5fe6f8d5c92fdbbea232f9f78",
|
||||
"content-hash": "5e1d60e650853d6113b01e1adaf49d65",
|
||||
"packages": [
|
||||
{
|
||||
"name": "anourvalar/eloquent-serialize",
|
||||
@@ -10043,6 +10043,82 @@
|
||||
],
|
||||
"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",
|
||||
"version": "v2.3.0",
|
||||
@@ -12852,82 +12928,6 @@
|
||||
],
|
||||
"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",
|
||||
"version": "1.3.1",
|
||||
|
||||
@@ -106,6 +106,24 @@ services:
|
||||
condition: service_healthy
|
||||
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:
|
||||
image: ${APP_IMAGE_REPO:-fotospiel-app}:${APP_IMAGE_TAG:-latest}
|
||||
env_file:
|
||||
@@ -340,6 +358,7 @@ volumes:
|
||||
external: true
|
||||
name: fotospiel-${APP_ENV:-prod}-storage
|
||||
app-bootstrap-cache:
|
||||
nuget-cache:
|
||||
photobooth-import:
|
||||
photobooth-ftp-auth:
|
||||
mysql-data:
|
||||
|
||||
@@ -53,6 +53,23 @@ refresh_config_cache() {
|
||||
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() {
|
||||
local name="$1" host="$2" port="$3" timeout="$4"
|
||||
local start
|
||||
@@ -120,6 +137,7 @@ ensure_helper_scripts
|
||||
prepare_storage
|
||||
refresh_config_cache
|
||||
wait_for_dependencies
|
||||
ensure_help_cache
|
||||
|
||||
cd "$APP_TARGET"
|
||||
exec "$@"
|
||||
|
||||
@@ -20,6 +20,12 @@ server {
|
||||
fastcgi_pass app:9000;
|
||||
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||
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_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.
|
||||
|
||||
- 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”).
|
||||
- Body (multipart/form-data): `media` (file or base64), `username`, `password`, optionally `name`, `email`, `message`.
|
||||
- Response:
|
||||
@@ -99,7 +99,7 @@ Use this when Sparkbooth runs in “Custom Upload” mode instead of FTP.
|
||||
Example cURL (JSON response):
|
||||
|
||||
```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 "username=PB123" \
|
||||
-F "password=SECRET" \
|
||||
@@ -109,7 +109,7 @@ curl -X POST https://app.example.com/api/v1/photobooth/sparkbooth/upload \
|
||||
Example cURL (request XML response):
|
||||
|
||||
```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 "username=PB123" \
|
||||
-F "password=SECRET" \
|
||||
|
||||
@@ -65,6 +65,25 @@ return [
|
||||
'benefit4' => 'Unterstuetzung, wenn du sie brauchst',
|
||||
'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_fallback' => 'Paket',
|
||||
'team_fallback' => 'dein Team',
|
||||
|
||||
@@ -65,6 +65,25 @@ return [
|
||||
'benefit4' => 'Friendly support whenever you need help',
|
||||
'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_fallback' => 'package',
|
||||
'team_fallback' => 'your team',
|
||||
|
||||
@@ -121,6 +121,7 @@
|
||||
--guest-radius: 14px;
|
||||
--guest-button-style: filled;
|
||||
--guest-link: #007aff;
|
||||
--guest-font-scale: 1;
|
||||
--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-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);
|
||||
}
|
||||
|
||||
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 {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
|
||||
@@ -218,6 +218,11 @@ export type PhotoboothStatus = {
|
||||
metrics?: PhotoboothStatusMetrics | null;
|
||||
};
|
||||
|
||||
export type PhotoboothConnectCode = {
|
||||
code: string;
|
||||
expires_at: string | null;
|
||||
};
|
||||
|
||||
export type EventAddonCheckout = {
|
||||
addon_key: string;
|
||||
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: {
|
||||
category: string;
|
||||
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_SETTINGS_PATH = adminPath('/mobile/settings');
|
||||
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_BILLING_PATH = adminPath('/mobile/billing');
|
||||
export const ADMIN_PACKAGE_SHOP_PATH = adminPath('/mobile/billing/shop');
|
||||
|
||||
@@ -1168,15 +1168,23 @@
|
||||
"mode": "Modus"
|
||||
},
|
||||
"mode": {
|
||||
"title": "Photobooth-Typ auswählen",
|
||||
"description": "Wähle zwischen klassischem FTP und Sparkbooth HTTP-Upload. Umschalten generiert neue Zugangsdaten.",
|
||||
"active": "Aktuell: {{mode}}"
|
||||
"title": "Uploader-Verbindung",
|
||||
"description": "Nutze die Fotospiel-Uploader-App für HTTP-Uploads. Beim Zurücksetzen werden neue Zugangsdaten generiert.",
|
||||
"active": "Aktuell: {{mode}}",
|
||||
"uploader": "Uploader-App (HTTP)"
|
||||
},
|
||||
"selector": {
|
||||
"title": "Verbindung",
|
||||
"description": "Nutze die Fotospiel-Uploader-App für HTTP-Uploads."
|
||||
},
|
||||
"credentials": {
|
||||
"heading": "FTP-Zugangsdaten",
|
||||
"description": "Teile die Zugangsdaten mit eurer Photobooth-Software.",
|
||||
"sparkboothTitle": "Sparkbooth-Upload (HTTP)",
|
||||
"sparkboothDescription": "Trage URL, Benutzername und Passwort in Sparkbooth ein. Antworten sind JSON (optional XML).",
|
||||
"heading": "Zugangsdaten für die Uploader-App",
|
||||
"description": "Teile die Zugangsdaten mit der Fotospiel-Uploader-App.",
|
||||
"uploaderTitle": "Uploader-App (HTTP)",
|
||||
"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",
|
||||
"port": "Port",
|
||||
"username": "Benutzername",
|
||||
@@ -1185,6 +1193,44 @@
|
||||
"postUrl": "Upload-URL",
|
||||
"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": {
|
||||
"enable": "Photobooth aktivieren",
|
||||
"disable": "Deaktivieren",
|
||||
@@ -1202,9 +1248,9 @@
|
||||
"title": "Setup-Checkliste",
|
||||
"description": "Durchlaufe die Schritte, bevor du Gästen Zugang gibst.",
|
||||
"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",
|
||||
"shareCopy": "Übergib Host, Benutzer & Passwort an den Betreiber.",
|
||||
"shareCopy": "Übergib URL, Benutzername & Passwort an den Betreiber.",
|
||||
"monitor": "Uploads beobachten",
|
||||
"monitorCopy": "Verfolge Uploads & Limits direkt im Dashboard."
|
||||
},
|
||||
@@ -1431,7 +1477,7 @@
|
||||
"photobooth": {
|
||||
"title": "Fotobox-Uploads",
|
||||
"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": {
|
||||
"backToEvent": "Zur Detailansicht",
|
||||
"allEvents": "Zur Eventliste"
|
||||
@@ -1872,23 +1918,63 @@
|
||||
"titleShort": "Branding",
|
||||
"previewTitle": "Guest-App-Vorschau",
|
||||
"previewSubtitle": "Aktuelle Farben & Schriften",
|
||||
"previewCta": "Fotos hochladen",
|
||||
"primary": "Primärfarbe",
|
||||
"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",
|
||||
"primaryColor": "Primärfarbe",
|
||||
"accentColor": "Akzentfarbe",
|
||||
"backgroundColor": "Hintergrundfarbe",
|
||||
"surfaceColor": "Flächenfarbe",
|
||||
"fonts": "Schriften",
|
||||
"headingFont": "Überschrift-Schrift",
|
||||
"headingFontPlaceholder": "SF Pro Display",
|
||||
"bodyFont": "Fließtext-Schrift",
|
||||
"bodyFontPlaceholder": "SF Pro Text",
|
||||
"fontSize": "Schriftgröße",
|
||||
"fontSizeSmall": "S",
|
||||
"fontSizeMedium": "M",
|
||||
"fontSizeLarge": "L",
|
||||
"logo": "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",
|
||||
"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)",
|
||||
"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",
|
||||
"saving": "Speichere...",
|
||||
"saveSuccess": "Branding gespeichert.",
|
||||
@@ -2346,7 +2432,7 @@
|
||||
"mobileProfile": {
|
||||
"title": "Profil",
|
||||
"settings": "Einstellungen",
|
||||
"account": "Account & Sicherheit",
|
||||
"account": "Account bearbeiten",
|
||||
"language": "Sprache",
|
||||
"languageDe": "Deutsch",
|
||||
"languageEn": "Englisch",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"profile": {
|
||||
"title": "Profil",
|
||||
"subtitle": "Verwalte deine Kontodaten und Zugangsdaten.",
|
||||
"loading": "Lädt ...",
|
||||
"sections": {
|
||||
"account": {
|
||||
"heading": "Account-Informationen",
|
||||
|
||||
@@ -881,15 +881,23 @@
|
||||
"mode": "Mode"
|
||||
},
|
||||
"mode": {
|
||||
"title": "Choose your photobooth type",
|
||||
"description": "Pick classic FTP or Sparkbooth HTTP upload. Switching regenerates credentials.",
|
||||
"active": "Current: {{mode}}"
|
||||
"title": "Uploader connection",
|
||||
"description": "Use the Fotospiel uploader app for live HTTP uploads. Rotating access regenerates credentials.",
|
||||
"active": "Current: {{mode}}",
|
||||
"uploader": "Uploader App (HTTP)"
|
||||
},
|
||||
"selector": {
|
||||
"title": "Connection",
|
||||
"description": "Use the Fotospiel uploader app for HTTP uploads."
|
||||
},
|
||||
"credentials": {
|
||||
"heading": "FTP credentials",
|
||||
"description": "Share these credentials with your photobooth software.",
|
||||
"sparkboothTitle": "Sparkbooth upload (HTTP)",
|
||||
"sparkboothDescription": "Enter URL, username and password in Sparkbooth. Responses default to JSON (XML optional).",
|
||||
"heading": "Uploader app credentials",
|
||||
"description": "Share these credentials with the Fotospiel uploader app.",
|
||||
"uploaderTitle": "Uploader App (HTTP)",
|
||||
"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",
|
||||
"port": "Port",
|
||||
"username": "Username",
|
||||
@@ -898,6 +906,44 @@
|
||||
"postUrl": "Upload URL",
|
||||
"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": {
|
||||
"enable": "Activate photobooth",
|
||||
"disable": "Disable",
|
||||
@@ -915,9 +961,9 @@
|
||||
"title": "Setup checklist",
|
||||
"description": "Complete each step before guests upload.",
|
||||
"enable": "Activate access",
|
||||
"enableCopy": "Enable the FTP account in your photobooth software.",
|
||||
"enableCopy": "Enable the uploader app connection for this event.",
|
||||
"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",
|
||||
"monitorCopy": "Watch uploads & limits in the dashboard."
|
||||
},
|
||||
@@ -1428,7 +1474,7 @@
|
||||
"photobooth": {
|
||||
"title": "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": {
|
||||
"backToEvent": "Back to detail view",
|
||||
"allEvents": "Back to event list"
|
||||
@@ -1876,23 +1922,63 @@
|
||||
"titleShort": "Branding",
|
||||
"previewTitle": "Guest app preview",
|
||||
"previewSubtitle": "Current colors & fonts",
|
||||
"previewCta": "Upload photos",
|
||||
"primary": "Primary",
|
||||
"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",
|
||||
"primaryColor": "Primary color",
|
||||
"accentColor": "Accent color",
|
||||
"backgroundColor": "Background color",
|
||||
"surfaceColor": "Surface color",
|
||||
"fonts": "Fonts",
|
||||
"headingFont": "Headline font",
|
||||
"headingFontPlaceholder": "SF Pro Display",
|
||||
"bodyFont": "Body font",
|
||||
"bodyFontPlaceholder": "SF Pro Text",
|
||||
"fontSize": "Font size",
|
||||
"fontSizeSmall": "S",
|
||||
"fontSizeMedium": "M",
|
||||
"fontSizeLarge": "L",
|
||||
"logo": "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",
|
||||
"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)",
|
||||
"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",
|
||||
"saving": "Saving...",
|
||||
"saveSuccess": "Branding saved.",
|
||||
@@ -2350,7 +2436,7 @@
|
||||
"mobileProfile": {
|
||||
"title": "Profile",
|
||||
"settings": "Settings",
|
||||
"account": "Account & security",
|
||||
"account": "Edit account",
|
||||
"language": "Language",
|
||||
"languageDe": "Deutsch",
|
||||
"languageEn": "English",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"profile": {
|
||||
"title": "Profile",
|
||||
"subtitle": "Manage your account details and credentials.",
|
||||
"loading": "Loading ...",
|
||||
"sections": {
|
||||
"account": {
|
||||
"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 {
|
||||
if (!event) {
|
||||
return null;
|
||||
|
||||
@@ -7,7 +7,7 @@ import { SizableText as Text } from '@tamagui/text';
|
||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||
import { MobileShell, HeaderActionButton } from './components/MobileShell';
|
||||
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 { ApiError, getApiErrorMessage } from '../lib/apiError';
|
||||
import { isBrandingAllowed } from '../lib/events';
|
||||
@@ -16,13 +16,45 @@ import toast from 'react-hot-toast';
|
||||
import { adminPath } from '../constants';
|
||||
import { useBackNavigation } from './hooks/useBackNavigation';
|
||||
import { ADMIN_COLORS, ADMIN_GRADIENTS, useAdminTheme } from './theme';
|
||||
import { extractBrandingForm, type BrandingFormValues } from '../lib/brandingForm';
|
||||
import { getContrastingTextColor } from '@/guest/lib/color';
|
||||
|
||||
type BrandingForm = {
|
||||
primary: string;
|
||||
accent: string;
|
||||
headingFont: string;
|
||||
bodyFont: string;
|
||||
logoDataUrl: string;
|
||||
const BRANDING_FORM_DEFAULTS = {
|
||||
primary: ADMIN_COLORS.primary,
|
||||
accent: ADMIN_COLORS.accent,
|
||||
background: '#ffffff',
|
||||
surface: '#ffffff',
|
||||
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 =
|
||||
@@ -58,13 +90,7 @@ export default function MobileBrandingPage() {
|
||||
const { textStrong, muted, subtle, border, primary, accentSoft, danger, surfaceMuted, surface } = useAdminTheme();
|
||||
|
||||
const [event, setEvent] = React.useState<TenantEvent | null>(null);
|
||||
const [form, setForm] = React.useState<BrandingForm>({
|
||||
primary: ADMIN_COLORS.primary,
|
||||
accent: ADMIN_COLORS.accent,
|
||||
headingFont: '',
|
||||
bodyFont: '',
|
||||
logoDataUrl: '',
|
||||
});
|
||||
const [form, setForm] = React.useState<BrandingFormValues>(BRANDING_FORM_BASE);
|
||||
const [watermarkForm, setWatermarkForm] = React.useState<WatermarkForm>({
|
||||
mode: 'base',
|
||||
assetPath: '',
|
||||
@@ -86,6 +112,8 @@ export default function MobileBrandingPage() {
|
||||
const [fonts, setFonts] = React.useState<TenantFont[]>([]);
|
||||
const [fontsLoading, setFontsLoading] = 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(() => {
|
||||
if (!slug) return;
|
||||
@@ -94,7 +122,7 @@ export default function MobileBrandingPage() {
|
||||
try {
|
||||
const data = await getEvent(slug);
|
||||
setEvent(data);
|
||||
setForm(extractBranding(data));
|
||||
setForm(extractBrandingForm(data.settings ?? {}, BRANDING_FORM_DEFAULTS));
|
||||
setWatermarkForm(extractWatermark(data));
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
@@ -119,12 +147,42 @@ export default function MobileBrandingPage() {
|
||||
});
|
||||
}, [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 previewHeadingFont = form.headingFont || 'Fraunces';
|
||||
const previewBodyFont = form.bodyFont || 'Manrope';
|
||||
const previewHeadingFont = previewForm.headingFont || 'Fraunces';
|
||||
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 brandingAllowed = isBrandingAllowed(event ?? null);
|
||||
const watermarkLocked = watermarkAllowed && !brandingAllowed;
|
||||
const brandingDisabled = !brandingAllowed || form.useDefaultBranding;
|
||||
|
||||
async function handleSave() {
|
||||
if (!event?.slug) return;
|
||||
@@ -155,18 +213,38 @@ export default function MobileBrandingPage() {
|
||||
is_active: event.is_active ?? undefined,
|
||||
};
|
||||
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 = {
|
||||
...(typeof settings.branding === 'object' ? (settings.branding as Record<string, unknown>) : {}),
|
||||
use_default_branding: form.useDefaultBranding,
|
||||
primary_color: form.primary,
|
||||
secondary_color: form.accent,
|
||||
accent_color: form.accent,
|
||||
background_color: form.background,
|
||||
surface_color: form.surface,
|
||||
font_family: form.bodyFont,
|
||||
heading_font: form.headingFont,
|
||||
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: {
|
||||
...(typeof (settings.branding as Record<string, unknown> | undefined)?.typography === 'object'
|
||||
? ((settings.branding as Record<string, unknown>).typography as Record<string, unknown>)
|
||||
: {}),
|
||||
heading: form.headingFont,
|
||||
body: form.bodyFont,
|
||||
size: form.fontSize,
|
||||
},
|
||||
palette: {
|
||||
...(typeof (settings.branding as Record<string, unknown> | undefined)?.palette === 'object'
|
||||
@@ -174,16 +252,31 @@ export default function MobileBrandingPage() {
|
||||
: {}),
|
||||
primary: form.primary,
|
||||
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);
|
||||
if (watermarkPayload) {
|
||||
@@ -217,7 +310,7 @@ export default function MobileBrandingPage() {
|
||||
|
||||
function handleReset() {
|
||||
if (event) {
|
||||
setForm(extractBranding(event));
|
||||
setForm(extractBrandingForm(event.settings ?? {}, BRANDING_FORM_DEFAULTS));
|
||||
setWatermarkForm(extractWatermark(event));
|
||||
}
|
||||
}
|
||||
@@ -435,25 +528,143 @@ export default function MobileBrandingPage() {
|
||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||
{t('events.branding.previewTitle', 'Guest App Preview')}
|
||||
</Text>
|
||||
<YStack borderRadius={16} borderWidth={1} borderColor={border} backgroundColor={surfaceMuted} padding="$3" space="$2" alignItems="center">
|
||||
<YStack width="100%" borderRadius={12} backgroundColor={surface} borderWidth={1} borderColor={border} overflow="hidden">
|
||||
<YStack backgroundColor={form.primary} height={64} />
|
||||
<YStack padding="$3" space="$1.5">
|
||||
<Text fontSize="$md" fontWeight="800" color={textStrong} style={{ fontFamily: previewHeadingFont }}>
|
||||
<YStack borderRadius={16} borderWidth={1} borderColor={border} backgroundColor={previewForm.background} padding="$3" space="$2" alignItems="center">
|
||||
<YStack width="100%" borderRadius={12} backgroundColor={previewForm.surface} borderWidth={1} borderColor={border} overflow="hidden">
|
||||
<YStack
|
||||
height={64}
|
||||
style={{ background: `linear-gradient(135deg, ${previewForm.primary}, ${previewForm.accent})` }}
|
||||
/>
|
||||
<YStack padding="$3" space="$2">
|
||||
<XStack
|
||||
alignItems="center"
|
||||
space="$2"
|
||||
flexDirection={previewForm.logoPosition === 'center' ? 'column' : previewForm.logoPosition === 'right' ? 'row-reverse' : 'row'}
|
||||
justifyContent={previewForm.logoPosition === 'center' ? 'center' : 'flex-start'}
|
||||
>
|
||||
<YStack
|
||||
width={previewLogoSize}
|
||||
height={previewLogoSize}
|
||||
borderRadius={previewLogoSize}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
backgroundColor={previewForm.accent}
|
||||
>
|
||||
{previewLogoUrl ? (
|
||||
<img
|
||||
src={previewLogoUrl}
|
||||
alt={t('events.branding.logoAlt', 'Logo')}
|
||||
style={{ width: previewLogoSize - 6, height: previewLogoSize - 6, borderRadius: previewLogoSize, objectFit: 'cover' }}
|
||||
/>
|
||||
) : (
|
||||
<Text fontSize="$sm" color={previewSurfaceText} fontWeight="700">
|
||||
{previewLogoValue || previewInitials}
|
||||
</Text>
|
||||
)}
|
||||
</YStack>
|
||||
<YStack>
|
||||
<Text
|
||||
fontWeight="800"
|
||||
color={previewSurfaceText}
|
||||
style={{ fontFamily: previewHeadingFont, fontSize: 18 * previewScale }}
|
||||
>
|
||||
{previewTitle}
|
||||
</Text>
|
||||
<Text fontSize="$sm" color={muted} style={{ fontFamily: previewBodyFont }}>
|
||||
<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">
|
||||
<ColorSwatch color={form.primary} label={t('events.branding.primary', 'Primary')} />
|
||||
<ColorSwatch color={form.accent} label={t('events.branding.accent', 'Accent')} />
|
||||
<ColorSwatch color={previewForm.primary} label={t('events.branding.primary', 'Primary')} />
|
||||
<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>
|
||||
</YStack>
|
||||
</YStack>
|
||||
</YStack>
|
||||
</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">
|
||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||
{t('events.branding.colors', 'Colors')}
|
||||
@@ -462,11 +673,25 @@ export default function MobileBrandingPage() {
|
||||
label={t('events.branding.primary', 'Primary Color')}
|
||||
value={form.primary}
|
||||
onChange={(value) => setForm((prev) => ({ ...prev, primary: value }))}
|
||||
disabled={brandingDisabled}
|
||||
/>
|
||||
<ColorField
|
||||
label={t('events.branding.accent', 'Accent Color')}
|
||||
value={form.accent}
|
||||
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>
|
||||
|
||||
@@ -483,6 +708,7 @@ export default function MobileBrandingPage() {
|
||||
setFontField('heading');
|
||||
setShowFontsSheet(true);
|
||||
}}
|
||||
disabled={brandingDisabled}
|
||||
/>
|
||||
<InputField
|
||||
label={t('events.branding.bodyFont', 'Body Font')}
|
||||
@@ -493,13 +719,64 @@ export default function MobileBrandingPage() {
|
||||
setFontField('body');
|
||||
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 space="$3">
|
||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||
{t('events.branding.logo', 'Logo')}
|
||||
</Text>
|
||||
<Text fontSize="$sm" color={muted}>
|
||||
{t('events.branding.logoHint', 'Upload a logo or use an emoji for the guest header.')}
|
||||
</Text>
|
||||
<XStack space="$2">
|
||||
<ModeButton
|
||||
label={t('events.branding.logoModeUpload', 'Upload')}
|
||||
active={form.logoMode === 'upload'}
|
||||
onPress={() => setForm((prev) => ({ ...prev, logoMode: 'upload' }))}
|
||||
disabled={brandingDisabled}
|
||||
/>
|
||||
<ModeButton
|
||||
label={t('events.branding.logoModeEmoticon', 'Emoticon')}
|
||||
active={form.logoMode === 'emoticon'}
|
||||
onPress={() => setForm((prev) => ({ ...prev, logoMode: 'emoticon' }))}
|
||||
disabled={brandingDisabled}
|
||||
/>
|
||||
</XStack>
|
||||
|
||||
{form.logoMode === 'emoticon' ? (
|
||||
<InputField
|
||||
label={t('events.branding.logoValue', 'Emoticon')}
|
||||
value={form.logoValue}
|
||||
placeholder={t('events.branding.logoValuePlaceholder', '🎉')}
|
||||
onChange={(value) => setForm((prev) => ({ ...prev, logoValue: value }))}
|
||||
disabled={brandingDisabled}
|
||||
/>
|
||||
) : (
|
||||
<YStack
|
||||
borderRadius={14}
|
||||
borderWidth={1}
|
||||
@@ -521,8 +798,12 @@ export default function MobileBrandingPage() {
|
||||
<CTAButton
|
||||
label={t('events.branding.replaceLogo', 'Replace logo')}
|
||||
onPress={() => document.getElementById('branding-logo-input')?.click()}
|
||||
disabled={brandingDisabled}
|
||||
/>
|
||||
<Pressable onPress={() => setForm((prev) => ({ ...prev, logoDataUrl: '' }))}>
|
||||
<Pressable
|
||||
disabled={brandingDisabled}
|
||||
onPress={() => setForm((prev) => ({ ...prev, logoDataUrl: '' }))}
|
||||
>
|
||||
<XStack
|
||||
alignItems="center"
|
||||
space="$1.5"
|
||||
@@ -543,10 +824,10 @@ export default function MobileBrandingPage() {
|
||||
) : (
|
||||
<>
|
||||
<ImageIcon size={28} color={subtle} />
|
||||
<Text fontSize="$sm" color={muted} textAlign="center">
|
||||
{t('events.branding.logoHint', 'Upload a logo to brand guest invites and QR posters.')}
|
||||
</Text>
|
||||
<Pressable onPress={() => document.getElementById('branding-logo-input')?.click()}>
|
||||
<Pressable
|
||||
disabled={brandingDisabled}
|
||||
onPress={() => document.getElementById('branding-logo-input')?.click()}
|
||||
>
|
||||
<XStack
|
||||
alignItems="center"
|
||||
space="$2"
|
||||
@@ -570,6 +851,7 @@ export default function MobileBrandingPage() {
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: 'none' }}
|
||||
disabled={brandingDisabled}
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
@@ -592,6 +874,104 @@ export default function MobileBrandingPage() {
|
||||
}}
|
||||
/>
|
||||
</YStack>
|
||||
)}
|
||||
|
||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||
{t('events.branding.logoPosition', 'Position')}
|
||||
</Text>
|
||||
<XStack space="$2">
|
||||
<ModeButton
|
||||
label={t('events.branding.positionLeft', 'Left')}
|
||||
active={form.logoPosition === 'left'}
|
||||
onPress={() => setForm((prev) => ({ ...prev, logoPosition: 'left' }))}
|
||||
disabled={brandingDisabled}
|
||||
/>
|
||||
<ModeButton
|
||||
label={t('events.branding.positionCenter', 'Center')}
|
||||
active={form.logoPosition === 'center'}
|
||||
onPress={() => setForm((prev) => ({ ...prev, logoPosition: 'center' }))}
|
||||
disabled={brandingDisabled}
|
||||
/>
|
||||
<ModeButton
|
||||
label={t('events.branding.positionRight', 'Right')}
|
||||
active={form.logoPosition === 'right'}
|
||||
onPress={() => setForm((prev) => ({ ...prev, logoPosition: 'right' }))}
|
||||
disabled={brandingDisabled}
|
||||
/>
|
||||
</XStack>
|
||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||
{t('events.branding.logoSize', 'Size')}
|
||||
</Text>
|
||||
<XStack space="$2">
|
||||
<ModeButton
|
||||
label={t('events.branding.logoSizeSmall', 'S')}
|
||||
active={form.logoSize === 's'}
|
||||
onPress={() => setForm((prev) => ({ ...prev, logoSize: 's' }))}
|
||||
disabled={brandingDisabled}
|
||||
/>
|
||||
<ModeButton
|
||||
label={t('events.branding.logoSizeMedium', 'M')}
|
||||
active={form.logoSize === 'm'}
|
||||
onPress={() => setForm((prev) => ({ ...prev, logoSize: 'm' }))}
|
||||
disabled={brandingDisabled}
|
||||
/>
|
||||
<ModeButton
|
||||
label={t('events.branding.logoSizeLarge', 'L')}
|
||||
active={form.logoSize === 'l'}
|
||||
onPress={() => setForm((prev) => ({ ...prev, logoSize: 'l' }))}
|
||||
disabled={brandingDisabled}
|
||||
/>
|
||||
</XStack>
|
||||
</MobileCard>
|
||||
|
||||
<MobileCard space="$3">
|
||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||
{t('events.branding.buttons', 'Buttons & Links')}
|
||||
</Text>
|
||||
<Text fontSize="$sm" color={muted}>
|
||||
{t('events.branding.buttonsHint', 'Style, radius, and link color for CTA buttons.')}
|
||||
</Text>
|
||||
<XStack space="$2">
|
||||
<ModeButton
|
||||
label={t('events.branding.buttonFilled', 'Filled')}
|
||||
active={form.buttonStyle === 'filled'}
|
||||
onPress={() => setForm((prev) => ({ ...prev, buttonStyle: 'filled' }))}
|
||||
disabled={brandingDisabled}
|
||||
/>
|
||||
<ModeButton
|
||||
label={t('events.branding.buttonOutline', 'Outline')}
|
||||
active={form.buttonStyle === 'outline'}
|
||||
onPress={() => setForm((prev) => ({ ...prev, buttonStyle: 'outline' }))}
|
||||
disabled={brandingDisabled}
|
||||
/>
|
||||
</XStack>
|
||||
<LabeledSlider
|
||||
label={t('events.branding.buttonRadius', 'Radius')}
|
||||
value={form.buttonRadius}
|
||||
min={0}
|
||||
max={32}
|
||||
step={1}
|
||||
onChange={(value) => setForm((prev) => ({ ...prev, buttonRadius: value }))}
|
||||
disabled={brandingDisabled}
|
||||
/>
|
||||
<ColorField
|
||||
label={t('events.branding.buttonPrimary', 'Button Primary')}
|
||||
value={form.buttonPrimary}
|
||||
onChange={(value) => setForm((prev) => ({ ...prev, buttonPrimary: value }))}
|
||||
disabled={brandingDisabled}
|
||||
/>
|
||||
<ColorField
|
||||
label={t('events.branding.buttonSecondary', 'Button Secondary')}
|
||||
value={form.buttonSecondary}
|
||||
onChange={(value) => setForm((prev) => ({ ...prev, buttonSecondary: value }))}
|
||||
disabled={brandingDisabled}
|
||||
/>
|
||||
<ColorField
|
||||
label={t('events.branding.linkColor', 'Link Color')}
|
||||
value={form.linkColor}
|
||||
onChange={(value) => setForm((prev) => ({ ...prev, linkColor: value }))}
|
||||
disabled={brandingDisabled}
|
||||
/>
|
||||
</MobileCard>
|
||||
</>
|
||||
) : (
|
||||
@@ -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 {
|
||||
const settings = (event.settings as Record<string, unknown>) ?? {};
|
||||
const wm = (settings.watermark as Record<string, unknown>) ?? {};
|
||||
@@ -762,10 +1122,47 @@ function renderName(name: TenantEvent['name']): string {
|
||||
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();
|
||||
return (
|
||||
<YStack space="$2">
|
||||
<YStack space="$2" opacity={disabled ? 0.6 : 1}>
|
||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||
{label}
|
||||
</Text>
|
||||
@@ -774,6 +1171,7 @@ function ColorField({ label, value, onChange }: { label: string; value: string;
|
||||
type="color"
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
disabled={disabled}
|
||||
style={{ width: 52, height: 52, borderRadius: 12, border: `1px solid ${border}`, background: surface }}
|
||||
/>
|
||||
<Text fontSize="$sm" color={muted}>
|
||||
@@ -803,6 +1201,7 @@ function InputField({
|
||||
onChange,
|
||||
onPicker,
|
||||
children,
|
||||
disabled,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
@@ -810,10 +1209,11 @@ function InputField({
|
||||
onChange: (next: string) => void;
|
||||
onPicker?: () => void;
|
||||
children?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const { textStrong, border, surface, primary } = useAdminTheme();
|
||||
return (
|
||||
<YStack space="$2">
|
||||
<YStack space="$2" opacity={disabled ? 0.6 : 1}>
|
||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||
{label}
|
||||
</Text>
|
||||
@@ -834,6 +1234,7 @@ function InputField({
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
disabled={disabled}
|
||||
style={{
|
||||
flex: 1,
|
||||
height: '100%',
|
||||
@@ -846,7 +1247,7 @@ function InputField({
|
||||
/>
|
||||
)}
|
||||
{onPicker ? (
|
||||
<Pressable onPress={onPicker}>
|
||||
<Pressable onPress={onPicker} disabled={disabled}>
|
||||
<ChevronDown size={16} color={primary} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
@@ -1078,3 +1479,34 @@ function TabButton({ label, active, onPress }: { label: string; active: boolean;
|
||||
</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 { useNavigate, useParams } from 'react-router-dom';
|
||||
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 { SizableText as Text } from '@tamagui/text';
|
||||
import { Switch } from '@tamagui/switch';
|
||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||
import { MobileShell, HeaderActionButton } from './components/MobileShell';
|
||||
import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Primitives';
|
||||
@@ -14,12 +13,14 @@ import {
|
||||
enableEventPhotobooth,
|
||||
disableEventPhotobooth,
|
||||
rotateEventPhotobooth,
|
||||
createEventPhotoboothConnectCode,
|
||||
sendEventPhotoboothUploaderEmail,
|
||||
PhotoboothStatus,
|
||||
TenantEvent,
|
||||
} from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import { getApiErrorMessage } from '../lib/apiError';
|
||||
import { formatEventDate } from '../lib/events';
|
||||
import { formatEventDate, formatEventDateTime } from '../lib/events';
|
||||
import toast from 'react-hot-toast';
|
||||
import { adminPath } from '../constants';
|
||||
import { useBackNavigation } from './hooks/useBackNavigation';
|
||||
@@ -34,10 +35,14 @@ export default function MobileEventPhotoboothPage() {
|
||||
|
||||
const [event, setEvent] = React.useState<TenantEvent | 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 [updating, setUpdating] = React.useState(false);
|
||||
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 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)]);
|
||||
setEvent(eventData);
|
||||
setStatus(statusData);
|
||||
setSelectedMode(statusData.mode ?? 'ftp');
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError(getApiErrorMessage(err, t('management.photobooth.errors.loadFailed', 'Photobooth-Link konnte nicht geladen werden.')));
|
||||
@@ -64,20 +68,14 @@ export default function MobileEventPhotoboothPage() {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (status?.mode) {
|
||||
setSelectedMode(status.mode);
|
||||
}
|
||||
}, [status?.mode]);
|
||||
|
||||
const handleEnable = async (mode?: 'ftp' | 'sparkbooth') => {
|
||||
const handleEnable = async () => {
|
||||
if (!slug) return;
|
||||
const nextMode = mode ?? selectedMode ?? status?.mode ?? 'ftp';
|
||||
const nextMode = 'sparkbooth';
|
||||
setUpdating(true);
|
||||
try {
|
||||
const result = await enableEventPhotobooth(slug, { mode: nextMode });
|
||||
setStatus(result);
|
||||
setSelectedMode(result.mode ?? nextMode);
|
||||
toast.success(t('management.photobooth.actions.enable', 'Zugang aktiviert'));
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
@@ -90,12 +88,11 @@ export default function MobileEventPhotoboothPage() {
|
||||
|
||||
const handleDisable = async () => {
|
||||
if (!slug) return;
|
||||
const mode = status?.mode ?? selectedMode ?? 'ftp';
|
||||
const mode = 'sparkbooth';
|
||||
setUpdating(true);
|
||||
try {
|
||||
const result = await disableEventPhotobooth(slug, { mode });
|
||||
setStatus(result);
|
||||
setSelectedMode(result.mode ?? mode);
|
||||
toast.success(t('management.photobooth.actions.disable', 'Zugang deaktiviert'));
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
@@ -108,12 +105,11 @@ export default function MobileEventPhotoboothPage() {
|
||||
|
||||
const handleRotate = async () => {
|
||||
if (!slug) return;
|
||||
const mode = selectedMode ?? status?.mode ?? 'ftp';
|
||||
const mode = 'sparkbooth';
|
||||
setUpdating(true);
|
||||
try {
|
||||
const result = await rotateEventPhotobooth(slug, { mode });
|
||||
setStatus(result);
|
||||
setSelectedMode(result.mode ?? mode);
|
||||
toast.success(t('management.photobooth.presets.actions.rotate', 'Zugang zurückgesetzt'));
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
@@ -124,39 +120,57 @@ export default function MobileEventPhotoboothPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const activeMode = selectedMode ?? status?.mode ?? 'ftp';
|
||||
const isSpark = activeMode === 'sparkbooth';
|
||||
const handleGenerateConnectCode = async () => {
|
||||
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 ftp = status?.ftp ?? null;
|
||||
const metrics = isSpark ? spark?.metrics ?? null : status?.metrics ?? null;
|
||||
const expiresAt = isSpark ? spark?.expires_at ?? status?.expires_at : status?.expires_at ?? spark?.expires_at;
|
||||
const metrics = spark?.metrics ?? null;
|
||||
const expiresAt = spark?.expires_at ?? status?.expires_at;
|
||||
const lastUploadAt = metrics?.last_upload_at;
|
||||
const uploads24h = metrics?.uploads_24h ?? metrics?.uploads_today;
|
||||
const uploadsTotal = metrics?.uploads_total;
|
||||
const connectionPath = status?.path ?? '—';
|
||||
const ftpUrl = status?.ftp_url ?? '—';
|
||||
const uploadUrl = isSpark ? spark?.upload_url ?? status?.upload_url : null;
|
||||
const responseFormat = spark?.response_format ?? 'json';
|
||||
const username = isSpark ? spark?.username ?? status?.username : status?.username ?? spark?.username ?? null;
|
||||
const password = isSpark ? spark?.password ?? status?.password : status?.password ?? spark?.password ?? null;
|
||||
const uploadUrl = spark?.upload_url ?? status?.upload_url;
|
||||
const username = spark?.username ?? status?.username ?? null;
|
||||
const password = spark?.password ?? status?.password ?? null;
|
||||
|
||||
const modeLabel =
|
||||
activeMode === 'sparkbooth'
|
||||
? t('photobooth.credentials.sparkboothTitle', 'Sparkbooth / HTTP')
|
||||
: t('photobooth.credentials.heading', 'FTP (Classic)');
|
||||
const modeLabel = t('photobooth.mode.uploader', 'Uploader App (HTTP)');
|
||||
|
||||
const isActive = Boolean(status?.enabled);
|
||||
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 (
|
||||
<MobileShell
|
||||
activeTab="home"
|
||||
@@ -185,145 +199,161 @@ export default function MobileEventPhotoboothPage() {
|
||||
) : (
|
||||
<YStack space="$2">
|
||||
<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')}
|
||||
<YStack space="$1">
|
||||
<Text fontSize="$sm" fontWeight="800" color={text}>
|
||||
{t('photobooth.steps.activate.title', '1. Photobooth aktivieren')}
|
||||
</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 })}
|
||||
{t('photobooth.steps.activate.description', 'Schalte den Upload-Zugang fuer dieses Event frei.')}
|
||||
</Text>
|
||||
</YStack>
|
||||
<YStack alignItems="flex-end" space="$2">
|
||||
<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>
|
||||
<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')}
|
||||
{t('photobooth.mode.active', 'Current: {{mode}}', { mode: modeLabel })}
|
||||
</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">
|
||||
{isSpark ? (
|
||||
<>
|
||||
<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.password', 'Password')} value={password ?? '—'} border={border} masked />
|
||||
<CredentialRow label={t('photobooth.sparkbooth.format', 'Response format')} value={responseFormat.toUpperCase()} border={border} />
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('photobooth.sparkbooth.hint', 'POST with media file or base64 "media" field; username/password required.')}
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CredentialRow label={t('photobooth.credentials.host', 'Host')} value={ftp?.host ?? '—'} border={border} />
|
||||
<CredentialRow label={t('photobooth.credentials.port', 'Port')} value={String(ftp?.port ?? '—')} border={border} />
|
||||
<CredentialRow label={t('photobooth.credentials.path', 'Target folder')} value={connectionPath} border={border} />
|
||||
<CredentialRow label={t('photobooth.credentials.postUrl', 'FTP URL')} value={ftpUrl} border={border} />
|
||||
<CredentialRow label={t('photobooth.credentials.username', 'Username')} value={username ?? '—'} border={border} />
|
||||
<CredentialRow label={t('photobooth.credentials.password', 'Password')} value={password ?? '—'} border={border} masked />
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('photobooth.credentials.ftpsHint', 'Use FTPS if required; uploads go into the target folder for this event.')}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</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}>
|
||||
<XStack space="$2" marginTop="$2">
|
||||
<CTAButton
|
||||
label={isActive ? t('photobooth.actions.disable', 'Disable uploads') : t('photobooth.actions.enable', 'Enable uploads')}
|
||||
onPress={() => (isActive ? handleDisable() : handleEnable(selectedMode))}
|
||||
onPress={() => (isActive ? handleDisable() : handleEnable())}
|
||||
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 }}
|
||||
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.username', 'Username')} value={username ?? '—'} border={border} />
|
||||
<CredentialRow label={t('photobooth.credentials.password', 'Password')} value={password ?? '—'} border={border} masked />
|
||||
</YStack>
|
||||
) : (
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('photobooth.credentials.hidden', 'Credentials are hidden. Tap to show them.')}
|
||||
</Text>
|
||||
)}
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('photobooth.uploader.hint', 'POST with media file or base64 "media" field; app uses these credentials.')}
|
||||
</Text>
|
||||
</YStack>
|
||||
</MobileCard>
|
||||
|
||||
<MobileCard space="$2">
|
||||
<Text fontSize="$sm" fontWeight="700" color={text}>
|
||||
{t('photobooth.status.heading', 'Status')}
|
||||
|
||||
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 { useAuth } from '../auth/context';
|
||||
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 { useAppearance } from '@/hooks/use-appearance';
|
||||
import { useBackNavigation } from './hooks/useBackNavigation';
|
||||
@@ -85,7 +85,7 @@ export default function MobileProfilePage() {
|
||||
<YStack space="$4">
|
||||
<YGroup {...({ borderRadius: "$4", borderWidth: 1, borderColor: borderColor, overflow: "hidden" } as any)}>
|
||||
<YGroup.Item>
|
||||
<Pressable onPress={() => navigate(adminPath('/mobile/profile/security'))}>
|
||||
<Pressable onPress={() => navigate(ADMIN_PROFILE_ACCOUNT_PATH)}>
|
||||
<ListItem
|
||||
hoverTheme
|
||||
pressTheme
|
||||
@@ -93,7 +93,7 @@ export default function MobileProfilePage() {
|
||||
paddingHorizontal="$3"
|
||||
title={
|
||||
<Text fontSize="$sm" color={textColor}>
|
||||
{t('mobileProfile.account', 'Account & security')}
|
||||
{t('mobileProfile.account', 'Account bearbeiten')}
|
||||
</Text>
|
||||
}
|
||||
iconAfter={<Settings size={18} color={subtle} />}
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
NotificationPreferences,
|
||||
} from '../api';
|
||||
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 { useDevicePermissions } from './hooks/useDevicePermissions';
|
||||
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>
|
||||
) : null}
|
||||
<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') })} />
|
||||
</XStack>
|
||||
</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 MobileNotificationsPage = React.lazy(() => import('./mobile/NotificationsPage'));
|
||||
const MobileProfilePage = React.lazy(() => import('./mobile/ProfilePage'));
|
||||
const MobileProfileAccountPage = React.lazy(() => import('./mobile/ProfileAccountPage'));
|
||||
const MobileBillingPage = React.lazy(() => import('./mobile/BillingPage'));
|
||||
const MobilePackageShopPage = React.lazy(() => import('./mobile/PackageShopPage'));
|
||||
const MobileSettingsPage = React.lazy(() => import('./mobile/SettingsPage'));
|
||||
@@ -212,6 +213,7 @@ export const router = createBrowserRouter([
|
||||
{ path: 'mobile/notifications', element: <MobileNotificationsPage /> },
|
||||
{ path: 'mobile/notifications/:notificationId', element: <MobileNotificationsPage /> },
|
||||
{ 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/shop', element: <RequireAdminAccess><MobilePackageShopPage /></RequireAdminAccess> },
|
||||
{ path: 'mobile/settings', element: <RequireAdminAccess><MobileSettingsPage /></RequireAdminAccess> },
|
||||
|
||||
@@ -37,27 +37,36 @@ export default function FiltersBar({
|
||||
return (
|
||||
<div
|
||||
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,
|
||||
)}
|
||||
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">
|
||||
{filters.map((filter, index) => {
|
||||
const isActive = value === filter.value;
|
||||
return (
|
||||
<div key={filter.value} className="flex items-center">
|
||||
<button
|
||||
key={filter.value}
|
||||
type="button"
|
||||
onClick={() => onChange(filter.value)}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 rounded-full border px-4 py-2 transition',
|
||||
value === filter.value
|
||||
? 'border-pink-500 bg-gradient-to-r from-pink-500 to-pink-600 text-white shadow'
|
||||
: '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'
|
||||
: 'text-muted-foreground hover:bg-pink-50 hover:text-pink-600',
|
||||
)}
|
||||
>
|
||||
{filter.icon}
|
||||
{t(filter.labelKey)}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -111,33 +111,33 @@ export default function GalleryPreview({ token }: Props) {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 overflow-x-auto pb-1 text-sm font-medium [-ms-overflow-style:none] [scrollbar-width:none]">
|
||||
{filters.map((filter) => {
|
||||
<div className="flex overflow-x-auto pb-1 text-xs font-semibold [-ms-overflow-style:none] [scrollbar-width:none]">
|
||||
<div className="inline-flex items-center rounded-full border border-border/70 bg-white/80 p-1 shadow-sm backdrop-blur dark:border-white/10 dark:bg-slate-950/70">
|
||||
{filters.map((filter, index) => {
|
||||
const isActive = mode === filter.value;
|
||||
|
||||
return (
|
||||
<div key={filter.value} className="flex items-center">
|
||||
<button
|
||||
key={filter.value}
|
||||
type="button"
|
||||
onClick={() => setMode(filter.value)}
|
||||
style={{
|
||||
borderRadius: radius,
|
||||
border: isActive ? `1px solid ${branding.primaryColor}` : `1px solid ${branding.primaryColor}22`,
|
||||
background: isActive ? branding.primaryColor : undefined,
|
||||
boxShadow: isActive ? `0 8px 18px ${branding.primaryColor}33` : 'none',
|
||||
}}
|
||||
className={cn(
|
||||
'px-4 py-1 transition',
|
||||
'inline-flex items-center rounded-full px-3 py-1.5 transition',
|
||||
isActive
|
||||
? 'text-white'
|
||||
: 'bg-[var(--guest-surface)] text-foreground dark:bg-slate-950/70 dark:text-slate-100',
|
||||
? 'bg-pink-500 text-white shadow'
|
||||
: 'text-muted-foreground hover:bg-pink-50 hover:text-pink-600',
|
||||
)}
|
||||
>
|
||||
{filter.label}
|
||||
<span className="whitespace-nowrap">{filter.label}</span>
|
||||
</button>
|
||||
{index < filters.length - 1 && (
|
||||
<span className="mx-1 h-4 w-px bg-border/60 dark:bg-white/10" aria-hidden />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && <p className="text-sm text-muted-foreground">Lädt…</p>}
|
||||
{!loading && items.length === 0 && (
|
||||
@@ -147,37 +147,29 @@ export default function GalleryPreview({ token }: Props) {
|
||||
</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) => (
|
||||
<Link
|
||||
key={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"
|
||||
style={{
|
||||
borderRadius: radius,
|
||||
border: `1px solid ${branding.primaryColor}22`,
|
||||
boxShadow: `0 12px 26px ${branding.primaryColor}22`,
|
||||
}}
|
||||
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={{ borderRadius: radius }}
|
||||
>
|
||||
<div className="relative">
|
||||
<img
|
||||
src={p.thumbnail_path || p.file_path}
|
||||
alt={p.title || 'Foto'}
|
||||
className="h-40 w-full object-cover transition duration-300 group-hover:scale-105"
|
||||
className="aspect-[3/4] w-full object-cover transition duration-300 group-hover:scale-105"
|
||||
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}>
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-black/50 via-black/0 to-transparent" aria-hidden />
|
||||
</div>
|
||||
<div className="space-y-2 px-3 pb-3 pt-3">
|
||||
<p className="text-sm font-semibold leading-tight line-clamp-2 text-foreground" 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 />
|
||||
<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>
|
||||
|
||||
@@ -38,6 +38,18 @@ const EVENT_ICON_COMPONENTS: Record<string, React.ComponentType<{ className?: st
|
||||
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 }>> = {
|
||||
broadcast: MessageSquare,
|
||||
feedback_request: MessageSquare,
|
||||
@@ -69,18 +81,25 @@ function getInitials(name: string): string {
|
||||
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) {
|
||||
return (
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-white shadow-sm">
|
||||
<img src={logo.value} alt={name} className="h-9 w-9 rounded-full object-contain" />
|
||||
<div className={`flex items-center justify-center rounded-full bg-white shadow-sm ${sizes.container}`}>
|
||||
<img src={logo.value} alt={name} className={`rounded-full object-contain ${sizes.image}`} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (logo?.mode === 'emoticon' && logo.value && isLikelyEmoji(logo.value)) {
|
||||
return (
|
||||
<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 }}
|
||||
>
|
||||
<span aria-hidden>{logo.value}</span>
|
||||
@@ -97,10 +116,10 @@ function renderEventAvatar(name: string, icon: unknown, accentColor: string, tex
|
||||
if (IconComponent) {
|
||||
return (
|
||||
<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 }}
|
||||
>
|
||||
<IconComponent className="h-5 w-5" aria-hidden />
|
||||
<IconComponent className={sizes.icon} aria-hidden />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -108,7 +127,7 @@ function renderEventAvatar(name: string, icon: unknown, accentColor: string, tex
|
||||
if (isLikelyEmoji(trimmed)) {
|
||||
return (
|
||||
<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 }}
|
||||
>
|
||||
<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 bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined;
|
||||
const basePath = eventToken ? `/e/${encodeURIComponent(eventToken)}` : '';
|
||||
const showGalleryHelp = Boolean(
|
||||
basePath
|
||||
&& (location.pathname.startsWith(`${basePath}/gallery`) || location.pathname.startsWith(`${basePath}/photo`))
|
||||
);
|
||||
const galleryHelpHref = basePath ? `${basePath}/help/gallery-and-sharing` : '/help/gallery-and-sharing';
|
||||
|
||||
const logoPosition = branding.logo?.position ?? 'left';
|
||||
const headerStyle: React.CSSProperties = {
|
||||
background: `linear-gradient(135deg, ${branding.primaryColor}, ${branding.secondaryColor})`,
|
||||
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"
|
||||
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)}
|
||||
<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="flex items-center gap-2 text-xs text-white/70" style={bodyFont ? { fontFamily: bodyFont } : undefined}>
|
||||
{stats && tasksEnabled && (
|
||||
@@ -259,15 +283,6 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
|
||||
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 />
|
||||
<SettingsSheet />
|
||||
</div>
|
||||
|
||||
@@ -72,17 +72,17 @@ export default function RouteTransition({ children }: { children?: React.ReactNo
|
||||
};
|
||||
|
||||
const tabVariants = {
|
||||
enter: { opacity: 0, scale: 0.985 },
|
||||
center: { opacity: 1, scale: 1 },
|
||||
exit: { opacity: 0, scale: 0.985 },
|
||||
enter: { opacity: 0, y: 8 },
|
||||
center: { opacity: 1, y: 0 },
|
||||
exit: { opacity: 0, y: -8 },
|
||||
};
|
||||
|
||||
const transition = kind === 'tab'
|
||||
? { duration: 0.18, ease: [0.22, 0.61, 0.36, 1] }
|
||||
: { duration: 0.24, ease: [0.25, 0.8, 0.25, 1] };
|
||||
? { duration: 0.22, ease: [0.22, 0.61, 0.36, 1] }
|
||||
: { duration: 0.28, ease: [0.25, 0.8, 0.25, 1] };
|
||||
|
||||
return (
|
||||
<AnimatePresence initial={false}>
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
<motion.div
|
||||
key={location.pathname}
|
||||
custom={{ direction }}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
@@ -23,6 +23,7 @@ import { useTranslation } from '../i18n/useTranslation';
|
||||
import type { LocaleCode } from '../i18n/messages';
|
||||
import { useHapticsPreference } from '../hooks/useHapticsPreference';
|
||||
import { triggerHaptic } from '../lib/haptics';
|
||||
import { getHelpSlugForPathname } from '../lib/helpRouting';
|
||||
|
||||
const legalPages = [
|
||||
{ slug: 'impressum', translationKey: 'settings.legal.section.impressum' },
|
||||
@@ -53,12 +54,15 @@ export function SettingsSheet() {
|
||||
const localeContext = useLocale();
|
||||
const { t } = useTranslation();
|
||||
const params = useParams<{ token?: string }>();
|
||||
const location = useLocation();
|
||||
const [nameDraft, setNameDraft] = React.useState(identity?.name ?? '');
|
||||
const [nameStatus, setNameStatus] = React.useState<NameStatus>('idle');
|
||||
const [savingName, setSavingName] = React.useState(false);
|
||||
const isLegal = view.mode === 'legal';
|
||||
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(() => {
|
||||
if (open && identity?.hydrated) {
|
||||
|
||||
@@ -38,6 +38,11 @@ export const DEFAULT_EVENT_BRANDING: EventBranding = {
|
||||
const DEFAULT_PRIMARY = DEFAULT_EVENT_BRANDING.primaryColor.toLowerCase();
|
||||
const DEFAULT_SECONDARY = DEFAULT_EVENT_BRANDING.secondaryColor.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);
|
||||
|
||||
@@ -62,7 +67,8 @@ function resolveBranding(input?: EventBranding | null): EventBranding {
|
||||
|
||||
const headingFont = input.typography?.heading ?? 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 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-link', branding.buttons?.linkColor ?? branding.secondaryColor);
|
||||
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 bodyFont = branding.typography?.body ?? branding.fontFamily;
|
||||
@@ -149,6 +156,7 @@ function resetCssVariables() {
|
||||
root.style.removeProperty('--guest-radius');
|
||||
root.style.removeProperty('--guest-link');
|
||||
root.style.removeProperty('--guest-button-style');
|
||||
root.style.removeProperty('--guest-font-scale');
|
||||
root.style.removeProperty('--guest-font-family');
|
||||
root.style.removeProperty('--guest-body-font');
|
||||
root.style.removeProperty('--guest-heading-font');
|
||||
@@ -160,48 +168,32 @@ function applyThemeMode(mode: EventBranding['mode']) {
|
||||
}
|
||||
|
||||
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
|
||||
: 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 applyLight = () => root.classList.remove('dark');
|
||||
|
||||
if (mode === 'dark') {
|
||||
applyDark();
|
||||
root.style.colorScheme = 'dark';
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === 'light') {
|
||||
applyLight();
|
||||
return;
|
||||
}
|
||||
|
||||
if (storedTheme === 'dark') {
|
||||
applyDark();
|
||||
return;
|
||||
}
|
||||
|
||||
if (storedTheme === 'light') {
|
||||
applyLight();
|
||||
root.style.colorScheme = 'light';
|
||||
return;
|
||||
}
|
||||
|
||||
if (prefersDark) {
|
||||
applyDark();
|
||||
root.style.colorScheme = 'dark';
|
||||
return;
|
||||
}
|
||||
|
||||
applyLight();
|
||||
root.style.colorScheme = 'light';
|
||||
}
|
||||
|
||||
export function EventBrandingProvider({
|
||||
@@ -214,6 +206,9 @@ export function EventBrandingProvider({
|
||||
const resolved = useMemo(() => resolveBranding(branding), [branding]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.classList.add('guest-theme');
|
||||
}
|
||||
applyCssVariables(resolved);
|
||||
const previousDark = typeof document !== 'undefined' ? document.documentElement.classList.contains('dark') : false;
|
||||
applyThemeMode(resolved.mode ?? 'auto');
|
||||
@@ -225,6 +220,7 @@ export function EventBrandingProvider({
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
document.documentElement.classList.remove('guest-theme');
|
||||
}
|
||||
resetCssVariables();
|
||||
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',
|
||||
updated: 'Aktualisiert am {date}',
|
||||
relatedTitle: 'Verwandte Artikel',
|
||||
loadingTitle: 'Artikel wird geladen',
|
||||
loadingDescription: 'Wir holen die neuesten Infos für dich.',
|
||||
unavailable: 'Dieser Artikel ist nicht verfügbar.',
|
||||
reload: 'Neu laden',
|
||||
},
|
||||
@@ -1481,6 +1483,8 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
||||
back: 'Back to overview',
|
||||
updated: 'Updated on {date}',
|
||||
relatedTitle: 'Related articles',
|
||||
loadingTitle: 'Loading article',
|
||||
loadingDescription: 'Fetching the latest details for you.',
|
||||
unavailable: 'This article is unavailable.',
|
||||
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 { FADE_SCALE, FADE_UP, STAGGER_FAST, getMotionContainerProps, getMotionItemProps, prefersReducedMotion } from '../motion';
|
||||
import { getMotionContainerPropsForNavigation, getMotionItemPropsForNavigation, STAGGER_FAST, FADE_UP } from '../motion';
|
||||
|
||||
describe('motion helpers', () => {
|
||||
it('returns disabled props when motion is off', () => {
|
||||
const props = getMotionContainerProps(false, STAGGER_FAST);
|
||||
expect(props.initial).toBe(false);
|
||||
});
|
||||
|
||||
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,
|
||||
describe('getMotionContainerPropsForNavigation', () => {
|
||||
it('returns initial hidden for POP navigation', () => {
|
||||
expect(getMotionContainerPropsForNavigation(true, STAGGER_FAST, 'POP')).toEqual({
|
||||
variants: STAGGER_FAST,
|
||||
initial: 'hidden',
|
||||
animate: 'show',
|
||||
});
|
||||
});
|
||||
|
||||
it('exposes distinct base variants', () => {
|
||||
expect(FADE_UP).not.toBe(FADE_SCALE);
|
||||
it('skips initial animation for PUSH navigation', () => {
|
||||
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) {
|
||||
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 { createRoot } from 'react-dom/client';
|
||||
import '../../css/app.css';
|
||||
import { AppearanceProvider, initializeTheme } from '@/hooks/use-appearance';
|
||||
import { enableGuestDemoMode, shouldEnableGuestDemoMode } from './demo/demoMode';
|
||||
import { Sentry, initSentry } from '@/lib/sentry';
|
||||
|
||||
@@ -11,7 +10,6 @@ const GuestFallback: React.FC<{ message: string }> = ({ message }) => (
|
||||
</div>
|
||||
);
|
||||
|
||||
initializeTheme();
|
||||
initSentry('guest');
|
||||
if (shouldEnableGuestDemoMode()) {
|
||||
enableGuestDemoMode();
|
||||
@@ -24,9 +22,7 @@ const shareRoot = async () => {
|
||||
createRoot(rootEl).render(
|
||||
<Sentry.ErrorBoundary fallback={<GuestFallback message="Dieses Foto kann gerade nicht geladen werden." />}>
|
||||
<React.StrictMode>
|
||||
<AppearanceProvider>
|
||||
<SharedPhotoStandalone />
|
||||
</AppearanceProvider>
|
||||
</React.StrictMode>
|
||||
</Sentry.ErrorBoundary>
|
||||
);
|
||||
@@ -51,7 +47,6 @@ const appRoot = async () => {
|
||||
createRoot(rootEl).render(
|
||||
<Sentry.ErrorBoundary fallback={<GuestFallback message="Erlebnisse können nicht geladen werden." />}>
|
||||
<React.StrictMode>
|
||||
<AppearanceProvider>
|
||||
<LocaleProvider>
|
||||
<ToastProvider>
|
||||
<MatomoTracker config={matomoConfig} />
|
||||
@@ -61,7 +56,6 @@ const appRoot = async () => {
|
||||
</Suspense>
|
||||
</ToastProvider>
|
||||
</LocaleProvider>
|
||||
</AppearanceProvider>
|
||||
</React.StrictMode>
|
||||
</Sentry.ErrorBoundary>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -24,7 +24,7 @@ import type { LocaleCode } from '../i18n/messages';
|
||||
import { localizeTaskLabel } from '../lib/localizeTaskLabel';
|
||||
import { useEventData } from '../hooks/useEventData';
|
||||
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';
|
||||
|
||||
const GENERIC_ERROR = 'GENERIC_ERROR';
|
||||
@@ -343,6 +343,7 @@ function PersonalActions({ token, t, tasksEnabled }: PersonalActionsProps) {
|
||||
|
||||
export default function AchievementsPage() {
|
||||
const { token } = useParams<{ token: string }>();
|
||||
const navigationType = useNavigationType();
|
||||
const identity = useGuestIdentity();
|
||||
const { t, locale } = useTranslation();
|
||||
const { event } = useEventData();
|
||||
@@ -393,7 +394,7 @@ export default function AchievementsPage() {
|
||||
|
||||
const hasPersonal = Boolean(data?.personal);
|
||||
const motionEnabled = !prefersReducedMotion();
|
||||
const containerMotion = getMotionContainerProps(motionEnabled, STAGGER_FAST);
|
||||
const containerMotion = getMotionContainerPropsForNavigation(motionEnabled, STAGGER_FAST, navigationType);
|
||||
const fadeUpMotion = getMotionItemProps(motionEnabled, FADE_UP);
|
||||
const fadeScaleMotion = getMotionItemProps(motionEnabled, FADE_SCALE);
|
||||
const tabMotion = motionEnabled
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @ts-nocheck
|
||||
import React, { useEffect, useState } from 'react';
|
||||
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 FiltersBar, { type GalleryFilter } from '../components/FiltersBar';
|
||||
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 { useToast } from '../components/ToastHost';
|
||||
import { localizeTaskLabel } from '../lib/localizeTaskLabel';
|
||||
import { shouldShowPhotoboothFilter } from '../lib/galleryFilters';
|
||||
import { createPhotoShareLink } from '../services/photosApi';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useEventBranding } from '../context/EventBrandingContext';
|
||||
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 { triggerHaptic } from '../lib/haptics';
|
||||
|
||||
@@ -56,6 +64,7 @@ const normalizeImageUrl = (src?: string | null) => {
|
||||
|
||||
export default function GalleryPage() {
|
||||
const { token } = useParams<{ token?: string }>();
|
||||
const navigationType = useNavigationType();
|
||||
const { t, locale } = useTranslation();
|
||||
const { branding } = useEventBranding();
|
||||
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 headingFont = branding.typography?.heading ?? branding.fontFamily ?? undefined;
|
||||
const motionEnabled = !prefersReducedMotion();
|
||||
const containerMotion = getMotionContainerProps(motionEnabled, STAGGER_FAST);
|
||||
const fadeUpMotion = getMotionItemProps(motionEnabled, FADE_UP);
|
||||
const fadeScaleMotion = getMotionItemProps(motionEnabled, FADE_SCALE);
|
||||
const gridMotion = getMotionContainerProps(motionEnabled, STAGGER_FAST);
|
||||
const containerMotion = getMotionContainerPropsForNavigation(motionEnabled, STAGGER_FAST, navigationType);
|
||||
const fadeUpMotion = getMotionItemPropsForNavigation(motionEnabled, FADE_UP, navigationType);
|
||||
const fadeScaleMotion = getMotionItemPropsForNavigation(motionEnabled, FADE_SCALE, navigationType);
|
||||
const gridMotion = getMotionContainerPropsForNavigation(motionEnabled, STAGGER_FAST, navigationType);
|
||||
const [filter, setFilterState] = React.useState<GalleryFilter>('latest');
|
||||
const [currentPhotoIndex, setCurrentPhotoIndex] = React.useState<number | null>(null);
|
||||
const [hasOpenedPhoto, setHasOpenedPhoto] = useState(false);
|
||||
@@ -88,10 +97,7 @@ export default function GalleryPage() {
|
||||
});
|
||||
|
||||
const typedPhotos = photos as GalleryPhoto[];
|
||||
const showPhotoboothFilter = React.useMemo(
|
||||
() => Boolean(event?.photobooth_enabled) || typedPhotos.some((p) => p.ingest_source === 'photobooth'),
|
||||
[event?.photobooth_enabled, typedPhotos],
|
||||
);
|
||||
const showPhotoboothFilter = React.useMemo(() => shouldShowPhotoboothFilter(event), [event]);
|
||||
const allowedGalleryFilters = React.useMemo<GalleryFilter[]>(
|
||||
() => (showPhotoboothFilter ? allGalleryFilters : ['latest', 'popular', 'mine']),
|
||||
[showPhotoboothFilter],
|
||||
@@ -301,54 +307,62 @@ export default function GalleryPage() {
|
||||
|
||||
return (
|
||||
<Page title="">
|
||||
<div className="relative">
|
||||
<PullToRefresh
|
||||
onRefresh={handleRefresh}
|
||||
pullLabel={t('common.pullToRefresh')}
|
||||
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}>
|
||||
<motion.div className="space-y-2" style={bodyFont ? { fontFamily: bodyFont } : undefined} {...fadeUpMotion}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-pink-500/10 text-pink-500" style={{ borderRadius: radius }}>
|
||||
<ImageIcon className="h-5 w-5" aria-hidden />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="text-2xl font-semibold text-foreground" style={headingFont ? { fontFamily: headingFont } : undefined}>{t('galleryPage.title')}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t('galleryPage.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
{newCount > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={acknowledgeNew}
|
||||
className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold transition ${badgeEmphasisClass}`}
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-[11px] font-semibold ${badgeEmphasisClass}`}
|
||||
style={{ borderRadius: radius }}
|
||||
>
|
||||
{newPhotosBadgeText}
|
||||
</button>
|
||||
) : (
|
||||
<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>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{t('galleryPage.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{newCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={acknowledgeNew}
|
||||
className="inline-flex items-center rounded-full px-2.5 py-0.5 text-[11px] font-semibold text-pink-600 transition hover:bg-pink-50"
|
||||
style={{ borderRadius: radius }}
|
||||
>
|
||||
{t('galleryPage.badge.markSeen', 'Gesehen')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<motion.div {...fadeUpMotion}>
|
||||
<FiltersBar
|
||||
value={filter}
|
||||
onChange={setFilter}
|
||||
className="mt-2"
|
||||
className="mt-0"
|
||||
showPhotobooth={showPhotoboothFilter}
|
||||
styleOverride={{ borderRadius: radius, fontFamily: headingFont }}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{loading && (
|
||||
<motion.p className="px-4" style={bodyFont ? { fontFamily: bodyFont } : undefined} {...fadeUpMotion}>
|
||||
<motion.p className="px-1" {...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}>
|
||||
|
||||
<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
|
||||
@@ -377,10 +391,11 @@ export default function GalleryPage() {
|
||||
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"
|
||||
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}
|
||||
@@ -390,36 +405,22 @@ export default function GalleryPage() {
|
||||
}}
|
||||
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 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>
|
||||
<div className="absolute right-3 top-3 z-10 flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onShare(p);
|
||||
}}
|
||||
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,
|
||||
}}
|
||||
<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}
|
||||
>
|
||||
<Share2 className="h-4 w-4" aria-hidden />
|
||||
</button>
|
||||
{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) => {
|
||||
@@ -427,20 +428,33 @@ export default function GalleryPage() {
|
||||
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'
|
||||
'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,
|
||||
background: buttonStyle === 'outline' ? 'transparent' : '#00000066',
|
||||
border: buttonStyle === 'outline' ? `1px solid ${linkColor}` : '1px solid rgba(255,255,255,0.4)',
|
||||
color: buttonStyle === 'outline' ? linkColor : undefined,
|
||||
}}
|
||||
style={{ borderRadius: radius }}
|
||||
>
|
||||
<Heart className={`h-4 w-4 ${liked.has(p.id) ? 'fill-current' : ''}`} aria-hidden />
|
||||
<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>
|
||||
);
|
||||
@@ -448,7 +462,7 @@ export default function GalleryPage() {
|
||||
{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"
|
||||
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}
|
||||
>
|
||||
@@ -461,7 +475,9 @@ export default function GalleryPage() {
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</PullToRefresh>
|
||||
</div>
|
||||
{currentPhotoIndex !== null && list.length > 0 && (
|
||||
<PhotoLightbox
|
||||
photos={list}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { ArrowLeft, Loader2 } from 'lucide-react';
|
||||
import { Page } from './_util';
|
||||
import { useLocale } from '../i18n/LocaleContext';
|
||||
import { useTranslation } from '../i18n/useTranslation';
|
||||
@@ -37,7 +37,9 @@ export default function HelpArticlePage() {
|
||||
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 (
|
||||
<Page title={title}>
|
||||
@@ -48,17 +50,30 @@ export default function HelpArticlePage() {
|
||||
refreshingLabel={t('common.refreshing')}
|
||||
>
|
||||
<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}>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<ArrowLeft className="h-4 w-4" aria-hidden />
|
||||
{t('help.article.back')}
|
||||
</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{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">
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t('common.actions.loading')}
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -77,11 +92,6 @@ export default function HelpArticlePage() {
|
||||
{article.updated_at && (
|
||||
<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 className="overflow-x-auto">
|
||||
<div
|
||||
|
||||
@@ -18,6 +18,7 @@ export default function HelpCenterPage() {
|
||||
const [query, setQuery] = React.useState('');
|
||||
const [state, setState] = React.useState<'idle' | 'loading' | 'ready' | 'error'>('loading');
|
||||
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 loadArticles = React.useCallback(async (forceRefresh = false) => {
|
||||
@@ -37,6 +38,24 @@ export default function HelpCenterPage() {
|
||||
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(() => {
|
||||
if (!query.trim()) {
|
||||
return articles;
|
||||
@@ -85,7 +104,7 @@ export default function HelpCenterPage() {
|
||||
)}
|
||||
</Button>
|
||||
</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">
|
||||
<Badge variant="secondary" className="bg-amber-200/80 text-amber-900 dark:bg-amber-500/40 dark:text-amber-100">
|
||||
{t('help.center.offlineBadge')}
|
||||
|
||||
@@ -9,9 +9,8 @@ import { useTranslation } from '../i18n/useTranslation';
|
||||
import { DEFAULT_LOCALE, isLocaleCode } from '../i18n/messages';
|
||||
import { AlertTriangle, Download, Loader2, Share, X } from 'lucide-react';
|
||||
import { createPhotoShareLink } from '../services/photosApi';
|
||||
import { Share } from 'lucide-react';
|
||||
import { createPhotoShareLink } from '../services/photosApi';
|
||||
import { getContrastingTextColor } from '../lib/color';
|
||||
import { applyGuestTheme } from '../lib/guestTheme';
|
||||
|
||||
interface GalleryState {
|
||||
meta: GalleryMetaResponse | null;
|
||||
@@ -95,28 +94,34 @@ export default function PublicGalleryPage(): React.ReactElement | null {
|
||||
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(() => {
|
||||
const mode = state.meta?.branding.mode;
|
||||
if (!mode || typeof document === 'undefined') {
|
||||
if (!resolvedBranding) {
|
||||
return;
|
||||
}
|
||||
|
||||
const wasDark = document.documentElement.classList.contains('dark');
|
||||
|
||||
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]);
|
||||
return applyGuestTheme(resolvedBranding);
|
||||
}, [resolvedBranding]);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (!token || !state.cursor || state.loadingMore) {
|
||||
@@ -164,55 +169,46 @@ export default function PublicGalleryPage(): React.ReactElement | null {
|
||||
}, [state.cursor, loadMore]);
|
||||
|
||||
const themeStyles = useMemo(() => {
|
||||
if (!state.meta) {
|
||||
if (!resolvedBranding) {
|
||||
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 {
|
||||
'--gallery-primary': primary,
|
||||
'--gallery-secondary': secondary,
|
||||
'--gallery-background': background,
|
||||
'--gallery-surface': surface,
|
||||
'--gallery-primary': resolvedBranding.primary,
|
||||
'--gallery-secondary': resolvedBranding.secondary,
|
||||
'--gallery-background': resolvedBranding.background,
|
||||
'--gallery-surface': resolvedBranding.surface,
|
||||
} as React.CSSProperties & Record<string, string>;
|
||||
}, [state.meta]);
|
||||
}, [resolvedBranding]);
|
||||
|
||||
const headerStyle = useMemo(() => {
|
||||
if (!state.meta) {
|
||||
if (!resolvedBranding) {
|
||||
return {};
|
||||
}
|
||||
const palette = state.meta.branding.palette ?? {};
|
||||
const primary = palette.primary ?? state.meta.branding.primary_color;
|
||||
const secondary = palette.secondary ?? state.meta.branding.secondary_color ?? primary;
|
||||
const textColor = getContrastingTextColor(primary ?? '#f43f5e', '#0f172a', '#ffffff');
|
||||
const textColor = getContrastingTextColor(resolvedBranding.primary, '#0f172a', '#ffffff');
|
||||
return {
|
||||
background: `linear-gradient(135deg, ${primary}, ${secondary})`,
|
||||
background: `linear-gradient(135deg, ${resolvedBranding.primary}, ${resolvedBranding.secondary})`,
|
||||
color: textColor,
|
||||
} satisfies React.CSSProperties;
|
||||
}, [state.meta]);
|
||||
}, [resolvedBranding]);
|
||||
|
||||
const accentStyle = useMemo(() => {
|
||||
if (!state.meta) {
|
||||
if (!resolvedBranding) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
color: (state.meta.branding.palette?.primary ?? state.meta.branding.primary_color),
|
||||
color: resolvedBranding.primary,
|
||||
} satisfies React.CSSProperties;
|
||||
}, [state.meta]);
|
||||
}, [resolvedBranding]);
|
||||
|
||||
const backgroundStyle = useMemo(() => {
|
||||
if (!state.meta) {
|
||||
if (!resolvedBranding) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
backgroundColor: state.meta.branding.palette?.background ?? state.meta.branding.background_color,
|
||||
backgroundColor: resolvedBranding.background,
|
||||
} satisfies React.CSSProperties;
|
||||
}, [state.meta]);
|
||||
}, [resolvedBranding]);
|
||||
|
||||
const openLightbox = useCallback((photo: GalleryPhotoResource) => {
|
||||
setSelectedPhoto(photo);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Sparkles, RefreshCw, Smile, Camera, Timer as TimerIcon, Heart, ChevronRight, CheckCircle2 } from 'lucide-react';
|
||||
@@ -18,9 +18,10 @@ import {
|
||||
type EmotionTheme,
|
||||
} from '../lib/emotionTheme';
|
||||
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 { triggerHaptic } from '../lib/haptics';
|
||||
import { dedupeTasksById } from '../lib/taskUtils';
|
||||
|
||||
interface Task {
|
||||
id: number;
|
||||
@@ -55,6 +56,7 @@ export default function TaskPickerPage() {
|
||||
const { token } = useParams<{ token: string }>();
|
||||
const eventKey = token ?? '';
|
||||
const navigate = useNavigate();
|
||||
const navigationType = useNavigationType();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { branding } = useEventBranding();
|
||||
const { t, locale } = useTranslation();
|
||||
@@ -133,9 +135,10 @@ export default function TaskPickerPage() {
|
||||
? 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);
|
||||
setTasks(taskList);
|
||||
setTasks(uniqueTasks);
|
||||
} catch (err) {
|
||||
console.error('Failed to load tasks', err);
|
||||
setError(err instanceof Error ? err.message : 'Unbekannter Fehler');
|
||||
@@ -369,7 +372,7 @@ export default function TaskPickerPage() {
|
||||
);
|
||||
const toggleValue = selectedEmotion === 'all' ? 'none' : 'recent';
|
||||
const motionEnabled = !prefersReducedMotion();
|
||||
const containerMotion = getMotionContainerProps(motionEnabled, STAGGER_FAST);
|
||||
const containerMotion = getMotionContainerPropsForNavigation(motionEnabled, STAGGER_FAST, navigationType);
|
||||
const fadeUpMotion = getMotionItemProps(motionEnabled, FADE_UP);
|
||||
const fadeScaleMotion = getMotionItemProps(motionEnabled, FADE_SCALE);
|
||||
|
||||
|
||||
@@ -1488,7 +1488,7 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative h-24 w-24">
|
||||
<div className="relative flex h-24 w-24 items-center justify-center">
|
||||
{!isCountdownActive && mode !== 'uploading' && (
|
||||
<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 { 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';
|
||||
|
||||
vi.mock('react-router-dom', () => ({
|
||||
@@ -73,4 +73,15 @@ describe('UploadPage immersive mode', () => {
|
||||
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 { 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 BottomNav from './components/BottomNav';
|
||||
import RouteTransition from './components/RouteTransition';
|
||||
@@ -101,6 +101,7 @@ export const router = createBrowserRouter([
|
||||
function EventBoundary({ token }: { token: string }) {
|
||||
const identity = useOptionalGuestIdentity();
|
||||
const { event, status, error, errorCode } = useEventData();
|
||||
const location = useLocation();
|
||||
|
||||
if (status === 'loading') {
|
||||
return <EventLoadingView />;
|
||||
@@ -118,6 +119,9 @@ function EventBoundary({ token }: { token: string }) {
|
||||
const localeStorageKey = `guestLocale_event_${event.id ?? token}`;
|
||||
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 (
|
||||
<LocaleProvider defaultLocale={eventLocale} storageKey={localeStorageKey}>
|
||||
<EventBrandingProvider branding={branding}>
|
||||
@@ -125,7 +129,7 @@ function EventBoundary({ token }: { token: string }) {
|
||||
<NotificationCenterProvider eventToken={token}>
|
||||
<div className="pb-16">
|
||||
<Header eventToken={token} />
|
||||
<div className="px-4 py-3">
|
||||
<div className={contentPaddingClass}>
|
||||
<RouteTransition />
|
||||
</div>
|
||||
<BottomNav />
|
||||
|
||||
@@ -128,6 +128,25 @@ return [
|
||||
'benefit4' => 'Support durch das Die Fotospiel.App Team',
|
||||
'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' => [
|
||||
'subject' => 'Neue Kontakt-Anfrage',
|
||||
|
||||
@@ -127,6 +127,25 @@ return [
|
||||
'benefit4' => 'Support from the Die Fotospiel.App team',
|
||||
'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' => [
|
||||
'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')
|
||||
->name('gallery.photos.asset');
|
||||
|
||||
Route::post('/photobooth/sparkbooth/upload', [SparkboothUploadController::class, 'store'])
|
||||
->name('photobooth.sparkbooth.upload');
|
||||
Route::post('/photobooth/upload', [SparkboothUploadController::class, 'store'])
|
||||
->name('photobooth.upload');
|
||||
Route::post('/photobooth/connect', [PhotoboothConnectController::class, 'store'])
|
||||
->middleware('throttle: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('/connect-codes', [PhotoboothConnectCodeController::class, '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'])
|
||||
|
||||
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'));
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
$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([
|
||||
'slug' => 'connect-code-redeem',
|
||||
'name' => 'Winterhochzeit',
|
||||
]);
|
||||
|
||||
EventPhotoboothSetting::factory()
|
||||
@@ -59,6 +60,7 @@ class PhotoboothConnectCodeTest extends TenantTestCase
|
||||
]);
|
||||
|
||||
$redeem->assertOk()
|
||||
->assertJsonPath('data.event_name', 'Winterhochzeit')
|
||||
->assertJsonPath('data.upload_url', fn ($value) => is_string($value) && $value !== '')
|
||||
->assertJsonPath('data.username', 'pbconnect')
|
||||
->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