Compare commits

...

97 Commits

Author SHA1 Message Date
Codex Agent
8634d16359 Expand branding controls and logo upload
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-15 08:42:20 +01:00
Codex Agent
81446b37c3 Wire guest branding theme 2026-01-15 08:06:21 +01:00
Codex Agent
33e46b448d Match gallery preview filters and tiles to gallery
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 16:07:29 +01:00
Codex Agent
289ef70e53 Remove gallery route padding
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 15:58:40 +01:00
Codex Agent
d0559bf8c9 Align gallery layout with achievements structure
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 15:50:48 +01:00
Codex Agent
0ef4b32bf6 Match gallery layout to achievements spacing
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 15:44:32 +01:00
Codex Agent
3612c97e86 Tighten gallery spacing and add filter dividers
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 15:08:28 +01:00
Codex Agent
c0510581c6 Tighten gallery filters and badge placement
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 14:04:31 +01:00
Codex Agent
1ffd3e3b9d Fix gallery section closing tag
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 13:56:00 +01:00
Codex Agent
e05ee3b186 Unify gallery header and grid section
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 13:53:35 +01:00
Codex Agent
cf7b2e563a Unify gallery layout and reduce image overlays
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 12:40:55 +01:00
Codex Agent
719afb6920 Refresh gallery layout and tile styling
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 11:48:40 +01:00
Codex Agent
83c58358a1 Show photobooth filter only when enabled
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 11:45:29 +01:00
Codex Agent
2b888078a0 Modernize gallery UI and fix nav motion
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 11:42:12 +01:00
Codex Agent
2f584162d6 Avoid hidden gallery content on tab navigation
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 11:36:02 +01:00
Codex Agent
0833ea6b36 Skip hidden initial motion on achievements tab nav
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 11:32:54 +01:00
Codex Agent
5bdc15d399 Tune guest route transition animations
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 11:30:03 +01:00
Codex Agent
693540f609 Avoid task page hidden animation on tab navigation
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 11:25:43 +01:00
Codex Agent
c0193c9581 Deduplicate guest tasks list and restore header icon
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 11:17:35 +01:00
Codex Agent
03c7b20cae Improve guest help routing and loading
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 09:00:12 +01:00
Codex Agent
3a78c4f2c0 Ensure help sync creates cache directory
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 08:17:49 +01:00
Codex Agent
fa333deed9 Ensure storage subdirs exist on boot 2026-01-13 22:49:47 +01:00
Codex Agent
a733df6221 Add symfony/yaml for help sync
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 22:38:09 +01:00
Codex Agent
5ee1baa7e2 Fix forwarded host/port for signed URLs
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 22:30:10 +01:00
Codex Agent
2f19752199 chore: sync beads
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 21:33:26 +01:00
Codex Agent
7dd7ec14a4 chore: sync beads 2026-01-13 21:32:39 +01:00
Codex Agent
d9568be579 Fix proxy headers and help sync boot
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 21:31:46 +01:00
Codex Agent
9cf6e9d94d Add photobooth email translations
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 16:40:00 +01:00
Codex Agent
a23ce0c86f Set locale on photobooth mail
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 16:24:29 +01:00
Codex Agent
9efea136bd Normalize photobooth mail locale
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 15:37:26 +01:00
Codex Agent
7a6f489b8b Add tenant admin account edit page
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 15:09:25 +01:00
Codex Agent
cc11e024f0 Add photobooth folder presets
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 12:00:39 +01:00
Codex Agent
2089251a92 Extend uploader profiles, filters, and diagnostics
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 11:26:04 +01:00
Codex Agent
53094b8d36 Add filters, throttling, and connection test
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 11:15:57 +01:00
Codex Agent
0c33c1ddc1 Persist upload queue and uploaded cache
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 11:12:26 +01:00
Codex Agent
ce0b7c951a Update beads issues for uploader epic
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 11:08:39 +01:00
Codex Agent
fbbbbdac4c Add upload retries and richer errors 2026-01-13 11:08:26 +01:00
Codex Agent
94d0713ec0 Add manual uploader credentials fields
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 10:56:33 +01:00
Codex Agent
3e36354916 Restructure photobooth page flow
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 10:52:50 +01:00
Codex Agent
24a1319cc2 Add photobooth uploader download email
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 09:59:39 +01:00
Codex Agent
b1250c6246 Collapse photobooth credentials
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 08:59:24 +01:00
Codex Agent
fd7a3c846a Add uploader downloads for Windows macOS Linux
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 08:49:08 +01:00
Codex Agent
1ca7545f86 Add photobooth uploader build service
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 08:37:26 +01:00
Codex Agent
9f4a202d2b Add Windows app icon
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 08:12:11 +01:00
Codex Agent
fe0525e678 Fix uploader header layout
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 21:26:17 +01:00
Codex Agent
d62efdb55c Refresh uploader UI styling
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 21:15:55 +01:00
Codex Agent
be722f6e37 Remember uploader window size
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 21:03:27 +01:00
Codex Agent
898ac9ff0e Add uploader advanced settings and live status
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 20:50:39 +01:00
Codex Agent
c8d1ac7971 Improve uploader client connection and diagnostics
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 20:40:40 +01:00
Codex Agent
3ee23f3a66 Add uploader branding
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 20:28:49 +01:00
Codex Agent
993c351832 Remove response format from uploader UI
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 20:22:45 +01:00
Codex Agent
2444a62a4d Show connect code expiry time
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 20:14:42 +01:00
Codex Agent
e52720a3cb Rename photobooth upload endpoint
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 20:05:09 +01:00
Codex Agent
93bed358ba Remove sparkbooth option from photobooth UI
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 19:50:30 +01:00
Codex Agent
a16bd9c498 Relabel photobooth uploader mode
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 18:46:41 +01:00
Codex Agent
e32b1fa45a Add photobooth connect code UI
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 17:59:35 +01:00
Codex Agent
6edc890e01 Configure beads sync branch and ignore artifacts
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 17:46:39 +01:00
Codex Agent
e4100f7800 Polish uploader UI and queue handling
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 17:35:05 +01:00
Codex Agent
7786e3d134 Switch photobooth uploader to Avalonia
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 17:26:45 +01:00
Codex Agent
30f3d148bb bd sync: 2026-01-12 17:24:05
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 17:24:05 +01:00
Codex Agent
1970c259ed Restore photobooth uploader files after sync 2026-01-12 17:23:34 +01:00
Codex Agent
dc5c80cda4 bd sync: 2026-01-12 17:21:15
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 17:21:16 +01:00
Codex Agent
75a9bcee12 Migrate photobooth uploader to Avalonia 2026-01-12 17:20:35 +01:00
Codex Agent
6fe363640f Reapply photobooth uploader changes after sync
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 17:10:47 +01:00
Codex Agent
3df0542013 bd sync: 2026-01-12 17:10:05
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 17:10:05 +01:00
Codex Agent
4f4a527010 Reapply photobooth uploader changes 2026-01-12 17:09:37 +01:00
Codex Agent
e69c94ad20 bd sync: 2026-01-12 17:07:55
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 17:07:55 +01:00
Codex Agent
5afa96251b Fix WinUI build settings for linux tooling 2026-01-12 17:07:28 +01:00
Codex Agent
24f053d4c4 Add photobooth connect codes and uploader pipeline
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 17:02:50 +01:00
Codex Agent
ec360ed860 bd sync: 2026-01-12 17:02:15
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 17:02:15 +01:00
Codex Agent
83e78d7c66 Update backend photobooth connect API 2026-01-12 16:59:49 +01:00
Codex Agent
9b1c5bf978 bd sync: 2026-01-12 16:57:37
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 16:57:37 +01:00
Codex Agent
fb23a0a2f3 Add photobooth connect codes and uploader scaffold 2026-01-12 16:56:51 +01:00
Codex Agent
2287e7f32c Fix tenant photo moderation and guest updates
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 14:35:57 +01:00
Codex Agent
cceed361b7 feat: add checkout action banner
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 13:35:43 +01:00
Codex Agent
02363792c8 feat: poll checkout status and show failures
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 13:31:30 +01:00
Codex Agent
e93a00f0fc fix: block non-upgrade package selection
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 12:40:18 +01:00
Codex Agent
c1be7dd1ef fix: add package feature labels
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 12:32:43 +01:00
Codex Agent
f01a0e823b fix: handle array package features
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 12:29:19 +01:00
Codex Agent
915aede66e feat: add package comparison view
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 12:25:35 +01:00
Codex Agent
b854e3feaa Show billing activation banner
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 12:07:37 +01:00
Codex Agent
4bcaef53f7 Redirect checkout to billing with status
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 11:49:10 +01:00
Codex Agent
8f1d3a3eb6 Disallow downgrades in package shop
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 11:45:12 +01:00
Codex Agent
ab2cf3e023 Highlight upgrades in package shop
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 11:38:16 +01:00
Codex Agent
ce0ab269c9 Cap analytics timeframe label
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 11:30:00 +01:00
Codex Agent
dce24bb86a Compute analytics timeframe dynamically
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 11:12:50 +01:00
Codex Agent
03bf178d61 Enhance analytics snapshot and empty states
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 11:07:23 +01:00
Codex Agent
8ebaf6c31d Refine analytics page and i18n
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 11:03:55 +01:00
Codex Agent
1b6dc63ec6 Clamp package summary remaining counts
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 10:50:35 +01:00
Codex Agent
accc63f4a2 Add pending test files
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 10:46:18 +01:00
Codex Agent
59e318e7b9 Ignore beads sync artifacts
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 10:42:58 +01:00
Codex Agent
3de1d3deab Misc unrelated updates
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 10:31:31 +01:00
Codex Agent
e9afbeb028 Unify admin home with event overview 2026-01-12 10:31:05 +01:00
Codex Agent
3e2b63f71f Paddle Coupon Sync prüft nun zuerst, ob der Discount schon existiert. 2026-01-08 13:36:58 +01:00
Codex Agent
cff014ede5 fix(i18n): restore missing translations and enable Suspense loading
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-07 20:50:09 +01:00
Codex Agent
8c5d3b93d5 feat: improve mobile navigation with tap-to-reset and history filtering
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-07 15:14:31 +01:00
Codex Agent
22cb7ed7ce fix: resolve typescript and build errors across admin and guest apps
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-07 13:25:30 +01:00
197 changed files with 12064 additions and 36582 deletions

6
.beads/.gitignore vendored
View File

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

View File

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

View File

@@ -9,6 +9,8 @@
{"id":"fotospiel-app-1we","title":"Live Show: define trusted uploader rules \u0026 default retention window","description":"# Decision: Trusted uploader rules \u0026 default retention window\n\n## Context\nModeration is required for many events, but we also want a fast “auto-approve trusted sources” mode.\n\nWe currently track photo ingestion sources in `photos.ingest_source` (e.g. `tenant_admin`, `photobooth`, `sparkbooth`, `guest_pwa`). Guest uploads are token-based and do not have strong identity guarantees.\n\n## Definitions\n- **Trusted uploader**: uploads that can bypass Live Show manual moderation.\n- **Retention window**: time window for which approved photos remain eligible for rotation in the Live Show.\n\n## Options (trusted rules)\n### A) Trust by ingestion source only (recommended for V1)\nAuto-approve for Live Show only when `ingest_source` is one of:\n- `tenant_admin` (authenticated staff actions)\n- `photobooth` / `sparkbooth` (controlled integrations)\n\nAll `guest_pwa` uploads require manual approval when moderation is enabled.\n\n**Pros**\n- Harder to spoof; aligns with real security boundaries.\n- Simple to explain and operate.\n\n**Cons**\n- Guests never auto-approve; more moderator work.\n\n### B) Trust by guest device id (not recommended without stronger proof)\nUse `created_by_device_id` / `X-Device-Id` to whitelist devices.\n\n**Risk**\n- Device IDs are not cryptographically bound; a motivated guest could spoof the header.\n\nIf we want this later, we should introduce a **server-issued signed device token** (pairing flow) and validate it on upload.\n\n### C) Trust by invitation/QR (future)\nGuests who joined with a special “staff QR/pairing token” become trusted.\n\n## Recommended decision\nChoose **Option A** for V1.\n\n### Moderation mode semantics (proposed)\n- `off`: all photos with “submit to live show” become `approved` immediately *except* photos that are already flagged/removed by other moderation pipelines.\n- `manual`: all guest PWA photos become `pending`; trusted sources auto-approve.\n- `trusted_only`: same as manual, but UI copy emphasises that only booth/staff are automatic.\n\n## Retention window (defaults)\n### Recommendation\nDefault `retention_window_hours = 12` (configurable per event).\n\nRationale:\n- Keeps the “eligible set” bounded for performance.\n- Fits most event durations; avoids showing very old photos late in the night.\n\n### Notes\n- Even with a retention window, we can still show older photos via “curated” mode (e.g. featured/top-liked) if product wants.\n\n## Edge cases\n- **High-volume**: moderators may not keep up → allow temporary switch to “trusted_only” + announce to guests.\n- **Abuse**: if a trusted integration misbehaves, operator can disable trusted auto-approve.\n- **Reversal**: approving a previously rejected photo must be tracked with audit info (who/when).\n\n## Decision needed from product\n- Confirm the default retention window: 12h vs 6h vs “entire event”.\n- Confirm whether “trusted_only” should auto-approve `tenant_admin` uploads (recommended: yes).\n- Confirm whether guest auto-approve is desired in V1 (recommended: no, unless we build pairing).\n","acceptance_criteria":"- Trusted rules options listed, with security risk called out for device-id trust\\n- Clear V1 recommendation (trust by ingest_source only)\\n- Moderation mode semantics defined\\n- Default retention window recommendation + product decision questions","notes":"Decision: V1 trusted auto-approve uses ingest_source only (tenant_admin/photobooth/sparkbooth). Default retention_window_hours = 12.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-05T11:43:32.455339503+01:00","created_by":"soeren","updated_at":"2026-01-05T12:06:45.973092473+01:00","closed_at":"2026-01-05T12:06:45.973092473+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-1we","depends_on_id":"fotospiel-app-vro","type":"blocks","created_at":"2026-01-05T11:44:02.062725386+01:00","created_by":"soeren"}]} {"id":"fotospiel-app-1we","title":"Live Show: define trusted uploader rules \u0026 default retention window","description":"# Decision: Trusted uploader rules \u0026 default retention window\n\n## Context\nModeration is required for many events, but we also want a fast “auto-approve trusted sources” mode.\n\nWe currently track photo ingestion sources in `photos.ingest_source` (e.g. `tenant_admin`, `photobooth`, `sparkbooth`, `guest_pwa`). Guest uploads are token-based and do not have strong identity guarantees.\n\n## Definitions\n- **Trusted uploader**: uploads that can bypass Live Show manual moderation.\n- **Retention window**: time window for which approved photos remain eligible for rotation in the Live Show.\n\n## Options (trusted rules)\n### A) Trust by ingestion source only (recommended for V1)\nAuto-approve for Live Show only when `ingest_source` is one of:\n- `tenant_admin` (authenticated staff actions)\n- `photobooth` / `sparkbooth` (controlled integrations)\n\nAll `guest_pwa` uploads require manual approval when moderation is enabled.\n\n**Pros**\n- Harder to spoof; aligns with real security boundaries.\n- Simple to explain and operate.\n\n**Cons**\n- Guests never auto-approve; more moderator work.\n\n### B) Trust by guest device id (not recommended without stronger proof)\nUse `created_by_device_id` / `X-Device-Id` to whitelist devices.\n\n**Risk**\n- Device IDs are not cryptographically bound; a motivated guest could spoof the header.\n\nIf we want this later, we should introduce a **server-issued signed device token** (pairing flow) and validate it on upload.\n\n### C) Trust by invitation/QR (future)\nGuests who joined with a special “staff QR/pairing token” become trusted.\n\n## Recommended decision\nChoose **Option A** for V1.\n\n### Moderation mode semantics (proposed)\n- `off`: all photos with “submit to live show” become `approved` immediately *except* photos that are already flagged/removed by other moderation pipelines.\n- `manual`: all guest PWA photos become `pending`; trusted sources auto-approve.\n- `trusted_only`: same as manual, but UI copy emphasises that only booth/staff are automatic.\n\n## Retention window (defaults)\n### Recommendation\nDefault `retention_window_hours = 12` (configurable per event).\n\nRationale:\n- Keeps the “eligible set” bounded for performance.\n- Fits most event durations; avoids showing very old photos late in the night.\n\n### Notes\n- Even with a retention window, we can still show older photos via “curated” mode (e.g. featured/top-liked) if product wants.\n\n## Edge cases\n- **High-volume**: moderators may not keep up → allow temporary switch to “trusted_only” + announce to guests.\n- **Abuse**: if a trusted integration misbehaves, operator can disable trusted auto-approve.\n- **Reversal**: approving a previously rejected photo must be tracked with audit info (who/when).\n\n## Decision needed from product\n- Confirm the default retention window: 12h vs 6h vs “entire event”.\n- Confirm whether “trusted_only” should auto-approve `tenant_admin` uploads (recommended: yes).\n- Confirm whether guest auto-approve is desired in V1 (recommended: no, unless we build pairing).\n","acceptance_criteria":"- Trusted rules options listed, with security risk called out for device-id trust\\n- Clear V1 recommendation (trust by ingest_source only)\\n- Moderation mode semantics defined\\n- Default retention window recommendation + product decision questions","notes":"Decision: V1 trusted auto-approve uses ingest_source only (tenant_admin/photobooth/sparkbooth). Default retention_window_hours = 12.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-05T11:43:32.455339503+01:00","created_by":"soeren","updated_at":"2026-01-05T12:06:45.973092473+01:00","closed_at":"2026-01-05T12:06:45.973092473+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-1we","depends_on_id":"fotospiel-app-vro","type":"blocks","created_at":"2026-01-05T11:44:02.062725386+01:00","created_by":"soeren"}]}
{"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-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-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-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-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"} {"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"}
@@ -28,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-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-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-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-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-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"}]} {"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"}]}
@@ -38,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-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-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-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-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-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-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-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-95m","title":"Paddle migration: admin catalog sync UI for packages","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:57:49.790409261+01:00","created_by":"soeren","updated_at":"2026-01-01T15:57:55.418180246+01:00","closed_at":"2026-01-01T15:57:55.418180246+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-99d","title":"Paddle migration: marketing checkout uses Paddle-hosted checkout","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:58:12.298063897+01:00","created_by":"soeren","updated_at":"2026-01-01T15:58:17.968032021+01:00","closed_at":"2026-01-01T15:58:17.968032021+01:00","close_reason":"Completed in codebase (verified)"} {"id":"fotospiel-app-99d","title":"Paddle migration: marketing checkout uses Paddle-hosted checkout","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:58:12.298063897+01:00","created_by":"soeren","updated_at":"2026-01-01T15:58:17.968032021+01:00","closed_at":"2026-01-01T15:58:17.968032021+01:00","close_reason":"Completed in codebase (verified)"}
{"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"} {"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"}
@@ -62,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-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-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-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-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-cwq","title":"Integrations health: unified Paddle/RevenueCat/webhook status dashboard","description":"Add a superadmin integrations health dashboard for Paddle/RevenueCat/webhooks.\nScope: show latest webhook processing status/lag, recent failures, retry backlog, and config presence (env set) without exposing secrets.\nInclude per-provider status badges and time-window filters, plus links to related logs/actions.\n","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-02T17:34:20.84661157+01:00","created_by":"soeren","updated_at":"2026-01-02T18:33:07.133704488+01:00","closed_at":"2026-01-02T18:33:07.133704488+01:00","close_reason":"Closed"}
{"id":"fotospiel-app-d39","title":"Superadmin control surface spec and access matrix","description":"Define the minimal superadmin control surface, permissions, and mapping to tenant/guest responsibilities. Document scope and non-goals.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T14:16:06.994379577+01:00","updated_at":"2026-01-01T14:20:43.080701114+01:00","closed_at":"2026-01-01T14:20:43.080701114+01:00"} {"id":"fotospiel-app-d39","title":"Superadmin control surface spec and access matrix","description":"Define the minimal superadmin control surface, permissions, and mapping to tenant/guest responsibilities. Document scope and non-goals.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T14:16:06.994379577+01:00","updated_at":"2026-01-01T14:20:43.080701114+01:00","closed_at":"2026-01-01T14:20:43.080701114+01:00"}
{"id":"fotospiel-app-dar","title":"Uploader: retry policy for failed uploads","description":"Part of epic fotospiel-app-5aa. Auto-retry with backoff and retry limit before marking failed.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:03:00.808893045+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:03:00.808893045+01:00"}
{"id":"fotospiel-app-dl5","title":"SEC-API-01 Signed URL middleware + asset migration","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:52:24.24098702+01:00","created_by":"soeren","updated_at":"2026-01-01T15:52:29.8793891+01:00","closed_at":"2026-01-01T15:52:29.8793891+01:00","close_reason":"Completed in codebase (verified)"} {"id":"fotospiel-app-dl5","title":"SEC-API-01 Signed URL middleware + asset migration","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:52:24.24098702+01:00","created_by":"soeren","updated_at":"2026-01-01T15:52:29.8793891+01:00","closed_at":"2026-01-01T15:52:29.8793891+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-dm4","title":"SEC-BILL-01 Checkout session linkage + idempotency locks","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:53:26.350238207+01:00","created_by":"soeren","updated_at":"2026-01-01T15:53:31.997737421+01:00","closed_at":"2026-01-01T15:53:31.997737421+01:00","close_reason":"Completed in codebase (verified)"} {"id":"fotospiel-app-dm4","title":"SEC-BILL-01 Checkout session linkage + idempotency locks","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:53:26.350238207+01:00","created_by":"soeren","updated_at":"2026-01-01T15:53:31.997737421+01:00","closed_at":"2026-01-01T15:53:31.997737421+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-dmb","title":"Security review checklist: Event Admin dynamic tests","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:04:46.359468828+01:00","created_by":"soeren","updated_at":"2026-01-01T16:04:46.359468828+01:00"} {"id":"fotospiel-app-dmb","title":"Security review checklist: Event Admin dynamic tests","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:04:46.359468828+01:00","created_by":"soeren","updated_at":"2026-01-01T16:04:46.359468828+01:00"}
@@ -85,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-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-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-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-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-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"} {"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"}
@@ -92,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-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-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-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-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-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"} {"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"}
@@ -101,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-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-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-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-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-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"} {"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"}
@@ -118,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-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-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-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-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-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-swb","title":"Security review: replace public asset URLs with signed routes","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:04:05.610098299+01:00","created_by":"soeren","updated_at":"2026-01-01T16:04:11.215921463+01:00","closed_at":"2026-01-01T16:04:11.215921463+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-t1k","title":"Live Show: data model \u0026 status workflow (pending/approved/ready)","acceptance_criteria":"- DB migrations add event token + photo live fields + indexes\\n- Token generation supports rotation (no expiry)\\n- Photo live workflow methods set timestamps/reviewer consistently\\n- Feature test covers token + workflow","notes":"Implemented Live Show data model: events.live_show_token + live_show_token_rotated_at; photos.live_status + timestamps/reviewer/rejection fields + indexes. Added PhotoLiveStatus enum and Photo workflow methods (markLivePending/approveForLiveShow/rejectForLiveShow). Added Event helpers (ensureLiveShowToken/rotateLiveShowToken). Tests: tests/Feature/LiveShowDataModelTest.php.","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-01-05T11:10:56.560421826+01:00","created_by":"soeren","updated_at":"2026-01-05T12:22:51.967913423+01:00","closed_at":"2026-01-05T12:22:51.967913423+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-t1k","depends_on_id":"fotospiel-app-vro","type":"blocks","created_at":"2026-01-05T11:12:20.345646244+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-t1k","depends_on_id":"fotospiel-app-h5d","type":"blocks","created_at":"2026-01-05T11:44:12.439413712+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-t1k","depends_on_id":"fotospiel-app-1eu","type":"blocks","created_at":"2026-01-05T11:44:22.588642567+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-t1k","depends_on_id":"fotospiel-app-1we","type":"blocks","created_at":"2026-01-05T11:44:31.775634827+01:00","created_by":"soeren"}]} {"id":"fotospiel-app-t1k","title":"Live Show: data model \u0026 status workflow (pending/approved/ready)","acceptance_criteria":"- DB migrations add event token + photo live fields + indexes\\n- Token generation supports rotation (no expiry)\\n- Photo live workflow methods set timestamps/reviewer consistently\\n- Feature test covers token + workflow","notes":"Implemented Live Show data model: events.live_show_token + live_show_token_rotated_at; photos.live_status + timestamps/reviewer/rejection fields + indexes. Added PhotoLiveStatus enum and Photo workflow methods (markLivePending/approveForLiveShow/rejectForLiveShow). Added Event helpers (ensureLiveShowToken/rotateLiveShowToken). Tests: tests/Feature/LiveShowDataModelTest.php.","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-01-05T11:10:56.560421826+01:00","created_by":"soeren","updated_at":"2026-01-05T12:22:51.967913423+01:00","closed_at":"2026-01-05T12:22:51.967913423+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-t1k","depends_on_id":"fotospiel-app-vro","type":"blocks","created_at":"2026-01-05T11:12:20.345646244+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-t1k","depends_on_id":"fotospiel-app-h5d","type":"blocks","created_at":"2026-01-05T11:44:12.439413712+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-t1k","depends_on_id":"fotospiel-app-1eu","type":"blocks","created_at":"2026-01-05T11:44:22.588642567+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-t1k","depends_on_id":"fotospiel-app-1we","type":"blocks","created_at":"2026-01-05T11:44:31.775634827+01:00","created_by":"soeren"}]}
{"id":"fotospiel-app-t2s","title":"Uploader: multiple event profiles","description":"Part of epic fotospiel-app-5aa. Save multiple event profiles and allow quick switching.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:04:18.20222112+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:04:18.20222112+01:00"}
{"id":"fotospiel-app-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-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-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-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)"} {"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)"}
@@ -141,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-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-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-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-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-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"} {"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"}

View File

@@ -1 +1 @@
fotospiel-app-9em fotospiel-app-6yz

12
.gitignore vendored
View File

@@ -13,6 +13,8 @@ fotospiel-tenant-app
/storage/*.key /storage/*.key
/storage/pail /storage/pail
/vendor /vendor
/clients/photobooth-uploader/**/bin
/clients/photobooth-uploader/**/obj
.env .env
.env.backup .env.backup
.env.production .env.production
@@ -23,11 +25,9 @@ Homestead.yaml
npm-debug.log npm-debug.log
yarn-error.log yarn-error.log
/auth.json /auth.json
/.fleet
/.idea
/.nova
/.vscode /.vscode
/.zed
tools/git-askpass.ps1
podman-compose.dev.yml
test-results test-results
GEMINI.md
.beads/.sync.lock
.beads/daemon-error
.beads/sync_base.jsonl

File diff suppressed because it is too large Load Diff

1
GEMINI.md Symbolic link
View File

@@ -0,0 +1 @@
/mnt/c/wwwroot/fotospiel-app/AGENTS.md

View File

@@ -40,7 +40,7 @@ class Login extends BaseLogin implements HasForms
} }
// SuperAdmin-spezifisch: Prüfe auf SuperAdmin-Rolle, keine Tenant-Prüfung // SuperAdmin-spezifisch: Prüfe auf SuperAdmin-Rolle, keine Tenant-Prüfung
if ($user->role !== 'super_admin') { if (! $user->isSuperAdmin()) {
$authGuard->logout(); $authGuard->logout();
throw ValidationException::withMessages([ throw ValidationException::withMessages([

View File

@@ -45,11 +45,11 @@ class GuestPolicySettingsPage extends Page
public int $join_token_failure_decay_minutes = 5; public int $join_token_failure_decay_minutes = 5;
public int $join_token_access_limit = 120; public int $join_token_access_limit = 300;
public int $join_token_access_decay_minutes = 1; public int $join_token_access_decay_minutes = 1;
public int $join_token_download_limit = 60; public int $join_token_download_limit = 120;
public int $join_token_download_decay_minutes = 1; public int $join_token_download_decay_minutes = 1;
@@ -69,9 +69,9 @@ class GuestPolicySettingsPage extends Page
$this->per_device_upload_limit = (int) ($settings->per_device_upload_limit ?? 50); $this->per_device_upload_limit = (int) ($settings->per_device_upload_limit ?? 50);
$this->join_token_failure_limit = (int) ($settings->join_token_failure_limit ?? 10); $this->join_token_failure_limit = (int) ($settings->join_token_failure_limit ?? 10);
$this->join_token_failure_decay_minutes = (int) ($settings->join_token_failure_decay_minutes ?? 5); $this->join_token_failure_decay_minutes = (int) ($settings->join_token_failure_decay_minutes ?? 5);
$this->join_token_access_limit = (int) ($settings->join_token_access_limit ?? 120); $this->join_token_access_limit = (int) ($settings->join_token_access_limit ?? 300);
$this->join_token_access_decay_minutes = (int) ($settings->join_token_access_decay_minutes ?? 1); $this->join_token_access_decay_minutes = (int) ($settings->join_token_access_decay_minutes ?? 1);
$this->join_token_download_limit = (int) ($settings->join_token_download_limit ?? 60); $this->join_token_download_limit = (int) ($settings->join_token_download_limit ?? 120);
$this->join_token_download_decay_minutes = (int) ($settings->join_token_download_decay_minutes ?? 1); $this->join_token_download_decay_minutes = (int) ($settings->join_token_download_decay_minutes ?? 1);
$this->join_token_ttl_hours = (int) ($settings->join_token_ttl_hours ?? 168); $this->join_token_ttl_hours = (int) ($settings->join_token_ttl_hours ?? 168);
$this->share_link_ttl_hours = (int) ($settings->share_link_ttl_hours ?? 48); $this->share_link_ttl_hours = (int) ($settings->share_link_ttl_hours ?? 48);

View File

@@ -3,9 +3,12 @@
namespace App\Http\Controllers\Api; namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Checkout\CheckoutSessionStatusRequest;
use App\Models\CheckoutSession;
use App\Models\Package; use App\Models\Package;
use App\Models\PackagePurchase; use App\Models\PackagePurchase;
use App\Models\TenantPackage; use App\Models\TenantPackage;
use App\Services\Checkout\CheckoutSessionService;
use App\Services\Paddle\PaddleCheckoutService; use App\Services\Paddle\PaddleCheckoutService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -14,7 +17,10 @@ use Illuminate\Validation\ValidationException;
class PackageController extends Controller class PackageController extends Controller
{ {
public function __construct(private readonly PaddleCheckoutService $paddleCheckout) {} public function __construct(
private readonly PaddleCheckoutService $paddleCheckout,
private readonly CheckoutSessionService $sessions,
) {}
public function index(Request $request): JsonResponse public function index(Request $request): JsonResponse
{ {
@@ -165,23 +171,82 @@ class PackageController extends Controller
$package = Package::findOrFail($request->integer('package_id')); $package = Package::findOrFail($request->integer('package_id'));
$tenant = $request->attributes->get('tenant'); $tenant = $request->attributes->get('tenant');
$user = $request->user();
if (! $tenant) { if (! $tenant) {
throw ValidationException::withMessages(['tenant' => 'Tenant context missing.']); throw ValidationException::withMessages(['tenant' => 'Tenant context missing.']);
} }
if (! $user) {
throw ValidationException::withMessages(['user' => 'User context missing.']);
}
if (! $package->paddle_price_id) { if (! $package->paddle_price_id) {
throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Paddle price.']); throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Paddle price.']);
} }
$session = $this->sessions->createOrResume($user, $package, [
'tenant' => $tenant,
]);
$this->sessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE);
$now = now();
$session->forceFill([
'accepted_terms_at' => $now,
'accepted_privacy_at' => $now,
'accepted_withdrawal_notice_at' => $now,
'digital_content_waiver_at' => null,
'legal_version' => config('app.legal_version', $now->toDateString()),
])->save();
$payload = [ $payload = [
'success_url' => $request->input('success_url'), 'success_url' => $request->input('success_url'),
'return_url' => $request->input('return_url'), 'return_url' => $request->input('return_url'),
'metadata' => [
'checkout_session_id' => $session->id,
'legal_version' => $session->legal_version,
'accepted_terms' => true,
],
]; ];
$checkout = $this->paddleCheckout->createCheckout($tenant, $package, $payload); $checkout = $this->paddleCheckout->createCheckout($tenant, $package, $payload);
return response()->json($checkout); $session->forceFill([
'paddle_checkout_id' => $checkout['id'] ?? $session->paddle_checkout_id,
'provider_metadata' => array_merge($session->provider_metadata ?? [], array_filter([
'paddle_checkout_id' => $checkout['id'] ?? null,
'paddle_checkout_url' => $checkout['checkout_url'] ?? null,
'paddle_expires_at' => $checkout['expires_at'] ?? null,
])),
])->save();
return response()->json(array_merge($checkout, [
'checkout_session_id' => $session->id,
]));
}
public function checkoutSessionStatus(CheckoutSessionStatusRequest $request, CheckoutSession $session): JsonResponse
{
$history = $session->status_history ?? [];
$reason = null;
foreach (array_reverse($history) as $entry) {
if (($entry['status'] ?? null) === $session->status) {
$reason = $entry['reason'] ?? null;
break;
}
}
$checkoutUrl = data_get($session->provider_metadata ?? [], 'paddle_checkout_url');
return response()->json([
'status' => $session->status,
'completed_at' => optional($session->completed_at)->toIso8601String(),
'reason' => $reason,
'checkout_url' => is_string($checkoutUrl) ? $checkoutUrl : null,
]);
} }
private function handleFreePurchase(Request $request, Package $package, $tenant): JsonResponse private function handleFreePurchase(Request $request, Package $package, $tenant): JsonResponse

View File

@@ -0,0 +1,70 @@
<?php
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;
class PhotoboothConnectController extends Controller
{
public function __construct(private readonly PhotoboothConnectCodeService $service) {}
public function store(PhotoboothConnectRedeemRequest $request): JsonResponse
{
$record = $this->service->redeem($request->input('code'));
if (! $record) {
return response()->json([
'message' => __('Ungültiger oder abgelaufener Verbindungscode.'),
], 422);
}
$record->loadMissing('event.photoboothSetting');
$event = $record->event;
$setting = $event?->photoboothSetting;
if (! $event || ! $setting || ! $setting->enabled || $setting->mode !== 'sparkbooth') {
return response()->json([
'message' => __('Photobooth ist nicht im Sparkbooth-Modus aktiv.'),
], 409);
}
return response()->json([
'data' => [
'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(),
'response_format' => ($setting->metadata ?? [])['sparkbooth_response_format']
?? config('photobooth.sparkbooth.response_format', 'json'),
],
]);
}
private function resolveEventName(?Event $event): ?string
{
if (! $event) {
return null;
}
$name = $event->name;
if (is_string($name) && trim($name) !== '') {
return $name;
}
if (is_array($name)) {
foreach ($name as $value) {
if (is_string($value) && trim($value) !== '') {
return $value;
}
}
}
return $event->slug ?: null;
}
}

View File

@@ -16,6 +16,7 @@ use App\Models\Package;
use App\Models\PackagePurchase; use App\Models\PackagePurchase;
use App\Models\Photo; use App\Models\Photo;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User;
use App\Services\EventJoinTokenService; use App\Services\EventJoinTokenService;
use App\Support\ApiError; use App\Support\ApiError;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@@ -88,12 +89,15 @@ class EventController extends Controller
$tenant = Tenant::findOrFail($tenantId); $tenant = Tenant::findOrFail($tenantId);
} }
$actor = $request->user();
$isSuperAdmin = $actor instanceof User && $actor->isSuperAdmin();
// Package check is now handled by middleware // Package check is now handled by middleware
$validated = $request->validated(); $validated = $request->validated();
$tenantId = $tenant->id; $tenantId = $tenant->id;
$requestedPackageId = $validated['package_id'] ?? null; $requestedPackageId = $isSuperAdmin ? $request->integer('package_id') : null;
unset($validated['package_id']); unset($validated['package_id']);
$tenantPackage = $tenant->tenantPackages() $tenantPackage = $tenant->tenantPackages()
@@ -108,6 +112,10 @@ class EventController extends Controller
$package = Package::query()->find($requestedPackageId); $package = Package::query()->find($requestedPackageId);
} }
if (! $package && $isSuperAdmin) {
$package = $this->resolveOwnerPackage();
}
if (! $package && $tenantPackage) { if (! $package && $tenantPackage) {
$package = $tenantPackage->package ?? Package::query()->find($tenantPackage->package_id); $package = $tenantPackage->package ?? Package::query()->find($tenantPackage->package_id);
} }
@@ -121,7 +129,7 @@ class EventController extends Controller
$requiresWaiver = $package->isEndcustomer(); $requiresWaiver = $package->isEndcustomer();
$latestPurchase = $requiresWaiver ? $this->resolveLatestPackagePurchase($tenant, $package) : null; $latestPurchase = $requiresWaiver ? $this->resolveLatestPackagePurchase($tenant, $package) : null;
$existingWaiver = $latestPurchase ? data_get($latestPurchase->metadata, 'consents.digital_content_waiver_at') : null; $existingWaiver = $latestPurchase ? data_get($latestPurchase->metadata, 'consents.digital_content_waiver_at') : null;
$needsWaiver = $requiresWaiver && ! $existingWaiver; $needsWaiver = ! $isSuperAdmin && $requiresWaiver && ! $existingWaiver;
if ($needsWaiver && ! $request->boolean('accepted_waiver')) { if ($needsWaiver && ! $request->boolean('accepted_waiver')) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
@@ -182,7 +190,7 @@ class EventController extends Controller
$eventData = Arr::only($eventData, $allowed); $eventData = Arr::only($eventData, $allowed);
$event = DB::transaction(function () use ($tenant, $eventData, $package) { $event = DB::transaction(function () use ($tenant, $eventData, $package, $isSuperAdmin) {
$event = Event::create($eventData); $event = Event::create($eventData);
EventPackage::create([ EventPackage::create([
@@ -193,7 +201,7 @@ class EventController extends Controller
'gallery_expires_at' => $package->gallery_days ? now()->addDays($package->gallery_days) : null, 'gallery_expires_at' => $package->gallery_days ? now()->addDays($package->gallery_days) : null,
]); ]);
if ($package->isReseller()) { if ($package->isReseller() && ! $isSuperAdmin) {
$note = sprintf('Event #%d created (%s)', $event->id, $event->name); $note = sprintf('Event #%d created (%s)', $event->id, $event->name);
if (! $tenant->consumeEventAllowance(1, 'event.create', $note)) { if (! $tenant->consumeEventAllowance(1, 'event.create', $note)) {
@@ -229,6 +237,15 @@ class EventController extends Controller
->first(); ->first();
} }
private function resolveOwnerPackage(): ?Package
{
$ownerPackage = Package::query()
->where('slug', 'pro')
->first();
return $ownerPackage ?? Package::query()->find(3);
}
private function recordEventStartWaiver(Tenant $tenant, Package $package, ?PackagePurchase $purchase): void private function recordEventStartWaiver(Tenant $tenant, Package $package, ?PackagePurchase $purchase): void
{ {
$timestamp = now(); $timestamp = now();
@@ -332,9 +349,14 @@ class EventController extends Controller
$validated['settings']['watermark_allowed'] = $watermarkAllowed; $validated['settings']['watermark_allowed'] = $watermarkAllowed;
$settings = $validated['settings']; $settings = $validated['settings'];
$branding = Arr::get($settings, 'branding', []);
$watermark = Arr::get($settings, 'watermark', []); $watermark = Arr::get($settings, 'watermark', []);
$existingWatermark = is_array($watermark) ? $watermark : []; $existingWatermark = is_array($watermark) ? $watermark : [];
if (is_array($branding)) {
$settings['branding'] = $this->normalizeBrandingSettings($branding, $event, $brandingAllowed);
}
if (is_array($watermark)) { if (is_array($watermark)) {
$mode = $watermark['mode'] ?? 'base'; $mode = $watermark['mode'] ?? 'base';
$policy = $watermarkAllowed ? 'basic' : 'none'; $policy = $watermarkAllowed ? 'basic' : 'none';
@@ -425,6 +447,68 @@ class EventController extends Controller
]); ]);
} }
/**
* @param array<string, mixed> $branding
* @return array<string, mixed>
*/
private function normalizeBrandingSettings(array $branding, Event $event, bool $brandingAllowed): array
{
$logoDataUrl = $branding['logo_data_url'] ?? null;
if (! $brandingAllowed) {
unset($branding['logo_data_url']);
return $branding;
}
if (! is_string($logoDataUrl) || trim($logoDataUrl) === '') {
unset($branding['logo_data_url']);
return $branding;
}
if (! preg_match('/^data:image\\/(png|webp|jpe?g);base64,(.+)$/i', $logoDataUrl, $matches)) {
throw ValidationException::withMessages([
'settings.branding.logo_data_url' => __('Ungültiges Branding-Logo.'),
]);
}
$decoded = base64_decode($matches[2], true);
if ($decoded === false) {
throw ValidationException::withMessages([
'settings.branding.logo_data_url' => __('Branding-Logo konnte nicht gelesen werden.'),
]);
}
if (strlen($decoded) > 1024 * 1024) { // 1 MB
throw ValidationException::withMessages([
'settings.branding.logo_data_url' => __('Branding-Logo ist zu groß (max. 1 MB).'),
]);
}
$extension = str_starts_with(strtolower($matches[1]), 'jp') ? 'jpg' : strtolower($matches[1]);
$path = sprintf('branding/logos/event-%s.%s', $event->id, $extension);
Storage::disk('public')->put($path, $decoded);
$branding['logo_url'] = $path;
$branding['logo_mode'] = 'upload';
$branding['logo_value'] = $path;
$logo = $branding['logo'] ?? [];
if (! is_array($logo)) {
$logo = [];
}
$logo['mode'] = 'upload';
$logo['value'] = $path;
$branding['logo'] = $logo;
unset($branding['logo_data_url']);
return $branding;
}
public function destroy(Request $request, Event $event): JsonResponse public function destroy(Request $request, Event $event): JsonResponse
{ {
$tenantId = $request->attributes->get('tenant_id'); $tenantId = $request->attributes->get('tenant_id');

View File

@@ -135,7 +135,7 @@ class EventMemberController extends Controller
$user->password = Hash::make(Str::random(32)); $user->password = Hash::make(Str::random(32));
} }
if ($user->tenant_id && (int) $user->tenant_id !== (int) $tenant->id && $user->role !== 'super_admin') { if ($user->tenant_id && (int) $user->tenant_id !== (int) $tenant->id && ! $user->isSuperAdmin()) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'email' => __('Dieser Benutzer ist einem anderen Mandanten zugeordnet.'), 'email' => __('Dieser Benutzer ist einem anderen Mandanten zugeordnet.'),
]); ]);
@@ -143,9 +143,9 @@ class EventMemberController extends Controller
$user->tenant_id = $tenant->id; $user->tenant_id = $tenant->id;
if ($role === 'tenant_admin' && $user->role !== 'super_admin') { if ($role === 'tenant_admin' && ! $user->isSuperAdmin()) {
$user->role = 'tenant_admin'; $user->role = 'tenant_admin';
} elseif (! in_array($user->role, ['tenant_admin', 'super_admin'], true)) { } elseif (! in_array($user->role, ['tenant_admin', 'super_admin', 'superadmin'], true)) {
$user->role = 'member'; $user->role = 'member';
} }

View File

@@ -525,13 +525,13 @@ class PhotoController extends Controller
]); ]);
// Only tenant admins can moderate // Only tenant admins can moderate
if (isset($validated['status']) && ! $this->tokenHasScope($request, 'tenant:write')) { if (isset($validated['status']) && ! $this->tokenHasScope($request, 'tenant-admin')) {
return ApiError::response( return ApiError::response(
'insufficient_scope', 'insufficient_scope',
'Insufficient Scopes', 'Insufficient Scopes',
'You are not allowed to moderate photos for this event.', 'You are not allowed to moderate photos for this event.',
Response::HTTP_FORBIDDEN, Response::HTTP_FORBIDDEN,
['required_scope' => 'tenant:write'] ['required_scope' => 'tenant-admin']
); );
} }
@@ -823,6 +823,11 @@ class PhotoController extends Controller
private function tokenHasScope(Request $request, string $scope): bool private function tokenHasScope(Request $request, string $scope): bool
{ {
$accessToken = $request->user()?->currentAccessToken();
if ($accessToken && $accessToken->can($scope)) {
return true;
}
$scopes = $request->user()->scopes ?? ($request->attributes->get('decoded_token')['scopes'] ?? []); $scopes = $request->user()->scopes ?? ($request->attributes->get('decoded_token')['scopes'] ?? []);
if (! is_array($scopes)) { if (! is_array($scopes)) {

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Http\Controllers\Api\Tenant;
use App\Http\Controllers\Controller;
use App\Http\Requests\Tenant\PhotoboothConnectCodeStoreRequest;
use App\Models\Event;
use App\Services\Photobooth\PhotoboothConnectCodeService;
use Illuminate\Http\JsonResponse;
class PhotoboothConnectCodeController extends Controller
{
public function __construct(private readonly PhotoboothConnectCodeService $service) {}
public function store(PhotoboothConnectCodeStoreRequest $request, Event $event): JsonResponse
{
$this->assertEventBelongsToTenant($request, $event);
$event->loadMissing('photoboothSetting');
$setting = $event->photoboothSetting;
if (! $setting || ! $setting->enabled || $setting->mode !== 'sparkbooth') {
return response()->json([
'message' => __('Photobooth muss im Sparkbooth-Modus aktiviert sein.'),
], 409);
}
$expiresInMinutes = $request->input('expires_in_minutes');
$result = $this->service->create($event, $expiresInMinutes ? (int) $expiresInMinutes : null);
return response()->json([
'data' => [
'code' => $result['code'],
'expires_at' => $result['expires_at']->toIso8601String(),
],
]);
}
protected function assertEventBelongsToTenant(PhotoboothConnectCodeStoreRequest $request, Event $event): void
{
$tenantId = (int) $request->attributes->get('tenant_id');
if ($tenantId !== (int) $event->tenant_id) {
abort(403, 'Event gehört nicht zu diesem Tenant.');
}
}
}

View File

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

View File

@@ -193,11 +193,11 @@ class TenantAdminTokenController extends Controller
$abilities[] = 'tenant:'.$user->tenant_id; $abilities[] = 'tenant:'.$user->tenant_id;
} }
if (in_array($user->role, ['tenant_admin', 'admin', 'super_admin'], true)) { if (in_array($user->role, ['tenant_admin', 'admin', 'super_admin', 'superadmin'], true)) {
$abilities[] = 'tenant-admin'; $abilities[] = 'tenant-admin';
} }
if ($user->role === 'super_admin') { if ($user->isSuperAdmin()) {
$abilities[] = 'super-admin'; $abilities[] = 'super-admin';
} }
@@ -219,7 +219,7 @@ class TenantAdminTokenController extends Controller
private function ensureUserCanAccessPanel(User $user): void private function ensureUserCanAccessPanel(User $user): void
{ {
if (in_array($user->role, ['tenant_admin', 'admin', 'super_admin'], true)) { if (in_array($user->role, ['tenant_admin', 'admin', 'super_admin', 'superadmin'], true)) {
return; return;
} }

View File

@@ -9,8 +9,8 @@ use App\Models\User;
use App\Notifications\TenantFeedbackSubmitted; use App\Notifications\TenantFeedbackSubmitted;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Illuminate\Support\Facades\Notification; use Illuminate\Support\Facades\Notification;
use Illuminate\Validation\Rule;
class TenantFeedbackController extends Controller class TenantFeedbackController extends Controller
{ {
@@ -56,7 +56,7 @@ class TenantFeedbackController extends Controller
]); ]);
$recipients = User::query() $recipients = User::query()
->where('role', 'super_admin') ->whereIn('role', ['super_admin', 'superadmin'])
->whereNotNull('email') ->whereNotNull('email')
->get(); ->get();

View File

@@ -80,7 +80,7 @@ class TenantAdminPasswordResetController extends Controller
private function canAccessEventAdmin(User $user): bool private function canAccessEventAdmin(User $user): bool
{ {
if (in_array($user->role, ['tenant_admin', 'admin', 'super_admin'], true)) { if (in_array($user->role, ['tenant_admin', 'admin', 'super_admin', 'superadmin'], true)) {
return true; return true;
} }

View File

@@ -155,7 +155,7 @@ class AuthenticatedSessionController extends Controller
} }
// Super admins go to Filament superadmin panel // Super admins go to Filament superadmin panel
if ($user && $user->role === 'super_admin') { if ($user && $user->isSuperAdmin()) {
return '/super-admin'; return '/super-admin';
} }

View File

@@ -12,7 +12,7 @@ class TenantAdminAuthController extends Controller
$user = Auth::user(); $user = Auth::user();
// Allow only tenant_admin and super_admin // Allow only tenant_admin and super_admin
if ($user && in_array($user->role, ['tenant_admin', 'super_admin'])) { if ($user && in_array($user->role, ['tenant_admin', 'super_admin', 'superadmin'], true)) {
return view('admin'); return view('admin');
} }

View File

@@ -46,7 +46,7 @@ class TenantAdminGoogleController extends Controller
/** @var User|null $user */ /** @var User|null $user */
$user = User::query()->where('email', $email)->first(); $user = User::query()->where('email', $email)->first();
if (! $user || ! in_array($user->role, ['tenant_admin', 'super_admin'], true)) { if (! $user || ! in_array($user->role, ['tenant_admin', 'super_admin', 'superadmin'], true)) {
return $this->sendBackWithError($request, 'google_no_match', 'No tenant admin account is linked to this Google address.'); return $this->sendBackWithError($request, 'google_no_match', 'No tenant admin account is linked to this Google address.');
} }

View File

@@ -3,6 +3,7 @@
namespace App\Http\Middleware; namespace App\Http\Middleware;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User;
use App\Services\Packages\PackageLimitEvaluator; use App\Services\Packages\PackageLimitEvaluator;
use App\Support\ApiError; use App\Support\ApiError;
use Closure; use Closure;
@@ -26,7 +27,7 @@ class CreditCheckMiddleware
]); ]);
} }
if ($this->requiresCredits($request)) { if ($this->requiresCredits($request) && ! $this->shouldBypassCreditCheck($request, $tenant)) {
$violation = $this->limitEvaluator->assessEventCreation($tenant); $violation = $this->limitEvaluator->assessEventCreation($tenant);
if ($violation !== null) { if ($violation !== null) {
@@ -43,6 +44,24 @@ class CreditCheckMiddleware
return $next($request); return $next($request);
} }
private function shouldBypassCreditCheck(Request $request, Tenant $tenant): bool
{
$user = $request->user();
if (! $user instanceof User) {
return false;
}
if (! $user->isSuperAdmin()) {
return false;
}
if (! $user->tenant_id) {
return false;
}
return (int) $user->tenant_id === (int) $tenant->id;
}
private function requiresCredits(Request $request): bool private function requiresCredits(Request $request): bool
{ {
return $request->isMethod('post') return $request->isMethod('post')

View File

@@ -42,7 +42,7 @@ class EnsureTenantAdminToken
/** @var Tenant|null $tenant */ /** @var Tenant|null $tenant */
$tenant = $user->tenant; $tenant = $user->tenant;
if (! $tenant && $user->role === 'super_admin') { if (! $tenant && $user->isSuperAdmin()) {
$requestedTenantId = $this->resolveRequestedTenantId($request); $requestedTenantId = $this->resolveRequestedTenantId($request);
if ($requestedTenantId !== null) { if ($requestedTenantId !== null) {
@@ -50,14 +50,14 @@ class EnsureTenantAdminToken
} }
} }
if (! $tenant && $user->role !== 'super_admin') { if (! $tenant && ! $user->isSuperAdmin()) {
return $this->forbiddenResponse('Tenant context missing for user.'); return $this->forbiddenResponse('Tenant context missing for user.');
} }
if ($tenant) { if ($tenant) {
$request->attributes->set('tenant_id', $tenant->id); $request->attributes->set('tenant_id', $tenant->id);
$request->attributes->set('tenant', $tenant); $request->attributes->set('tenant', $tenant);
} elseif ($user->role === 'super_admin') { } elseif ($user->isSuperAdmin()) {
$requestedTenantId = $this->resolveRequestedTenantId($request); $requestedTenantId = $this->resolveRequestedTenantId($request);
if ($requestedTenantId !== null) { if ($requestedTenantId !== null) {
$request->attributes->set('tenant_id', $requestedTenantId); $request->attributes->set('tenant_id', $requestedTenantId);
@@ -96,7 +96,7 @@ class EnsureTenantAdminToken
*/ */
protected function allowedRoles(): array protected function allowedRoles(): array
{ {
return ['tenant_admin', 'super_admin', 'admin']; return ['tenant_admin', 'super_admin', 'superadmin', 'admin'];
} }
protected function forbiddenRoleMessage(): string protected function forbiddenRoleMessage(): string

View File

@@ -9,7 +9,7 @@ class EnsureTenantCollaboratorToken extends EnsureTenantAdminToken
{ {
protected function allowedRoles(): array protected function allowedRoles(): array
{ {
return ['tenant_admin', 'super_admin', 'admin', 'member']; return ['tenant_admin', 'super_admin', 'superadmin', 'admin', 'member'];
} }
protected function forbiddenRoleMessage(): string protected function forbiddenRoleMessage(): string

View File

@@ -3,6 +3,7 @@
namespace App\Http\Middleware; namespace App\Http\Middleware;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User;
use App\Services\Packages\PackageLimitEvaluator; use App\Services\Packages\PackageLimitEvaluator;
use App\Support\ApiError; use App\Support\ApiError;
use Closure; use Closure;
@@ -26,7 +27,7 @@ class PackageMiddleware
]); ]);
} }
if ($this->requiresPackageCheck($request)) { if ($this->requiresPackageCheck($request) && ! $this->shouldBypassPackageCheck($request, $tenant)) {
$violation = $this->detectViolation($request, $tenant); $violation = $this->detectViolation($request, $tenant);
if ($violation !== null) { if ($violation !== null) {
@@ -43,6 +44,24 @@ class PackageMiddleware
return $next($request); return $next($request);
} }
private function shouldBypassPackageCheck(Request $request, Tenant $tenant): bool
{
$user = $request->user();
if (! $user instanceof User) {
return false;
}
if (! $user->isSuperAdmin()) {
return false;
}
if (! $user->tenant_id) {
return false;
}
return (int) $user->tenant_id === (int) $tenant->id;
}
private function requiresPackageCheck(Request $request): bool private function requiresPackageCheck(Request $request): bool
{ {
return $request->isMethod('post') && ( return $request->isMethod('post') && (

View File

@@ -112,7 +112,7 @@ class RedirectIfAuthenticated extends BaseMiddleware
return '/event-admin/dashboard'; return '/event-admin/dashboard';
} }
if ($user && $user->role === 'super_admin') { if ($user && $user->isSuperAdmin()) {
return '/super-admin'; return '/super-admin';
} }

View File

@@ -4,9 +4,9 @@ namespace App\Http\Middleware;
use Closure; use Closure;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;
class SuperAdminAuth class SuperAdminAuth
{ {
@@ -21,15 +21,15 @@ class SuperAdminAuth
return $next($request); return $next($request);
} }
if (!Auth::check()) { if (! Auth::check()) {
abort(403, 'Nicht angemeldet.'); abort(403, 'Nicht angemeldet.');
} }
$user = Auth::user(); $user = Auth::user();
Log::info('SuperAdminAuth: User ID ' . $user->id . ', role: ' . $user->role); Log::info('SuperAdminAuth: User ID '.$user->id.', role: '.$user->role);
if ($user->role !== 'super_admin') { if (! $user->isSuperAdmin()) {
abort(403, 'Zugriff nur für SuperAdmin. User ID: ' . $user->id . ', Role: ' . $user->role); abort(403, 'Zugriff nur für SuperAdmin. User ID: '.$user->id.', Role: '.$user->role);
} }
return $next($request); return $next($request);

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Http\Requests\Photobooth;
use Illuminate\Foundation\Http\FormRequest;
class PhotoboothConnectRedeemRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'code' => ['required', 'string', 'size:6', 'regex:/^\d{6}$/'],
];
}
protected function prepareForValidation(): void
{
$code = preg_replace('/\D+/', '', (string) $this->input('code'));
$this->merge([
'code' => $code,
]);
}
}

View File

@@ -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 [];
}
}

View File

@@ -30,6 +30,7 @@ class EventStoreRequest extends FormRequest
'event_date' => ['required', 'date', 'after_or_equal:today'], 'event_date' => ['required', 'date', 'after_or_equal:today'],
'location' => ['nullable', 'string', 'max:255'], 'location' => ['nullable', 'string', 'max:255'],
'event_type_id' => ['required', 'exists:event_types,id'], 'event_type_id' => ['required', 'exists:event_types,id'],
'package_id' => ['nullable', 'integer', 'exists:packages,id'],
'max_participants' => ['nullable', 'integer', 'min:1', 'max:10000'], 'max_participants' => ['nullable', 'integer', 'min:1', 'max:10000'],
'public_url' => ['nullable', 'url', 'max:500'], 'public_url' => ['nullable', 'url', 'max:500'],
'custom_domain' => ['nullable', 'string', 'max:255'], 'custom_domain' => ['nullable', 'string', 'max:255'],

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Http\Requests\Tenant;
use Illuminate\Foundation\Http\FormRequest;
class PhotoboothConnectCodeStoreRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'expires_in_minutes' => ['nullable', 'integer', 'min:1', 'max:120'],
];
}
}

View File

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

View File

@@ -5,11 +5,19 @@ namespace App\Listeners\GuestNotifications;
use App\Enums\GuestNotificationAudience; use App\Enums\GuestNotificationAudience;
use App\Enums\GuestNotificationType; use App\Enums\GuestNotificationType;
use App\Events\GuestPhotoUploaded; use App\Events\GuestPhotoUploaded;
use App\Models\GuestNotification;
use App\Models\Photo; use App\Models\Photo;
use App\Services\GuestNotificationService; use App\Services\GuestNotificationService;
use Illuminate\Support\Carbon;
class SendPhotoUploadedNotification class SendPhotoUploadedNotification
{ {
private const DEDUPE_WINDOW_SECONDS = 30;
private const GROUP_WINDOW_MINUTES = 10;
private const MAX_GROUP_PHOTOS = 6;
/** /**
* @param int[] $milestones * @param int[] $milestones
*/ */
@@ -25,7 +33,20 @@ class SendPhotoUploadedNotification
? sprintf('%s hat gerade ein Foto gemacht 🎉', $guestLabel) ? sprintf('%s hat gerade ein Foto gemacht 🎉', $guestLabel)
: 'Es gibt neue Fotos!'; : 'Es gibt neue Fotos!';
$this->notifications->createNotification( $recent = $this->findRecentPhotoNotification($event->event->id);
if ($recent) {
if ($this->shouldSkipDuplicate($recent, $event->photoId, $title)) {
return;
}
$notification = $this->updateGroupedNotification($recent, $event->photoId);
$this->markUploaderRead($notification, $event->guestIdentifier);
$this->maybeCreateMilestoneNotification($event, $guestLabel);
return;
}
$notification = $this->notifications->createNotification(
$event->event, $event->event,
GuestNotificationType::PHOTO_ACTIVITY, GuestNotificationType::PHOTO_ACTIVITY,
$title, $title,
@@ -34,11 +55,15 @@ class SendPhotoUploadedNotification
'audience_scope' => GuestNotificationAudience::ALL, 'audience_scope' => GuestNotificationAudience::ALL,
'payload' => [ 'payload' => [
'photo_id' => $event->photoId, 'photo_id' => $event->photoId,
'photo_ids' => [$event->photoId],
'count' => 1,
], ],
'expires_at' => now()->addHours(3), 'expires_at' => now()->addHours(3),
] ]
); );
$this->markUploaderRead($notification, $event->guestIdentifier);
$this->maybeCreateMilestoneNotification($event, $guestLabel); $this->maybeCreateMilestoneNotification($event, $guestLabel);
} }
@@ -87,4 +112,94 @@ class SendPhotoUploadedNotification
return $guestIdentifier; return $guestIdentifier;
} }
private function findRecentPhotoNotification(int $eventId): ?GuestNotification
{
$cutoff = Carbon::now()->subMinutes(self::GROUP_WINDOW_MINUTES);
return GuestNotification::query()
->where('event_id', $eventId)
->where('type', GuestNotificationType::PHOTO_ACTIVITY)
->active()
->notExpired()
->where('created_at', '>=', $cutoff)
->orderByDesc('id')
->first();
}
private function shouldSkipDuplicate(GuestNotification $notification, int $photoId, string $title): bool
{
$payload = $notification->payload;
if (is_array($payload)) {
$payloadIds = array_filter(
array_map(
fn ($value) => is_numeric($value) ? (int) $value : null,
(array) ($payload['photo_ids'] ?? [])
),
fn ($value) => $value !== null && $value > 0
);
if (in_array($photoId, $payloadIds, true)) {
return true;
}
if (is_numeric($payload['photo_id'] ?? null) && (int) $payload['photo_id'] === $photoId) {
return true;
}
}
$cutoff = Carbon::now()->subSeconds(self::DEDUPE_WINDOW_SECONDS);
if ($notification->created_at instanceof Carbon && $notification->created_at->greaterThanOrEqualTo($cutoff)) {
return $notification->title === $title;
}
return false;
}
private function updateGroupedNotification(GuestNotification $notification, int $photoId): GuestNotification
{
$payload = is_array($notification->payload) ? $notification->payload : [];
$photoIds = array_filter(
array_map(
fn ($value) => is_numeric($value) ? (int) $value : null,
(array) ($payload['photo_ids'] ?? [])
),
fn ($value) => $value !== null && $value > 0
);
$photoIds[] = $photoId;
$photoIds = array_values(array_unique($photoIds));
$photoIds = array_slice($photoIds, 0, self::MAX_GROUP_PHOTOS);
$existingCount = is_numeric($payload['count'] ?? null)
? max(1, (int) $payload['count'])
: max(1, count($photoIds) - 1);
$newCount = $existingCount + 1;
$notification->forceFill([
'title' => $this->buildGroupedTitle($newCount),
'payload' => [
'count' => $newCount,
'photo_ids' => $photoIds,
],
])->save();
return $notification;
}
private function buildGroupedTitle(int $count): string
{
if ($count <= 1) {
return 'Es gibt neue Fotos!';
}
return sprintf('Es gibt %d neue Fotos!', $count);
}
private function markUploaderRead(GuestNotification $notification, string $guestIdentifier): void
{
$guestIdentifier = trim($guestIdentifier);
if ($guestIdentifier === '' || $guestIdentifier === 'anonymous') {
return;
}
$this->notifications->markAsRead($notification, $guestIdentifier);
}
} }

View 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 [];
}
}

View File

@@ -41,9 +41,9 @@ class GuestPolicySetting extends Model
'per_device_upload_limit' => 50, 'per_device_upload_limit' => 50,
'join_token_failure_limit' => (int) config('join_tokens.failure_limit', 10), 'join_token_failure_limit' => (int) config('join_tokens.failure_limit', 10),
'join_token_failure_decay_minutes' => (int) config('join_tokens.failure_decay_minutes', 5), 'join_token_failure_decay_minutes' => (int) config('join_tokens.failure_decay_minutes', 5),
'join_token_access_limit' => (int) config('join_tokens.access_limit', 120), 'join_token_access_limit' => (int) config('join_tokens.access_limit', 300),
'join_token_access_decay_minutes' => (int) config('join_tokens.access_decay_minutes', 1), 'join_token_access_decay_minutes' => (int) config('join_tokens.access_decay_minutes', 1),
'join_token_download_limit' => (int) config('join_tokens.download_limit', 60), 'join_token_download_limit' => (int) config('join_tokens.download_limit', 120),
'join_token_download_decay_minutes' => (int) config('join_tokens.download_decay_minutes', 1), 'join_token_download_decay_minutes' => (int) config('join_tokens.download_decay_minutes', 1),
'join_token_ttl_hours' => 168, 'join_token_ttl_hours' => 168,
'share_link_ttl_hours' => (int) config('share-links.ttl_hours', 48), 'share_link_ttl_hours' => (int) config('share-links.ttl_hours', 48),

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class PhotoboothConnectCode extends Model
{
/** @use HasFactory<\Database\Factories\PhotoboothConnectCodeFactory> */
use HasFactory;
protected $guarded = [];
protected $casts = [
'expires_at' => 'datetime',
'redeemed_at' => 'datetime',
];
public function event(): BelongsTo
{
return $this->belongsTo(Event::class);
}
}

View File

@@ -69,6 +69,16 @@ class User extends Authenticatable implements FilamentHasTenants, FilamentUser,
]; ];
} }
public function isSuperAdmin(): bool
{
return self::isSuperAdminRole($this->role);
}
public static function isSuperAdminRole(?string $role): bool
{
return in_array($role, ['super_admin', 'superadmin'], true);
}
/** /**
* Retrieve the user by the given credentials. * Retrieve the user by the given credentials.
*/ */
@@ -127,12 +137,12 @@ class User extends Authenticatable implements FilamentHasTenants, FilamentUser,
public function canAccessPanel(Panel $panel): bool public function canAccessPanel(Panel $panel): bool
{ {
if (! $this->email_verified_at && $this->role !== 'super_admin') { if (! $this->email_verified_at && ! $this->isSuperAdmin()) {
return false; return false;
} }
return match ($panel->getId()) { return match ($panel->getId()) {
'superadmin' => $this->role === 'super_admin', 'superadmin' => $this->isSuperAdmin(),
'admin' => $this->role === 'tenant_admin', 'admin' => $this->role === 'tenant_admin',
default => false, default => false,
}; };
@@ -140,7 +150,7 @@ class User extends Authenticatable implements FilamentHasTenants, FilamentUser,
public function canAccessTenant(Model $tenant): bool public function canAccessTenant(Model $tenant): bool
{ {
if ($this->role === 'super_admin') { if ($this->isSuperAdmin()) {
return true; return true;
} }
@@ -155,7 +165,7 @@ class User extends Authenticatable implements FilamentHasTenants, FilamentUser,
public function getTenants(Panel $panel): array|Collection public function getTenants(Panel $panel): array|Collection
{ {
if ($this->role === 'super_admin') { if ($this->isSuperAdmin()) {
return Tenant::query()->orderBy('name')->get(); return Tenant::query()->orderBy('name')->get();
} }

View File

@@ -12,12 +12,11 @@ class PurchaseHistoryPolicy
public function viewAny(User $user): bool public function viewAny(User $user): bool
{ {
return $user->role === 'super_admin'; return $user->isSuperAdmin();
} }
public function view(User $user, PurchaseHistory $purchaseHistory): bool public function view(User $user, PurchaseHistory $purchaseHistory): bool
{ {
return $user->role === 'super_admin'; return $user->isSuperAdmin();
} }
} }

View File

@@ -15,7 +15,7 @@ class TenantPolicy
*/ */
public function viewAny(User $user): bool public function viewAny(User $user): bool
{ {
return $user->role === 'super_admin'; return $user->isSuperAdmin();
} }
/** /**
@@ -35,7 +35,7 @@ class TenantPolicy
*/ */
public function create(User $user): bool public function create(User $user): bool
{ {
return $user->role === 'super_admin'; return $user->isSuperAdmin();
} }
/** /**
@@ -43,7 +43,7 @@ class TenantPolicy
*/ */
public function update(User $user, Tenant $tenant): bool public function update(User $user, Tenant $tenant): bool
{ {
return $user->role === 'super_admin'; return $user->isSuperAdmin();
} }
/** /**
@@ -51,7 +51,7 @@ class TenantPolicy
*/ */
public function delete(User $user, Tenant $tenant): bool public function delete(User $user, Tenant $tenant): bool
{ {
return $user->role === 'super_admin'; return $user->isSuperAdmin();
} }
/** /**
@@ -59,6 +59,6 @@ class TenantPolicy
*/ */
public function suspend(User $user, Tenant $tenant): bool public function suspend(User $user, Tenant $tenant): bool
{ {
return $user->role === 'super_admin'; return $user->isSuperAdmin();
} }
} }

View File

@@ -155,7 +155,15 @@ class AppServiceProvider extends ServiceProvider
$key = $tenantId ? 'tenant:'.$tenantId : ('ip:'.($request->ip() ?? 'unknown')); $key = $tenantId ? 'tenant:'.$tenantId : ('ip:'.($request->ip() ?? 'unknown'));
return Limit::perMinute(100)->by($key); return Limit::perMinute(600)->by($key);
});
RateLimiter::for('guest-api', function (Request $request) {
return Limit::perMinute(300)->by('guest-api:'.($request->ip() ?? 'unknown'));
});
RateLimiter::for('photobooth-connect', function (Request $request) {
return Limit::perMinute(30)->by('photobooth-connect:'.($request->ip() ?? 'unknown'));
}); });
RateLimiter::for('tenant-auth', function (Request $request) { RateLimiter::for('tenant-auth', function (Request $request) {

View File

@@ -46,7 +46,7 @@ class AuthServiceProvider extends ServiceProvider
}); });
Gate::before(function (User $user): ?bool { Gate::before(function (User $user): ?bool {
return $user->role === 'super_admin' ? true : null; return $user->isSuperAdmin() ? true : null;
}); });
} }
} }

View File

@@ -83,7 +83,7 @@ class SuperAdminAuditLogger
private function shouldLog(?User $actor): bool private function shouldLog(?User $actor): bool
{ {
if (! $actor || $actor->role !== 'super_admin') { if (! $actor || ! $actor->isSuperAdmin()) {
return false; return false;
} }

View File

@@ -126,6 +126,36 @@ class GuestNotificationService
return null; return null;
} }
$photoId = Arr::get($payload, 'photo_id');
if (is_numeric($photoId)) {
$photoId = max(1, (int) $photoId);
} else {
$photoId = null;
}
$photoIds = Arr::get($payload, 'photo_ids');
if (is_array($photoIds)) {
$photoIds = array_values(array_unique(array_filter(array_map(function ($value) {
if (! is_numeric($value)) {
return null;
}
$int = (int) $value;
return $int > 0 ? $int : null;
}, $photoIds))));
$photoIds = array_slice($photoIds, 0, 10);
} else {
$photoIds = [];
}
$count = Arr::get($payload, 'count');
if (is_numeric($count)) {
$count = max(1, min(9999, (int) $count));
} else {
$count = null;
}
$cta = Arr::get($payload, 'cta'); $cta = Arr::get($payload, 'cta');
if (is_array($cta)) { if (is_array($cta)) {
$cta = [ $cta = [
@@ -142,6 +172,9 @@ class GuestNotificationService
$clean = array_filter([ $clean = array_filter([
'cta' => $cta, 'cta' => $cta,
'photo_id' => $photoId,
'photo_ids' => $photoIds,
'count' => $count,
]); ]);
return $clean === [] ? null : $clean; return $clean === [] ? null : $clean;

View File

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

View File

@@ -17,6 +17,11 @@ class PaddleDiscountService
*/ */
public function createDiscount(Coupon $coupon): array public function createDiscount(Coupon $coupon): array
{ {
$existing = $this->findExistingDiscount($coupon->code);
if ($existing !== null) {
return $existing;
}
$payload = $this->buildDiscountPayload($coupon); $payload = $this->buildDiscountPayload($coupon);
$response = $this->client->post('/discounts', $payload); $response = $this->client->post('/discounts', $payload);
@@ -82,6 +87,35 @@ class PaddleDiscountService
return Arr::get($response, 'data', $response); return Arr::get($response, 'data', $response);
} }
/**
* @return array<string, mixed>|null
*/
protected function findExistingDiscount(?string $code): ?array
{
$normalized = Str::upper(trim((string) $code));
if ($normalized === '') {
return null;
}
$response = $this->client->get('/discounts', [
'code' => $normalized,
'per_page' => 1,
]);
$items = Arr::get($response, 'data', []);
if (! is_array($items) || $items === []) {
return null;
}
$match = Collection::make($items)->first(static function ($item) use ($normalized) {
$codeValue = Str::upper((string) Arr::get($item, 'code', ''));
return $codeValue === $normalized ? $item : null;
});
return is_array($match) ? $match : null;
}
/** /**
* @return array<string, mixed> * @return array<string, mixed>
*/ */

View File

@@ -0,0 +1,80 @@
<?php
namespace App\Services\Photobooth;
use App\Models\Event;
use App\Models\PhotoboothConnectCode;
class PhotoboothConnectCodeService
{
public function create(Event $event, ?int $expiresInMinutes = null): array
{
$length = (int) config('photobooth.connect_code.length', 6);
$length = max(4, min(8, $length));
$expiresInMinutes = $expiresInMinutes ?: (int) config('photobooth.connect_code.expires_minutes', 10);
$expiresInMinutes = max(1, min(120, $expiresInMinutes));
$code = null;
$hash = null;
$max = (10 ** $length) - 1;
for ($attempts = 0; $attempts < 5; $attempts++) {
$candidate = str_pad((string) random_int(0, $max), $length, '0', STR_PAD_LEFT);
$candidateHash = hash('sha256', $candidate);
$exists = PhotoboothConnectCode::query()
->where('code_hash', $candidateHash)
->whereNull('redeemed_at')
->where('expires_at', '>=', now())
->exists();
if (! $exists) {
$code = $candidate;
$hash = $candidateHash;
break;
}
}
if (! $code || ! $hash) {
$code = str_pad((string) random_int(0, $max), $length, '0', STR_PAD_LEFT);
$hash = hash('sha256', $code);
}
$expiresAt = now()->addMinutes($expiresInMinutes);
$record = PhotoboothConnectCode::query()->create([
'event_id' => $event->getKey(),
'code_hash' => $hash,
'expires_at' => $expiresAt,
]);
return [
'code' => $code,
'record' => $record,
'expires_at' => $expiresAt,
];
}
public function redeem(string $code): ?PhotoboothConnectCode
{
$hash = hash('sha256', $code);
/** @var PhotoboothConnectCode|null $record */
$record = PhotoboothConnectCode::query()
->where('code_hash', $hash)
->whereNull('redeemed_at')
->where('expires_at', '>=', now())
->first();
if (! $record) {
return null;
}
$record->forceFill([
'redeemed_at' => now(),
])->save();
return $record;
}
}

View File

@@ -24,15 +24,15 @@ class TenantAuth
} }
$user = $request->user(); $user = $request->user();
if ($user && in_array($user->role, ['tenant_admin', 'admin', 'super_admin', 'member'], true)) { if ($user && in_array($user->role, ['tenant_admin', 'admin', 'super_admin', 'superadmin', 'member'], true)) {
if ($user->role !== 'super_admin' || (int) $user->tenant_id === (int) $tenantId) { if (! $user->isSuperAdmin() || (int) $user->tenant_id === (int) $tenantId) {
return $user; return $user;
} }
} }
$user = User::query() $user = User::query()
->where('tenant_id', $tenantId) ->where('tenant_id', $tenantId)
->whereIn('role', ['tenant_admin', 'admin', 'member']) ->whereIn('role', ['tenant_admin', 'admin', 'super_admin', 'superadmin', 'member'])
->orderByDesc('email_verified_at') ->orderByDesc('email_verified_at')
->orderBy('id') ->orderBy('id')
->first(); ->first();

View File

@@ -0,0 +1,90 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="PhotoboothUploader.App"
RequestedThemeVariant="Default">
<!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. -->
<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>

View File

@@ -0,0 +1,23 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
namespace PhotoboothUploader;
public partial class App : Application
{
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
}
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.MainWindow = new MainWindow();
}
base.OnFrameworkInitializationCompleted();
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 B

View File

@@ -0,0 +1,160 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="520" d:DesignHeight="360"
x:Class="PhotoboothUploader.MainWindow"
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>
<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" />
<TextBlock x:Name="StepFolderText" Text="2. Upload-Ordner wählen" />
<TextBlock x:Name="StepReadyText" Text="3. Upload läuft" />
</StackPanel>
</Border>
<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="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="#14FFFFFF" Padding="10" CornerRadius="8" Margin="0,0,0,8">
<Grid ColumnDefinitions="*,Auto" RowDefinitions="Auto,Auto">
<TextBlock Grid.Column="0" Grid.Row="0" Text="{Binding FileName}" />
<TextBlock Grid.Column="1" Grid.Row="0" Text="{Binding StatusLabel}" />
<TextBlock Grid.Column="0" Grid.Row="1" Text="{Binding UpdatedLabel}" Opacity="0.7" FontSize="11" />
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<StackPanel Orientation="Horizontal" Spacing="8">
<Button x:Name="RetryFailedButton" Content="Fehlgeschlagene erneut senden" Click="RetryFailedButton_Click" IsEnabled="False" Classes="secondary" />
<Button x:Name="ClearFailedButton" Content="Fehlerliste leeren" Click="ClearFailedButton_Click" IsEnabled="False" Classes="secondary" />
</StackPanel>
</StackPanel>
</Border>
</StackPanel>
</Grid>
</Grid>
</Window>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,33 @@
using System.Text.Json.Serialization;
namespace PhotoboothUploader.Models;
public sealed class PhotoboothConnectResponse
{
[JsonPropertyName("data")]
public PhotoboothConnectPayload? Data { get; set; }
[JsonPropertyName("message")]
public string? Message { get; set; }
}
public sealed class PhotoboothConnectPayload
{
[JsonPropertyName("event_name")]
public string? EventName { get; set; }
[JsonPropertyName("upload_url")]
public string? UploadUrl { get; set; }
[JsonPropertyName("username")]
public string? Username { get; set; }
[JsonPropertyName("password")]
public string? Password { get; set; }
[JsonPropertyName("expires_at")]
public string? ExpiresAt { get; set; }
[JsonPropertyName("response_format")]
public string? ResponseFormat { get; set; }
}

View File

@@ -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";
}

View File

@@ -0,0 +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; }
}

View File

@@ -0,0 +1,74 @@
using System;
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace PhotoboothUploader.Models;
public enum UploadStatus
{
Queued,
Uploading,
Success,
Failed,
}
public sealed class UploadItem : INotifyPropertyChanged
{
private UploadStatus _status;
private DateTimeOffset _updatedAt;
public UploadItem(string path)
{
Path = path;
FileName = System.IO.Path.GetFileName(path);
UpdatedAt = DateTimeOffset.Now;
Status = UploadStatus.Queued;
}
public string Path { get; }
public string FileName { get; }
public UploadStatus Status
{
get => _status;
set
{
if (_status != value)
{
_status = value;
UpdatedAt = DateTimeOffset.Now;
OnPropertyChanged();
OnPropertyChanged(nameof(StatusLabel));
}
}
}
public DateTimeOffset UpdatedAt
{
get => _updatedAt;
private set
{
_updatedAt = value;
OnPropertyChanged();
OnPropertyChanged(nameof(UpdatedLabel));
}
}
public string StatusLabel => Status switch
{
UploadStatus.Uploading => "Upload läuft",
UploadStatus.Success => "Fertig",
UploadStatus.Failed => "Fehlgeschlagen",
_ => "Wartet",
};
public string UpdatedLabel => $"{UpdatedAt:HH:mm}";
public event PropertyChangedEventHandler? PropertyChanged;
private void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}

View File

@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ApplicationManifest>app.manifest</ApplicationManifest>
<ApplicationIcon>Assets\app.ico</ApplicationIcon>
<AvaloniaUseCompiledBindingsByDefault>false</AvaloniaUseCompiledBindingsByDefault>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="11.3.10" />
<PackageReference Include="Avalonia.Desktop" Version="11.3.10" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.10" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.10" />
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
<PackageReference Include="Avalonia.Diagnostics" Version="11.3.10">
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
<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>

View File

@@ -0,0 +1,21 @@
using Avalonia;
using System;
namespace PhotoboothUploader;
class Program
{
// Initialization code. Don't use any Avalonia, third-party APIs or any
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
// yet and stuff might break.
[STAThread]
public static void Main(string[] args) => BuildAvaloniaApp()
.StartWithClassicDesktopLifetime(args);
// Avalonia configuration, don't remove; also used by visual designer.
public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure<App>()
.UsePlatformDetect()
.WithInterFont()
.LogToTrace();
}

View File

@@ -0,0 +1,122 @@
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;
using System.Threading.Tasks;
using PhotoboothUploader.Models;
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, 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 request = new { code };
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 = message,
};
}
}

View File

@@ -0,0 +1,47 @@
using System;
using System.IO;
using System.Text.Json;
using PhotoboothUploader.Models;
namespace PhotoboothUploader.Services;
public sealed class SettingsStore
{
private readonly JsonSerializerOptions _options = new()
{
PropertyNameCaseInsensitive = true,
WriteIndented = true,
};
public string SettingsPath { get; }
public string LogPath { get; }
public SettingsStore()
{
var basePath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Fotospiel",
"PhotoboothUploader");
Directory.CreateDirectory(basePath);
SettingsPath = Path.Combine(basePath, "settings.json");
LogPath = Path.Combine(basePath, "uploader.log");
}
public PhotoboothSettings Load()
{
if (!File.Exists(SettingsPath))
{
return new PhotoboothSettings();
}
var json = File.ReadAllText(SettingsPath);
return JsonSerializer.Deserialize<PhotoboothSettings>(json, _options) ?? new PhotoboothSettings();
}
public void Save(PhotoboothSettings settings)
{
var json = JsonSerializer.Serialize(settings, _options);
File.WriteAllText(SettingsPath, json);
}
}

View File

@@ -0,0 +1,297 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using PhotoboothUploader.Models;
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, string> onFailure)
{
Stop();
_cts = new CancellationTokenSource();
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()
{
_cts?.Cancel();
_cts = null;
_pending.Clear();
_workers.Clear();
}
public void Enqueue(string path, Action<string> onQueued)
{
if (!_pending.TryAdd(path, 0))
{
return;
}
_queue.Writer.TryWrite(path);
onQueued(path);
}
private async Task WorkerAsync(
PhotoboothSettings settings,
Action<string> onQueued,
Action<string> onUploading,
Action<string> onSuccess,
Action<string, string> onFailure,
CancellationToken token)
{
if (string.IsNullOrWhiteSpace(settings.UploadUrl))
{
return;
}
using var client = new HttpClient();
client.Timeout = DefaultTimeout;
client.DefaultRequestHeaders.UserAgent.ParseAdd(_userAgent);
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
while (await _queue.Reader.WaitToReadAsync(token))
{
while (_queue.Reader.TryRead(out var path))
{
try
{
onUploading(path);
var error = await UploadWithRetryAsync(client, settings, path, token);
if (error is null)
{
onSuccess(path);
}
else
{
onFailure(path, error);
}
}
catch (OperationCanceledException)
{
return;
}
finally
{
_pending.TryRemove(path, out _);
if (settings.UploadDelayMs > 0)
{
await Task.Delay(settings.UploadDelayMs, token);
}
}
}
}
}
private static async Task<string?> UploadWithRetryAsync(
HttpClient client,
PhotoboothSettings settings,
string path,
CancellationToken token)
{
for (var attempt = 0; attempt <= MaxRetries; attempt++)
{
var attemptError = await UploadOnceAsync(client, settings, path, token);
if (attemptError.Success)
{
return null;
}
if (!attemptError.Retryable || attempt >= MaxRetries)
{
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))
{
return UploadAttempt.Fail("Datei nicht gefunden.", retryable: false);
}
using var content = new MultipartFormDataContent();
if (!string.IsNullOrWhiteSpace(settings.Username))
{
content.Add(new StringContent(settings.Username), "username");
}
if (!string.IsNullOrWhiteSpace(settings.Password))
{
content.Add(new StringContent(settings.Password), "password");
}
if (!string.IsNullOrWhiteSpace(settings.ResponseFormat))
{
content.Add(new StringContent(settings.ResponseFormat), "format");
}
var stream = File.OpenRead(path);
var fileContent = new StreamContent(stream);
fileContent.Headers.ContentType = new MediaTypeHeaderValue(ResolveContentType(path));
content.Add(fileContent, "media", Path.GetFileName(path));
try
{
var response = await client.PostAsync(settings.UploadUrl, content, token);
if (response.IsSuccessStatusCode)
{
return UploadAttempt.Ok();
}
var body = await ReadResponseBodyAsync(response, token);
var status = $"{(int)response.StatusCode} {response.ReasonPhrase}".Trim();
var message = string.IsNullOrWhiteSpace(body) ? status : $"{status} {body}";
return UploadAttempt.Fail(message, IsRetryableStatus(response.StatusCode));
}
catch (TaskCanceledException) when (!token.IsCancellationRequested)
{
return UploadAttempt.Fail("Zeitüberschreitung beim Upload.", retryable: true);
}
catch (HttpRequestException)
{
return UploadAttempt.Fail("Netzwerkfehler beim Upload.", retryable: true);
}
catch (IOException)
{
return UploadAttempt.Fail("Datei konnte nicht gelesen werden.", retryable: false);
}
}
private static async Task<string?> WaitForFileReadyAsync(string path, CancellationToken token)
{
var lastSize = -1L;
for (var attempts = 0; attempts < 10; attempts++)
{
token.ThrowIfCancellationRequested();
if (!File.Exists(path))
{
await Task.Delay(500, token);
continue;
}
var info = new FileInfo(path);
var size = info.Length;
if (size > 0 && size == lastSize)
{
return null;
}
lastSize = size;
await Task.Delay(700, token);
}
return "Datei ist noch in Bearbeitung.";
}
private static string ResolveContentType(string path)
{
return Path.GetExtension(path)?.ToLowerInvariant() switch
{
".png" => "image/png",
".webp" => "image/webp",
_ => "image/jpeg",
};
}
private static bool IsRetryableStatus(System.Net.HttpStatusCode statusCode)
{
var numeric = (int)statusCode;
return numeric >= 500 || statusCode is System.Net.HttpStatusCode.RequestTimeout or System.Net.HttpStatusCode.TooManyRequests;
}
private static TimeSpan GetRetryDelay(int attempt)
{
var jitter = TimeSpan.FromMilliseconds(Random.Shared.Next(100, 350));
return TimeSpan.FromMilliseconds(RetryBaseDelay.TotalMilliseconds * Math.Pow(2, attempt)) + jitter;
}
private static async Task<string?> ReadResponseBodyAsync(HttpResponseMessage response, CancellationToken token)
{
if (response.Content is null)
{
return null;
}
var body = await response.Content.ReadAsStringAsync(token);
if (string.IsNullOrWhiteSpace(body))
{
return null;
}
body = body.Trim();
return body.Length > 200 ? body[..200] + "…" : body;
}
private static int GetWorkerCount(PhotoboothSettings settings)
{
var count = settings.MaxConcurrentUploads;
if (count < 1)
{
return 1;
}
return count > 5 ? 5 : count;
}
private readonly record struct UploadAttempt(bool Success, bool Retryable, string? Error)
{
public static UploadAttempt Ok() => new(true, false, null);
public static UploadAttempt Fail(string error, bool retryable) => new(false, retryable, error);
}
}

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<!-- This manifest is used on Windows only.
Don't remove it as it might cause problems with window transparency and embedded controls.
For more details visit https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests -->
<assemblyIdentity version="1.0.0.0" name="PhotoboothUploader.Desktop"/>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- A list of the Windows versions that this application has been tested on
and is designed to work with. Uncomment the appropriate elements
and Windows will automatically select the most compatible environment. -->
<!-- Windows 10 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
</assembly>

View File

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

154
composer.lock generated
View File

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

View File

@@ -4,9 +4,9 @@ return [
'failure_limit' => (int) env('JOIN_TOKEN_FAILURE_LIMIT', 10), 'failure_limit' => (int) env('JOIN_TOKEN_FAILURE_LIMIT', 10),
'failure_decay_minutes' => (int) env('JOIN_TOKEN_FAILURE_DECAY', 5), 'failure_decay_minutes' => (int) env('JOIN_TOKEN_FAILURE_DECAY', 5),
'access_limit' => (int) env('JOIN_TOKEN_ACCESS_LIMIT', 120), 'access_limit' => (int) env('JOIN_TOKEN_ACCESS_LIMIT', 300),
'access_decay_minutes' => (int) env('JOIN_TOKEN_ACCESS_DECAY', 1), 'access_decay_minutes' => (int) env('JOIN_TOKEN_ACCESS_DECAY', 1),
'download_limit' => (int) env('JOIN_TOKEN_DOWNLOAD_LIMIT', 60), 'download_limit' => (int) env('JOIN_TOKEN_DOWNLOAD_LIMIT', 120),
'download_decay_minutes' => (int) env('JOIN_TOKEN_DOWNLOAD_DECAY', 1), 'download_decay_minutes' => (int) env('JOIN_TOKEN_DOWNLOAD_DECAY', 1),
]; ];

View File

@@ -34,4 +34,8 @@ return [
'rate_limit_per_minute' => (int) env('SPARKBOOTH_RATE_LIMIT_PER_MINUTE', env('PHOTOBOOTH_RATE_LIMIT_PER_MINUTE', 20)), 'rate_limit_per_minute' => (int) env('SPARKBOOTH_RATE_LIMIT_PER_MINUTE', env('PHOTOBOOTH_RATE_LIMIT_PER_MINUTE', 20)),
'response_format' => env('SPARKBOOTH_RESPONSE_FORMAT', 'json'), 'response_format' => env('SPARKBOOTH_RESPONSE_FORMAT', 'json'),
], ],
'connect_code' => [
'length' => (int) env('PHOTOBOOTH_CONNECT_CODE_LENGTH', 6),
'expires_minutes' => (int) env('PHOTOBOOTH_CONNECT_CODE_EXPIRES_MINUTES', 10),
],
]; ];

View File

@@ -0,0 +1,29 @@
<?php
namespace Database\Factories;
use App\Models\Event;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\PhotoboothConnectCode>
*/
class PhotoboothConnectCodeFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
$rawCode = str_pad((string) $this->faker->numberBetween(0, 999999), 6, '0', STR_PAD_LEFT);
return [
'event_id' => Event::factory(),
'code_hash' => hash('sha256', $rawCode),
'expires_at' => now()->addMinutes(10),
'redeemed_at' => null,
];
}
}

View File

@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('photobooth_connect_codes', function (Blueprint $table) {
$table->id();
$table->foreignId('event_id')->constrained()->cascadeOnDelete();
$table->string('code_hash', 64)->unique();
$table->timestamp('expires_at');
$table->timestamp('redeemed_at')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('photobooth_connect_codes');
}
};

View File

@@ -0,0 +1,16 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
class PhotoboothConnectCodeSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
//
}
}

View File

@@ -2,9 +2,11 @@
namespace Database\Seeders; namespace Database\Seeders;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use App\Models\User; use Illuminate\Support\Str;
class SuperAdminSeeder extends Seeder class SuperAdminSeeder extends Seeder
{ {
@@ -12,12 +14,49 @@ class SuperAdminSeeder extends Seeder
{ {
$email = env('ADMIN_EMAIL', 'admin@example.com'); $email = env('ADMIN_EMAIL', 'admin@example.com');
$password = env('ADMIN_PASSWORD', 'ChangeMe123!'); $password = env('ADMIN_PASSWORD', 'ChangeMe123!');
User::updateOrCreate(['email'=>$email], [ $user = User::updateOrCreate(['email' => $email], [
'first_name' => 'Super', 'first_name' => 'Super',
'last_name' => 'Admin', 'last_name' => 'Admin',
'password' => Hash::make($password), 'password' => Hash::make($password),
'role' => 'super_admin', 'role' => 'super_admin',
]); ]);
$tenantSlug = env('OWNER_TENANT_SLUG', 'owner-tenant');
$tenantName = env('OWNER_TENANT_NAME', 'Owner Tenant');
$tenant = Tenant::query()->firstOrCreate(
['slug' => $tenantSlug],
[
'name' => $tenantName,
'email' => $email,
'contact_email' => $email,
'user_id' => $user->id,
'is_active' => true,
'is_suspended' => false,
'settings' => [
'contact_email' => $email,
],
],
);
if (! $tenant->slug) {
$tenant->forceFill(['slug' => Str::slug($tenantName)])->save();
}
if (! $tenant->user_id) {
$tenant->forceFill(['user_id' => $user->id])->save();
}
if (! $tenant->email) {
$tenant->forceFill(['email' => $email])->save();
}
if (! $tenant->contact_email) {
$tenant->forceFill(['contact_email' => $email])->save();
}
if ($user->tenant_id !== $tenant->id) {
$user->forceFill(['tenant_id' => $tenant->id])->save();
}
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

@@ -1 +1,80 @@
{} {
"login_failed": "Diese Anmeldedaten wurden nicht gefunden.",
"login_success": "Sie sind nun eingeloggt.",
"registration_failed": "Registrierung fehlgeschlagen.",
"registration_success": "Registrierung erfolgreich bitte mit dem Kauf fortfahren.",
"already_logged_in": "Sie sind bereits eingeloggt.",
"failed_credentials": "Diese Anmeldedaten wurden nicht gefunden.",
"header": {
"login": "Anmelden",
"register": "Registrieren",
"home": "Startseite",
"packages": "Pakete",
"blog": "Blog",
"occasions": {
"wedding": "Hochzeit",
"birthday": "Geburtstag",
"corporate": "Firmenevent"
},
"contact": "Kontakt"
},
"login": {
"title": "Die Fotospiel App",
"description": "Melde dich mit deinem Fotospiel-Zugang an und steuere deine Events zentral in einem Dashboard.",
"brand": "Die Fotospiel App",
"logo_alt": "Logo Die Fotospiel App",
"identifier": "E-Mail oder Username",
"identifier_placeholder": "z. B. name@beispiel.de oder hochzeit_julia",
"username_or_email": "Username oder E-Mail",
"email": "E-Mail-Adresse",
"email_placeholder": "ihre@email.de",
"password": "Passwort",
"password_placeholder": "Ihr Passwort",
"remember": "Angemeldet bleiben",
"forgot": "Passwort vergessen?",
"submit": "Anmelden",
"oauth_divider": "oder",
"google_cta": "Mit Google anmelden",
"google_helper": "Nutze dein Google-Konto, um dich sicher bei der Eventverwaltung anzumelden.",
"no_account": "Noch keinen Zugang?",
"sign_up": "Jetzt registrieren"
},
"register": {
"title": "Registrieren",
"name": "Vollständiger Name",
"username": "Username",
"email": "E-Mail-Adresse",
"password": "Passwort",
"password_confirmation": "Passwort bestätigen",
"first_name": "Vorname",
"last_name": "Nachname",
"address": "Adresse",
"phone": "Telefonnummer",
"privacy_consent": "Ich stimme der Datenschutzerklärung zu und akzeptiere die Verarbeitung meiner persönlichen Daten.",
"privacy_policy_link": "Datenschutzerklärung",
"submit": "Registrieren",
"first_name_placeholder": "Vorname",
"last_name_placeholder": "Nachname",
"email_placeholder": "beispiel@email.de",
"address_placeholder": "Straße Hausnummer, PLZ Ort",
"phone_placeholder": "+49 170 1234567",
"username_placeholder": "z. B. hochzeit_julia",
"password_placeholder": "Mindestens 8 Zeichen",
"password_confirmation_placeholder": "Passwort erneut eingeben",
"server_error_title": "Registrierung konnte nicht abgeschlossen werden",
"server_error_message": "Auf unserer Seite ist ein Fehler aufgetreten. Bitte versuche es später erneut oder kontaktiere support@fotospiel.de.",
"session_expired_title": "Sicherheitsprüfung abgelaufen",
"session_expired_message": "Deine Sitzung ist abgelaufen. Lade die Seite neu und versuche es erneut."
},
"verification": {
"notice": "Bitte bestätigen Sie Ihre E-Mail-Adresse.",
"resend": "E-Mail erneut senden",
"success_title": "E-Mail bestätigt",
"success_message": "Deine E-Mail ist bestätigt. Du kannst dich jetzt anmelden.",
"checkout_success_message": "E-Mail bestätigt. Du kannst mit dem Checkout fortfahren.",
"toast_success": "E-Mail erfolgreich bestätigt.",
"expired_title": "Bestätigungslink abgelaufen",
"expired_message": "Dieser Bestätigungslink ist nicht mehr gültig. Fordere unten einen neuen Link an.",
"toast_error": "Bestätigungslink abgelaufen. Bitte fordere einen neuen Link an."
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1 +1,80 @@
{} {
"login_failed": "Invalid email or password.",
"login_success": "You are now logged in.",
"registration_failed": "Registration failed.",
"registration_success": "Registration successful proceed with purchase.",
"already_logged_in": "You are already logged in.",
"failed_credentials": "Wrong credentials.",
"header": {
"login": "Login",
"register": "Register",
"home": "Home",
"packages": "Packages",
"blog": "Blog",
"occasions": {
"wedding": "Wedding",
"birthday": "Birthday",
"corporate": "Corporate Event"
},
"contact": "Contact"
},
"login": {
"title": "Die Fotospiel App",
"description": "Sign in with your Fotospiel account to manage every event in one place.",
"brand": "Die Fotospiel App",
"logo_alt": "Fotospiel App logo",
"identifier": "Email or Username",
"identifier_placeholder": "you@example.com or username",
"username_or_email": "Username or Email",
"email": "Email Address",
"email_placeholder": "your@email.com",
"password": "Password",
"password_placeholder": "Your password",
"remember": "Stay logged in",
"forgot": "Forgot password?",
"submit": "Login",
"oauth_divider": "or",
"google_cta": "Continue with Google",
"google_helper": "Use your Google account to access the event dashboard securely.",
"no_account": "Don't have access yet?",
"sign_up": "Create an account"
},
"register": {
"title": "Register",
"name": "Full Name",
"username": "Username",
"email": "Email Address",
"password": "Password",
"password_confirmation": "Confirm password",
"first_name": "First Name",
"last_name": "Last Name",
"address": "Address",
"phone": "Phone Number",
"privacy_consent": "I agree to the privacy policy and accept the processing of my personal data.",
"privacy_policy_link": "Privacy Policy",
"submit": "Register",
"first_name_placeholder": "First name",
"last_name_placeholder": "Last name",
"email_placeholder": "you@example.com",
"address_placeholder": "Street, ZIP, City",
"phone_placeholder": "+1 555 123 4567",
"username_placeholder": "e.g. wedding_julia",
"password_placeholder": "At least 8 characters",
"password_confirmation_placeholder": "Repeat your password",
"server_error_title": "We couldn't finish your registration",
"server_error_message": "Something went wrong on our side. Please try again in a moment or contact support@fotospiel.de.",
"session_expired_title": "Security check expired",
"session_expired_message": "Your session expired. Refresh the page and try again."
},
"verification": {
"notice": "Please verify your email address.",
"resend": "Resend email",
"success_title": "Email verified",
"success_message": "Your email is confirmed. You can sign in now.",
"checkout_success_message": "Email confirmed. Continue your checkout to finish the order.",
"toast_success": "Email verified successfully.",
"expired_title": "Verification link expired",
"expired_message": "That verification link is no longer valid. Request a new email below.",
"toast_error": "Verification link expired. Request a new one."
}
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -2,6 +2,7 @@
import { authorizedFetch } from './auth/tokens'; import { authorizedFetch } from './auth/tokens';
import { ApiError, emitApiErrorEvent } from './lib/apiError'; import { ApiError, emitApiErrorEvent } from './lib/apiError';
import type { EventLimitSummary } from './lib/limitWarnings'; import type { EventLimitSummary } from './lib/limitWarnings';
export type { EventLimitSummary };
import i18n from './i18n'; import i18n from './i18n';
type JsonValue = Record<string, unknown>; type JsonValue = Record<string, unknown>;
@@ -217,6 +218,11 @@ export type PhotoboothStatus = {
metrics?: PhotoboothStatusMetrics | null; metrics?: PhotoboothStatusMetrics | null;
}; };
export type PhotoboothConnectCode = {
code: string;
expires_at: string | null;
};
export type EventAddonCheckout = { export type EventAddonCheckout = {
addon_key: string; addon_key: string;
quantity?: number; quantity?: number;
@@ -2040,6 +2046,35 @@ export async function disableEventPhotobooth(slug: string, options?: { mode?: 'f
); );
} }
export async function createEventPhotoboothConnectCode(
slug: string,
options?: { expires_in_minutes?: number }
): Promise<PhotoboothConnectCode> {
const body = options ? JSON.stringify(options) : undefined;
const headers = body ? { 'Content-Type': 'application/json' } : undefined;
const response = await authorizedFetch(`${photoboothEndpoint(slug)}/connect-codes`, {
method: 'POST',
body,
headers,
});
const data = await jsonOrThrow<{ data?: JsonValue }>(response, 'Failed to create photobooth connect code');
const record = (data.data ?? {}) as Record<string, JsonValue>;
return {
code: typeof record.code === 'string' ? record.code : '',
expires_at: typeof record.expires_at === 'string' ? record.expires_at : null,
};
}
export async function sendEventPhotoboothUploaderEmail(slug: string): Promise<void> {
const response = await authorizedFetch(`${photoboothEndpoint(slug)}/uploader-email`, {
method: 'POST',
});
await jsonOrThrow<{ message?: string }>(response, 'Failed to send photobooth uploader email');
}
export async function submitTenantFeedback(payload: { export async function submitTenantFeedback(payload: {
category: string; category: string;
sentiment?: 'positive' | 'neutral' | 'negative'; sentiment?: 'positive' | 'neutral' | 'negative';
@@ -2457,7 +2492,7 @@ export async function getTenantPaddleTransactions(cursor?: string): Promise<{
export async function createTenantPaddleCheckout( export async function createTenantPaddleCheckout(
packageId: number, packageId: number,
urls?: { success_url?: string; return_url?: string } urls?: { success_url?: string; return_url?: string }
): Promise<{ checkout_url: string; id: string; expires_at?: string }> { ): Promise<{ checkout_url: string; id: string; expires_at?: string; checkout_session_id?: string }> {
const response = await authorizedFetch('/api/v1/tenant/packages/paddle-checkout', { const response = await authorizedFetch('/api/v1/tenant/packages/paddle-checkout', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -2467,12 +2502,22 @@ export async function createTenantPaddleCheckout(
return_url: urls?.return_url, return_url: urls?.return_url,
}), }),
}); });
return await jsonOrThrow<{ checkout_url: string; id: string; expires_at?: string }>( return await jsonOrThrow<{ checkout_url: string; id: string; expires_at?: string; checkout_session_id?: string }>(
response, response,
'Failed to create checkout' 'Failed to create checkout'
); );
} }
export async function getTenantPackageCheckoutStatus(
checkoutSessionId: string,
): Promise<{ status: string; completed_at?: string | null; reason?: string | null; checkout_url?: string | null }> {
const response = await authorizedFetch(`/api/v1/tenant/packages/checkout-session/${checkoutSessionId}/status`);
return await jsonOrThrow<{ status: string; completed_at?: string | null; reason?: string | null; checkout_url?: string | null }>(
response,
'Failed to load checkout status'
);
}
export async function createTenantBillingPortalSession(): Promise<{ url: string }> { export async function createTenantBillingPortalSession(): Promise<{ url: string }> {
const response = await authorizedFetch('/api/v1/tenant/billing/portal', { const response = await authorizedFetch('/api/v1/tenant/billing/portal', {
method: 'POST', method: 'POST',

View File

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

View File

@@ -34,6 +34,27 @@
"more": "Weitere Einträge konnten nicht geladen werden.", "more": "Weitere Einträge konnten nicht geladen werden.",
"portal": "Paddle-Portal konnte nicht geöffnet werden." "portal": "Paddle-Portal konnte nicht geöffnet werden."
}, },
"checkoutSuccess": "Checkout abgeschlossen. Dein Paket wird in Kürze aktiviert.",
"checkoutCancelled": "Checkout wurde abgebrochen.",
"checkoutActivated": "Dein Paket ist jetzt aktiv.",
"checkoutPendingTitle": "Paket wird aktiviert",
"checkoutPendingBody": "Das kann ein paar Minuten dauern. Wir aktualisieren den Status, sobald das Paket aktiv ist.",
"checkoutPendingBadge": "Ausstehend",
"checkoutPendingRefresh": "Aktualisieren",
"checkoutPendingDismiss": "Ausblenden",
"checkoutFailedTitle": "Checkout fehlgeschlagen",
"checkoutFailedBody": "Die Zahlung wurde nicht abgeschlossen. Du kannst es erneut versuchen oder den Support kontaktieren.",
"checkoutFailedBadge": "Fehlgeschlagen",
"checkoutFailedRetry": "Erneut versuchen",
"checkoutFailedDismiss": "Ausblenden",
"checkoutActionTitle": "Aktion erforderlich",
"checkoutActionBody": "Schließe die Zahlung ab, um das Paket zu aktivieren.",
"checkoutActionBadge": "Aktion nötig",
"checkoutActionButton": "Checkout fortsetzen",
"checkoutFailureReasons": {
"paddle_failed": "Die Zahlung wurde abgelehnt.",
"paddle_cancelled": "Der Checkout wurde abgebrochen."
},
"sections": { "sections": {
"invoices": { "invoices": {
"title": "Rechnungen & Zahlungen", "title": "Rechnungen & Zahlungen",
@@ -176,6 +197,8 @@
}, },
"common": { "common": {
"all": "Alle", "all": "Alle",
"anonymous": "Anonym",
"error": "Etwas ist schiefgelaufen",
"loadMore": "Mehr laden", "loadMore": "Mehr laden",
"processing": "Verarbeite …", "processing": "Verarbeite …",
"select": "Auswählen", "select": "Auswählen",
@@ -1145,15 +1168,23 @@
"mode": "Modus" "mode": "Modus"
}, },
"mode": { "mode": {
"title": "Photobooth-Typ auswählen", "title": "Uploader-Verbindung",
"description": "Wähle zwischen klassischem FTP und Sparkbooth HTTP-Upload. Umschalten generiert neue Zugangsdaten.", "description": "Nutze die Fotospiel-Uploader-App für HTTP-Uploads. Beim Zurücksetzen werden neue Zugangsdaten generiert.",
"active": "Aktuell: {{mode}}" "active": "Aktuell: {{mode}}",
"uploader": "Uploader-App (HTTP)"
},
"selector": {
"title": "Verbindung",
"description": "Nutze die Fotospiel-Uploader-App für HTTP-Uploads."
}, },
"credentials": { "credentials": {
"heading": "FTP-Zugangsdaten", "heading": "Zugangsdaten für die Uploader-App",
"description": "Teile die Zugangsdaten mit eurer Photobooth-Software.", "description": "Teile die Zugangsdaten mit der Fotospiel-Uploader-App.",
"sparkboothTitle": "Sparkbooth-Upload (HTTP)", "uploaderTitle": "Uploader-App (HTTP)",
"sparkboothDescription": "Trage URL, Benutzername und Passwort in Sparkbooth ein. Antworten sind JSON (optional XML).", "uploaderDescription": "Trage URL, Benutzername und Passwort in die Fotospiel-Uploader-App ein. Antworten sind JSON (optional XML).",
"show": "Zugangsdaten anzeigen",
"hide": "Zugangsdaten verbergen",
"hidden": "Zugangsdaten verborgen. Tippe zum Anzeigen.",
"host": "Host", "host": "Host",
"port": "Port", "port": "Port",
"username": "Benutzername", "username": "Benutzername",
@@ -1162,6 +1193,44 @@
"postUrl": "Upload-URL", "postUrl": "Upload-URL",
"responseFormat": "Antwort-Format" "responseFormat": "Antwort-Format"
}, },
"connectCode": {
"label": "Verbindungscode",
"description": "Erstelle einen 6-stelligen Code für die Uploader-App.",
"expires": "Läuft ab: {{date}}",
"actions": {
"generate": "Verbindungscode erstellen",
"generated": "Verbindungscode erstellt"
},
"errors": {
"failed": "Verbindungscode konnte nicht erstellt werden."
}
},
"uploader": {
"hint": "POST mit Mediendatei oder base64-Feld \"media\"; die App nutzt diese Zugangsdaten."
},
"steps": {
"activate": {
"title": "1. Photobooth aktivieren",
"description": "Schalte den Upload-Zugang fuer dieses Event frei."
},
"download": {
"title": "2. Uploader App herunterladen"
},
"access": {
"title": "3. Verbindungscode erstellen",
"description": "Der Code verbindet die App sicher mit deinem Event."
}
},
"uploaderDownload": {
"title": "Fotospiel Uploader App",
"description": "Die Fotospiel Uploader App wird benötigt, damit Uploads stabil laufen, die Zugangsdaten geschützt bleiben und keine Dateien verloren gehen.",
"emailAction": "Download-Links per E-Mail senden",
"emailSuccess": "Download-Links wurden per E-Mail gesendet.",
"emailFailed": "E-Mail konnte nicht gesendet werden.",
"actionWindows": "Uploader herunterladen (Windows)",
"actionMac": "Uploader herunterladen (macOS)",
"actionLinux": "Uploader herunterladen (Linux)"
},
"actions": { "actions": {
"enable": "Photobooth aktivieren", "enable": "Photobooth aktivieren",
"disable": "Deaktivieren", "disable": "Deaktivieren",
@@ -1179,9 +1248,9 @@
"title": "Setup-Checkliste", "title": "Setup-Checkliste",
"description": "Durchlaufe die Schritte, bevor du Gästen Zugang gibst.", "description": "Durchlaufe die Schritte, bevor du Gästen Zugang gibst.",
"enable": "Zugang aktivieren", "enable": "Zugang aktivieren",
"enableCopy": "Aktiviere den FTP-Account für eure Photobooth-Software.", "enableCopy": "Aktiviere die Verbindung für die Uploader-App.",
"share": "Zugang teilen", "share": "Zugang teilen",
"shareCopy": "Übergib Host, Benutzer & Passwort an den Betreiber.", "shareCopy": "Übergib URL, Benutzername & Passwort an den Betreiber.",
"monitor": "Uploads beobachten", "monitor": "Uploads beobachten",
"monitorCopy": "Verfolge Uploads & Limits direkt im Dashboard." "monitorCopy": "Verfolge Uploads & Limits direkt im Dashboard."
}, },
@@ -1408,7 +1477,7 @@
"photobooth": { "photobooth": {
"title": "Fotobox-Uploads", "title": "Fotobox-Uploads",
"titleForEvent": "Fotobox-Uploads verwalten", "titleForEvent": "Fotobox-Uploads verwalten",
"subtitle": "Erstelle FTP-Zugänge für Photobooth-Software und behalte Limits im Blick.", "subtitle": "Erstelle Zugang für die Uploader-App und behalte Limits im Blick.",
"actions": { "actions": {
"backToEvent": "Zur Detailansicht", "backToEvent": "Zur Detailansicht",
"allEvents": "Zur Eventliste" "allEvents": "Zur Eventliste"
@@ -1849,23 +1918,63 @@
"titleShort": "Branding", "titleShort": "Branding",
"previewTitle": "Guest-App-Vorschau", "previewTitle": "Guest-App-Vorschau",
"previewSubtitle": "Aktuelle Farben & Schriften", "previewSubtitle": "Aktuelle Farben & Schriften",
"previewCta": "Fotos hochladen",
"primary": "Primärfarbe", "primary": "Primärfarbe",
"accent": "Akzentfarbe", "accent": "Akzentfarbe",
"background": "Hintergrund",
"surface": "Fläche",
"lockedBranding": "Branding ist in diesem Paket gesperrt.",
"source": "Branding-Quelle",
"sourceHint": "Nutze das Tenant-Branding oder überschreibe es für dieses Event.",
"useDefault": "Tenant",
"useCustom": "Event",
"usingDefault": "Tenant-Branding aktiv",
"usingCustom": "Event-Branding aktiv",
"mode": "Theme",
"modeLight": "Hell",
"modeAuto": "Auto",
"modeDark": "Dunkel",
"colors": "Farben", "colors": "Farben",
"primaryColor": "Primärfarbe", "primaryColor": "Primärfarbe",
"accentColor": "Akzentfarbe", "accentColor": "Akzentfarbe",
"backgroundColor": "Hintergrundfarbe",
"surfaceColor": "Flächenfarbe",
"fonts": "Schriften", "fonts": "Schriften",
"headingFont": "Überschrift-Schrift", "headingFont": "Überschrift-Schrift",
"headingFontPlaceholder": "SF Pro Display", "headingFontPlaceholder": "SF Pro Display",
"bodyFont": "Fließtext-Schrift", "bodyFont": "Fließtext-Schrift",
"bodyFontPlaceholder": "SF Pro Text", "bodyFontPlaceholder": "SF Pro Text",
"fontSize": "Schriftgröße",
"fontSizeSmall": "S",
"fontSizeMedium": "M",
"fontSizeLarge": "L",
"logo": "Logo", "logo": "Logo",
"logoAlt": "Logo", "logoAlt": "Logo",
"logoModeUpload": "Upload",
"logoModeEmoticon": "Emoticon",
"logoValue": "Emoticon",
"logoValuePlaceholder": "🎉",
"logoPosition": "Position",
"positionLeft": "Links",
"positionCenter": "Zentriert",
"positionRight": "Rechts",
"logoSize": "Größe",
"logoSizeSmall": "S",
"logoSizeMedium": "M",
"logoSizeLarge": "L",
"replaceLogo": "Logo ersetzen", "replaceLogo": "Logo ersetzen",
"removeLogo": "Entfernen", "removeLogo": "Entfernen",
"logoHint": "Lade ein Logo hoch, um Einladungen und QR-Poster zu branden.", "logoHint": "Logo hochladen oder Emoji für den Guest-Header nutzen.",
"uploadLogo": "Logo hochladen (max. 1 MB)", "uploadLogo": "Logo hochladen (max. 1 MB)",
"logoTooLarge": "Logo muss unter 1 MB sein.", "logoTooLarge": "Logo muss unter 1 MB sein.",
"buttons": "Buttons & Links",
"buttonsHint": "Stil, Radius und Link-Farbe für CTAs.",
"buttonFilled": "Gefüllt",
"buttonOutline": "Outline",
"buttonRadius": "Radius",
"buttonPrimary": "Button Primär",
"buttonSecondary": "Button Sekundär",
"linkColor": "Link-Farbe",
"save": "Branding speichern", "save": "Branding speichern",
"saving": "Speichere...", "saving": "Speichere...",
"saveSuccess": "Branding gespeichert.", "saveSuccess": "Branding gespeichert.",
@@ -2323,7 +2432,7 @@
"mobileProfile": { "mobileProfile": {
"title": "Profil", "title": "Profil",
"settings": "Einstellungen", "settings": "Einstellungen",
"account": "Account & Sicherheit", "account": "Account bearbeiten",
"language": "Sprache", "language": "Sprache",
"languageDe": "Deutsch", "languageDe": "Deutsch",
"languageEn": "Englisch", "languageEn": "Englisch",
@@ -2875,16 +2984,25 @@
"analytics": { "analytics": {
"title": "Analytics", "title": "Analytics",
"upgradeAction": "Upgrade auf Premium", "upgradeAction": "Upgrade auf Premium",
"kpiTitle": "Event-Überblick",
"kpiUploads": "Uploads",
"kpiContributors": "Beitragende",
"kpiLikes": "Likes",
"activityTitle": "Aktivitäts-Zeitachse", "activityTitle": "Aktivitäts-Zeitachse",
"timeframe": "Letzte {{hours}} Stunden",
"timeframeHint": "Ältere Aktivität ausgeblendet",
"uploadsPerHour": "Uploads pro Stunde", "uploadsPerHour": "Uploads pro Stunde",
"noActivity": "Noch keine Uploads", "noActivity": "Noch keine Uploads",
"emptyActionShareQr": "QR-Code teilen",
"contributorsTitle": "Top-Beitragende", "contributorsTitle": "Top-Beitragende",
"likesCount": "{{count}} Likes", "likesCount": "{{count}} Likes",
"likesCount_one": "{{count}} Like", "likesCount_one": "{{count}} Like",
"likesCount_other": "{{count}} Likes", "likesCount_other": "{{count}} Likes",
"noContributors": "Noch keine Beitragenden", "noContributors": "Noch keine Beitragenden",
"emptyActionInvite": "Gäste einladen",
"tasksTitle": "Beliebte Aufgaben", "tasksTitle": "Beliebte Aufgaben",
"noTasks": "Noch keine Aufgabenaktivität", "noTasks": "Noch keine Aufgabenaktivität",
"emptyActionOpenTasks": "Aufgaben öffnen",
"lockedTitle": "Analytics freischalten", "lockedTitle": "Analytics freischalten",
"lockedBody": "Erhalte tiefe Einblicke in die Interaktionen deines Events mit dem Premium-Paket." "lockedBody": "Erhalte tiefe Einblicke in die Interaktionen deines Events mit dem Premium-Paket."
}, },
@@ -2893,6 +3011,26 @@
"subtitle": "Wähle ein Paket, um mehr Funktionen und Limits freizuschalten.", "subtitle": "Wähle ein Paket, um mehr Funktionen und Limits freizuschalten.",
"recommendationTitle": "Empfohlen für dich", "recommendationTitle": "Empfohlen für dich",
"recommendationBody": "Das hervorgehobene Paket enthält das gewünschte Feature.", "recommendationBody": "Das hervorgehobene Paket enthält das gewünschte Feature.",
"compare": {
"title": "Pakete vergleichen",
"helper": "Wische, um Pakete nebeneinander zu vergleichen.",
"toggleCards": "Karten",
"toggleCompare": "Vergleichen",
"headers": {
"plan": "Paket",
"price": "Preis"
},
"rows": {
"photos": "Fotos",
"guests": "Gäste",
"days": "Galerietage"
},
"values": {
"included": "Enthalten",
"notIncluded": "Nicht enthalten",
"unlimited": "Unbegrenzt"
}
},
"select": "Auswählen", "select": "Auswählen",
"manage": "Paket verwalten", "manage": "Paket verwalten",
"limits": { "limits": {
@@ -2906,7 +3044,13 @@
}, },
"features": { "features": {
"advanced_analytics": "Erweiterte Analytics", "advanced_analytics": "Erweiterte Analytics",
"basic_uploads": "Basis-Uploads",
"custom_branding": "Eigenes Branding", "custom_branding": "Eigenes Branding",
"custom_tasks": "Benutzerdefinierte Aufgaben",
"limited_sharing": "Begrenztes Teilen",
"live_slideshow": "Live-Slideshow",
"priority_support": "Priorisierter Support",
"unlimited_sharing": "Unbegrenztes Teilen",
"watermark_removal": "Kein Wasserzeichen" "watermark_removal": "Kein Wasserzeichen"
}, },
"status": { "status": {
@@ -2918,7 +3062,9 @@
}, },
"badges": { "badges": {
"recommended": "Empfohlen", "recommended": "Empfohlen",
"active": "Aktiv" "active": "Aktiv",
"upgrade": "Upgrade",
"downgrade": "Downgrade"
}, },
"confirmTitle": "Kauf bestätigen", "confirmTitle": "Kauf bestätigen",
"confirmSubtitle": "Du upgradest auf:", "confirmSubtitle": "Du upgradest auf:",
@@ -2931,6 +3077,7 @@
"payNow": "Jetzt zahlen", "payNow": "Jetzt zahlen",
"errors": { "errors": {
"checkout": "Checkout fehlgeschlagen" "checkout": "Checkout fehlgeschlagen"
} },
"selectDisabled": "Nicht verfügbar"
} }
} }

View File

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

View File

@@ -34,6 +34,27 @@
"more": "Unable to load more entries.", "more": "Unable to load more entries.",
"portal": "Unable to open the Paddle portal." "portal": "Unable to open the Paddle portal."
}, },
"checkoutSuccess": "Checkout completed. Your package will activate shortly.",
"checkoutCancelled": "Checkout was cancelled.",
"checkoutActivated": "Your package is now active.",
"checkoutPendingTitle": "Activating your package",
"checkoutPendingBody": "This can take a few minutes. We will update this screen once the package is active.",
"checkoutPendingBadge": "Pending",
"checkoutPendingRefresh": "Refresh",
"checkoutPendingDismiss": "Dismiss",
"checkoutFailedTitle": "Checkout failed",
"checkoutFailedBody": "The payment did not complete. You can try again or contact support.",
"checkoutFailedBadge": "Failed",
"checkoutFailedRetry": "Try again",
"checkoutFailedDismiss": "Dismiss",
"checkoutActionTitle": "Action required",
"checkoutActionBody": "Complete your payment to activate the package.",
"checkoutActionBadge": "Action needed",
"checkoutActionButton": "Continue checkout",
"checkoutFailureReasons": {
"paddle_failed": "The payment was declined.",
"paddle_cancelled": "The checkout was cancelled."
},
"sections": { "sections": {
"invoices": { "invoices": {
"title": "Invoices & payments", "title": "Invoices & payments",
@@ -172,6 +193,8 @@
}, },
"common": { "common": {
"all": "All", "all": "All",
"anonymous": "Anonymous",
"error": "Something went wrong",
"loadMore": "Load more", "loadMore": "Load more",
"processing": "Processing…", "processing": "Processing…",
"select": "Select", "select": "Select",
@@ -858,15 +881,23 @@
"mode": "Mode" "mode": "Mode"
}, },
"mode": { "mode": {
"title": "Choose your photobooth type", "title": "Uploader connection",
"description": "Pick classic FTP or Sparkbooth HTTP upload. Switching regenerates credentials.", "description": "Use the Fotospiel uploader app for live HTTP uploads. Rotating access regenerates credentials.",
"active": "Current: {{mode}}" "active": "Current: {{mode}}",
"uploader": "Uploader App (HTTP)"
},
"selector": {
"title": "Connection",
"description": "Use the Fotospiel uploader app for HTTP uploads."
}, },
"credentials": { "credentials": {
"heading": "FTP credentials", "heading": "Uploader app credentials",
"description": "Share these credentials with your photobooth software.", "description": "Share these credentials with the Fotospiel uploader app.",
"sparkboothTitle": "Sparkbooth upload (HTTP)", "uploaderTitle": "Uploader App (HTTP)",
"sparkboothDescription": "Enter URL, username and password in Sparkbooth. Responses default to JSON (XML optional).", "uploaderDescription": "Enter URL, username and password in the Fotospiel uploader app. Responses default to JSON (XML optional).",
"show": "Show credentials",
"hide": "Hide credentials",
"hidden": "Credentials are hidden. Tap to show them.",
"host": "Host", "host": "Host",
"port": "Port", "port": "Port",
"username": "Username", "username": "Username",
@@ -875,6 +906,44 @@
"postUrl": "Upload URL", "postUrl": "Upload URL",
"responseFormat": "Response format" "responseFormat": "Response format"
}, },
"connectCode": {
"label": "Connect code",
"description": "Create a 6-digit code for the uploader app.",
"expires": "Expires: {{date}}",
"actions": {
"generate": "Generate connect code",
"generated": "Connect code created"
},
"errors": {
"failed": "Connect code could not be created."
}
},
"uploader": {
"hint": "POST with media file or base64 \"media\" field; app uses these credentials."
},
"steps": {
"activate": {
"title": "1. Activate photobooth",
"description": "Enable upload access for this event."
},
"download": {
"title": "2. Download uploader app"
},
"access": {
"title": "3. Generate connect code",
"description": "The code securely pairs the app with your event."
}
},
"uploaderDownload": {
"title": "Fotospiel Uploader App",
"description": "The Fotospiel Uploader App is required so uploads stay stable, credentials remain protected, and no files are lost.",
"emailAction": "Send download links by email",
"emailSuccess": "Download links were sent by email.",
"emailFailed": "Email could not be sent.",
"actionWindows": "Download uploader (Windows)",
"actionMac": "Download uploader (macOS)",
"actionLinux": "Download uploader (Linux)"
},
"actions": { "actions": {
"enable": "Activate photobooth", "enable": "Activate photobooth",
"disable": "Disable", "disable": "Disable",
@@ -892,9 +961,9 @@
"title": "Setup checklist", "title": "Setup checklist",
"description": "Complete each step before guests upload.", "description": "Complete each step before guests upload.",
"enable": "Activate access", "enable": "Activate access",
"enableCopy": "Enable the FTP account in your photobooth software.", "enableCopy": "Enable the uploader app connection for this event.",
"share": "Share credentials", "share": "Share credentials",
"shareCopy": "Hand over host, user, and password to the operator.", "shareCopy": "Share URL, username, and password with the operator.",
"monitor": "Monitor uploads", "monitor": "Monitor uploads",
"monitorCopy": "Watch uploads & limits in the dashboard." "monitorCopy": "Watch uploads & limits in the dashboard."
}, },
@@ -1401,11 +1470,11 @@
"submit": "Save emotion" "submit": "Save emotion"
} }
}, },
"management": { "management": {
"photobooth": { "photobooth": {
"title": "Photobooth uploads", "title": "Photobooth uploads",
"titleForEvent": "Manage photobooth uploads", "titleForEvent": "Manage photobooth uploads",
"subtitle": "Create FTP access for photobooth software and keep limits in sight.", "subtitle": "Create uploader access for photobooth apps and keep limits in sight.",
"actions": { "actions": {
"backToEvent": "Back to detail view", "backToEvent": "Back to detail view",
"allEvents": "Back to event list" "allEvents": "Back to event list"
@@ -1853,23 +1922,63 @@
"titleShort": "Branding", "titleShort": "Branding",
"previewTitle": "Guest app preview", "previewTitle": "Guest app preview",
"previewSubtitle": "Current colors & fonts", "previewSubtitle": "Current colors & fonts",
"previewCta": "Upload photos",
"primary": "Primary", "primary": "Primary",
"accent": "Accent", "accent": "Accent",
"background": "Background",
"surface": "Surface",
"lockedBranding": "Branding is locked for this package.",
"source": "Branding source",
"sourceHint": "Use tenant branding or override for this event.",
"useDefault": "Tenant",
"useCustom": "Event",
"usingDefault": "Tenant branding active",
"usingCustom": "Event branding active",
"mode": "Theme",
"modeLight": "Light",
"modeAuto": "Auto",
"modeDark": "Dark",
"colors": "Colors", "colors": "Colors",
"primaryColor": "Primary color", "primaryColor": "Primary color",
"accentColor": "Accent color", "accentColor": "Accent color",
"backgroundColor": "Background color",
"surfaceColor": "Surface color",
"fonts": "Fonts", "fonts": "Fonts",
"headingFont": "Headline font", "headingFont": "Headline font",
"headingFontPlaceholder": "SF Pro Display", "headingFontPlaceholder": "SF Pro Display",
"bodyFont": "Body font", "bodyFont": "Body font",
"bodyFontPlaceholder": "SF Pro Text", "bodyFontPlaceholder": "SF Pro Text",
"fontSize": "Font size",
"fontSizeSmall": "S",
"fontSizeMedium": "M",
"fontSizeLarge": "L",
"logo": "Logo", "logo": "Logo",
"logoAlt": "Logo", "logoAlt": "Logo",
"logoModeUpload": "Upload",
"logoModeEmoticon": "Emoticon",
"logoValue": "Emoticon",
"logoValuePlaceholder": "🎉",
"logoPosition": "Position",
"positionLeft": "Left",
"positionCenter": "Center",
"positionRight": "Right",
"logoSize": "Size",
"logoSizeSmall": "S",
"logoSizeMedium": "M",
"logoSizeLarge": "L",
"replaceLogo": "Replace logo", "replaceLogo": "Replace logo",
"removeLogo": "Remove", "removeLogo": "Remove",
"logoHint": "Upload a logo to brand guest invites and QR posters.", "logoHint": "Upload a logo or use an emoji for the guest header.",
"uploadLogo": "Upload logo (max. 1 MB)", "uploadLogo": "Upload logo (max. 1 MB)",
"logoTooLarge": "Logo must be under 1 MB.", "logoTooLarge": "Logo must be under 1 MB.",
"buttons": "Buttons & links",
"buttonsHint": "Style, radius, and link color for CTA buttons.",
"buttonFilled": "Filled",
"buttonOutline": "Outline",
"buttonRadius": "Radius",
"buttonPrimary": "Button primary",
"buttonSecondary": "Button secondary",
"linkColor": "Link color",
"save": "Save branding", "save": "Save branding",
"saving": "Saving...", "saving": "Saving...",
"saveSuccess": "Branding saved.", "saveSuccess": "Branding saved.",
@@ -2327,7 +2436,7 @@
"mobileProfile": { "mobileProfile": {
"title": "Profile", "title": "Profile",
"settings": "Settings", "settings": "Settings",
"account": "Account & security", "account": "Edit account",
"language": "Language", "language": "Language",
"languageDe": "Deutsch", "languageDe": "Deutsch",
"languageEn": "English", "languageEn": "English",
@@ -2879,16 +2988,25 @@
"analytics": { "analytics": {
"title": "Analytics", "title": "Analytics",
"upgradeAction": "Upgrade to Premium", "upgradeAction": "Upgrade to Premium",
"kpiTitle": "Event snapshot",
"kpiUploads": "Uploads",
"kpiContributors": "Contributors",
"kpiLikes": "Likes",
"activityTitle": "Activity Timeline", "activityTitle": "Activity Timeline",
"timeframe": "Last {{hours}} hours",
"timeframeHint": "Older activity hidden",
"uploadsPerHour": "Uploads per hour", "uploadsPerHour": "Uploads per hour",
"noActivity": "No uploads yet", "noActivity": "No uploads yet",
"emptyActionShareQr": "Share your QR code",
"contributorsTitle": "Top Contributors", "contributorsTitle": "Top Contributors",
"likesCount": "{{count}} likes", "likesCount": "{{count}} likes",
"likesCount_one": "{{count}} like", "likesCount_one": "{{count}} like",
"likesCount_other": "{{count}} likes", "likesCount_other": "{{count}} likes",
"noContributors": "No contributors yet", "noContributors": "No contributors yet",
"emptyActionInvite": "Invite guests",
"tasksTitle": "Popular Tasks", "tasksTitle": "Popular Tasks",
"noTasks": "No task activity yet", "noTasks": "No task activity yet",
"emptyActionOpenTasks": "Open tasks",
"lockedTitle": "Unlock Analytics", "lockedTitle": "Unlock Analytics",
"lockedBody": "Get deep insights into your event engagement with the Premium package." "lockedBody": "Get deep insights into your event engagement with the Premium package."
}, },
@@ -2897,6 +3015,26 @@
"subtitle": "Choose a package to unlock more features and limits.", "subtitle": "Choose a package to unlock more features and limits.",
"recommendationTitle": "Recommended for you", "recommendationTitle": "Recommended for you",
"recommendationBody": "The highlighted package includes the feature you requested.", "recommendationBody": "The highlighted package includes the feature you requested.",
"compare": {
"title": "Compare plans",
"helper": "Swipe to compare packages side by side.",
"toggleCards": "Cards",
"toggleCompare": "Compare",
"headers": {
"plan": "Plan",
"price": "Price"
},
"rows": {
"photos": "Photos",
"guests": "Guests",
"days": "Gallery days"
},
"values": {
"included": "Included",
"notIncluded": "Not included",
"unlimited": "Unlimited"
}
},
"select": "Select", "select": "Select",
"manage": "Manage Plan", "manage": "Manage Plan",
"limits": { "limits": {
@@ -2910,7 +3048,13 @@
}, },
"features": { "features": {
"advanced_analytics": "Advanced Analytics", "advanced_analytics": "Advanced Analytics",
"basic_uploads": "Basic uploads",
"custom_branding": "Custom Branding", "custom_branding": "Custom Branding",
"custom_tasks": "Custom tasks",
"limited_sharing": "Limited sharing",
"live_slideshow": "Live slideshow",
"priority_support": "Priority support",
"unlimited_sharing": "Unlimited sharing",
"watermark_removal": "No Watermark" "watermark_removal": "No Watermark"
}, },
"status": { "status": {
@@ -2922,7 +3066,9 @@
}, },
"badges": { "badges": {
"recommended": "Recommended", "recommended": "Recommended",
"active": "Active" "active": "Active",
"upgrade": "Upgrade",
"downgrade": "Downgrade"
}, },
"confirmTitle": "Confirm Purchase", "confirmTitle": "Confirm Purchase",
"confirmSubtitle": "You are upgrading to:", "confirmSubtitle": "You are upgrading to:",
@@ -2935,6 +3081,7 @@
"payNow": "Pay Now", "payNow": "Pay Now",
"errors": { "errors": {
"checkout": "Checkout failed" "checkout": "Checkout failed"
} },
"selectDisabled": "Not available"
} }
} }

View File

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

View 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');
});
});

View 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,
};
}

View File

@@ -14,7 +14,7 @@ export type EventTabCounts = Partial<{
tasks: number; tasks: number;
}>; }>;
type Translator = (key: string, fallback: string) => string; type Translator = any;
export function buildEventTabs(event: TenantEvent, translate: Translator, counts: EventTabCounts = {}) { export function buildEventTabs(event: TenantEvent, translate: Translator, counts: EventTabCounts = {}) {
if (!event.slug) { if (!event.slug) {

View File

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

View File

@@ -8,20 +8,11 @@ import { Pressable } from '@tamagui/react-native-web-lite';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { MobileShell, HeaderActionButton } from './components/MobileShell'; import { MobileShell, HeaderActionButton } from './components/MobileShell';
import { MobileCard, CTAButton, PillBadge } from './components/Primitives'; import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
import React from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Package, Receipt, RefreshCcw, Sparkles } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite';
import toast from 'react-hot-toast';
import { MobileShell, HeaderActionButton } from './components/MobileShell';
import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
import { import {
createTenantBillingPortalSession, createTenantBillingPortalSession,
getTenantPackagesOverview, getTenantPackagesOverview,
getTenantPaddleTransactions, getTenantPaddleTransactions,
getTenantPackageCheckoutStatus,
TenantPackageSummary, TenantPackageSummary,
PaddleTransactionSummary, PaddleTransactionSummary,
} from '../api'; } from '../api';
@@ -37,6 +28,14 @@ import {
getPackageFeatureLabel, getPackageFeatureLabel,
getPackageLimitEntries, getPackageLimitEntries,
} from './lib/packageSummary'; } from './lib/packageSummary';
import {
PendingCheckout,
loadPendingCheckout,
shouldClearPendingCheckout,
storePendingCheckout,
} from './lib/billingCheckout';
const CHECKOUT_POLL_INTERVAL_MS = 10000;
export default function MobileBillingPage() { export default function MobileBillingPage() {
const { t } = useTranslation('management'); const { t } = useTranslation('management');
@@ -50,6 +49,11 @@ export default function MobileBillingPage() {
const [loading, setLoading] = React.useState(true); const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null); const [error, setError] = React.useState<string | null>(null);
const [portalBusy, setPortalBusy] = React.useState(false); const [portalBusy, setPortalBusy] = React.useState(false);
const [pendingCheckout, setPendingCheckout] = React.useState<PendingCheckout | null>(() => loadPendingCheckout());
const [checkoutStatus, setCheckoutStatus] = React.useState<string | null>(null);
const [checkoutStatusReason, setCheckoutStatusReason] = React.useState<string | null>(null);
const [checkoutActionUrl, setCheckoutActionUrl] = React.useState<string | null>(null);
const lastCheckoutStatusRef = React.useRef<string | null>(null);
const packagesRef = React.useRef<HTMLDivElement | null>(null); const packagesRef = React.useRef<HTMLDivElement | null>(null);
const invoicesRef = React.useRef<HTMLDivElement | null>(null); const invoicesRef = React.useRef<HTMLDivElement | null>(null);
const supportEmail = 'support@fotospiel.de'; const supportEmail = 'support@fotospiel.de';
@@ -105,6 +109,11 @@ export default function MobileBillingPage() {
} }
}, [portalBusy, t]); }, [portalBusy, t]);
const persistPendingCheckout = React.useCallback((next: PendingCheckout | null) => {
setPendingCheckout(next);
storePendingCheckout(next);
}, []);
React.useEffect(() => { React.useEffect(() => {
void load(); void load();
}, [load]); }, [load]);
@@ -118,6 +127,115 @@ export default function MobileBillingPage() {
} }
}, [location.hash, loading]); }, [location.hash, loading]);
React.useEffect(() => {
if (!location.search) {
return;
}
const params = new URLSearchParams(location.search);
const checkout = params.get('checkout');
const packageId = params.get('package_id');
if (!checkout) {
return;
}
if (checkout === 'success') {
const packageIdNumber = packageId ? Number(packageId) : null;
const existingSessionId = pendingCheckout?.checkoutSessionId ?? null;
const pendingEntry = {
packageId: Number.isFinite(packageIdNumber) ? packageIdNumber : null,
checkoutSessionId: existingSessionId,
startedAt: Date.now(),
};
persistPendingCheckout(pendingEntry);
toast.success(t('billing.checkoutSuccess', 'Checkout completed. Your package will activate shortly.'));
} else if (checkout === 'cancel') {
persistPendingCheckout(null);
toast(t('billing.checkoutCancelled', 'Checkout was cancelled.'));
}
params.delete('checkout');
params.delete('package_id');
navigate(
{
pathname: location.pathname,
search: params.toString(),
hash: location.hash,
},
{ replace: true },
);
}, [location.hash, location.pathname, location.search, navigate, pendingCheckout?.checkoutSessionId, persistPendingCheckout, t]);
React.useEffect(() => {
if (!pendingCheckout) {
return;
}
if (shouldClearPendingCheckout(pendingCheckout, activePackage?.package_id ?? null)) {
persistPendingCheckout(null);
}
}, [activePackage?.package_id, pendingCheckout, persistPendingCheckout]);
React.useEffect(() => {
if (!pendingCheckout?.checkoutSessionId) {
setCheckoutStatus(null);
setCheckoutStatusReason(null);
setCheckoutActionUrl(null);
lastCheckoutStatusRef.current = null;
return;
}
let active = true;
let intervalId: ReturnType<typeof setInterval> | null = null;
const poll = async () => {
try {
const result = await getTenantPackageCheckoutStatus(pendingCheckout.checkoutSessionId as string);
if (!active) {
return;
}
setCheckoutStatus(result.status);
setCheckoutStatusReason(result.reason ?? null);
setCheckoutActionUrl(typeof result.checkout_url === 'string' ? result.checkout_url : null);
const lastStatus = lastCheckoutStatusRef.current;
lastCheckoutStatusRef.current = result.status;
if (result.status === 'completed') {
persistPendingCheckout(null);
if (lastStatus !== 'completed') {
toast.success(t('billing.checkoutActivated', 'Your package is now active.'));
}
await load();
if (intervalId) {
clearInterval(intervalId);
}
return;
}
if (result.status === 'failed' || result.status === 'cancelled') {
if (intervalId) {
clearInterval(intervalId);
}
}
} catch {
if (!active) {
return;
}
}
};
void poll();
intervalId = setInterval(poll, CHECKOUT_POLL_INTERVAL_MS);
return () => {
active = false;
if (intervalId) {
clearInterval(intervalId);
}
};
}, [load, pendingCheckout?.checkoutSessionId, persistPendingCheckout, t]);
return ( return (
<MobileShell <MobileShell
activeTab="profile" activeTab="profile"
@@ -137,6 +255,109 @@ export default function MobileBillingPage() {
<CTAButton label={t('billing.actions.refresh', 'Refresh')} tone="ghost" onPress={load} /> <CTAButton label={t('billing.actions.refresh', 'Refresh')} tone="ghost" onPress={load} />
</MobileCard> </MobileCard>
) : null} ) : null}
{pendingCheckout && (checkoutStatus === 'failed' || checkoutStatus === 'cancelled') ? (
<MobileCard borderColor={danger} backgroundColor="$red1" space="$2">
<XStack alignItems="center" justifyContent="space-between">
<YStack space="$0.5" flex={1}>
<Text fontSize="$sm" fontWeight="800" color={danger}>
{t('billing.checkoutFailedTitle', 'Checkout failed')}
</Text>
<Text fontSize="$xs" color={muted}>
{t(
'billing.checkoutFailedBody',
'The payment did not complete. You can try again or contact support.'
)}
</Text>
{checkoutStatusReason ? (
<Text fontSize="$xs" color={muted}>
{t(`billing.checkoutFailureReasons.${checkoutStatusReason}`, checkoutStatusReason)}
</Text>
) : null}
</YStack>
<PillBadge tone="danger">
{t('billing.checkoutFailedBadge', 'Failed')}
</PillBadge>
</XStack>
<XStack space="$2">
<CTAButton
label={t('billing.checkoutFailedRetry', 'Try again')}
onPress={() => navigate(adminPath('/mobile/billing/shop'))}
fullWidth={false}
/>
<CTAButton
label={t('billing.checkoutFailedDismiss', 'Dismiss')}
tone="ghost"
onPress={() => persistPendingCheckout(null)}
fullWidth={false}
/>
</XStack>
</MobileCard>
) : null}
{pendingCheckout && checkoutStatus === 'requires_customer_action' ? (
<MobileCard borderColor={accentSoft} backgroundColor={accentSoft} space="$2">
<XStack alignItems="center" justifyContent="space-between">
<YStack space="$0.5" flex={1}>
<Text fontSize="$sm" fontWeight="800" color={textStrong}>
{t('billing.checkoutActionTitle', 'Action required')}
</Text>
<Text fontSize="$xs" color={muted}>
{t('billing.checkoutActionBody', 'Complete your payment to activate the package.')}
</Text>
</YStack>
<PillBadge tone="warning">
{t('billing.checkoutActionBadge', 'Action needed')}
</PillBadge>
</XStack>
<XStack space="$2">
<CTAButton
label={t('billing.checkoutActionButton', 'Continue checkout')}
onPress={() => {
if (checkoutActionUrl && typeof window !== 'undefined') {
window.open(checkoutActionUrl, '_blank', 'noopener');
return;
}
navigate(adminPath('/mobile/billing/shop'));
}}
fullWidth={false}
/>
<CTAButton
label={t('billing.checkoutFailedDismiss', 'Dismiss')}
tone="ghost"
onPress={() => persistPendingCheckout(null)}
fullWidth={false}
/>
</XStack>
</MobileCard>
) : null}
{pendingCheckout && checkoutStatus !== 'failed' && checkoutStatus !== 'cancelled' && checkoutStatus !== 'requires_customer_action' ? (
<MobileCard borderColor={accentSoft} backgroundColor={accentSoft} space="$2">
<XStack alignItems="center" justifyContent="space-between">
<YStack space="$0.5" flex={1}>
<Text fontSize="$sm" fontWeight="800" color={textStrong}>
{t('billing.checkoutPendingTitle', 'Activating your package')}
</Text>
<Text fontSize="$xs" color={muted}>
{t(
'billing.checkoutPendingBody',
'This can take a few minutes. We will update this screen once the package is active.'
)}
</Text>
</YStack>
<PillBadge tone="warning">
{t('billing.checkoutPendingBadge', 'Pending')}
</PillBadge>
</XStack>
<XStack space="$2">
<CTAButton label={t('billing.checkoutPendingRefresh', 'Refresh')} onPress={load} fullWidth={false} />
<CTAButton
label={t('billing.checkoutPendingDismiss', 'Dismiss')}
tone="ghost"
onPress={() => persistPendingCheckout(null)}
fullWidth={false}
/>
</XStack>
</MobileCard>
) : null}
<MobileCard space="$2" ref={packagesRef as any}> <MobileCard space="$2" ref={packagesRef as any}>
<XStack alignItems="center" space="$2"> <XStack alignItems="center" space="$2">
@@ -235,7 +456,6 @@ export default function MobileBillingPage() {
))} ))}
</YStack> </YStack>
)} )}
{null}
</MobileCard> </MobileCard>
<MobileCard space="$2"> <MobileCard space="$2">
@@ -263,7 +483,6 @@ export default function MobileBillingPage() {
))} ))}
</YStack> </YStack>
)} )}
{null}
</MobileCard> </MobileCard>
</MobileShell> </MobileShell>
); );
@@ -548,155 +767,3 @@ function formatDate(value: string | null | undefined): string {
if (Number.isNaN(date.getTime())) return '—'; if (Number.isNaN(date.getTime())) return '—';
return date.toLocaleDateString(undefined, { day: '2-digit', month: 'short', year: 'numeric' }); return date.toLocaleDateString(undefined, { day: '2-digit', month: 'short', year: 'numeric' });
} }
function renderFeatureBadge(pkg: TenantPackageSummary, t: any, key: string, label: string) {
const value = (pkg.package_limits as any)?.[key] ?? (pkg as any)[key];
if (value === undefined || value === null) return null;
const enabled = value !== false;
return <PillBadge tone={enabled ? 'success' : 'muted'}>{enabled ? label : `${label} off`}</PillBadge>;
}
function UsageBar({ metric }: { metric: PackageUsageMetric }) {
const { t } = useTranslation('management');
const { muted, textStrong, border, primary, subtle, warningText, danger } = useAdminTheme();
const labelMap: Record<PackageUsageMetric['key'], string> = {
events: t('mobileBilling.usage.events', 'Events'),
guests: t('mobileBilling.usage.guests', 'Guests'),
photos: t('mobileBilling.usage.photos', 'Photos'),
gallery: t('mobileBilling.usage.gallery', 'Gallery days'),
};
if (!metric.limit) {
return null;
}
const status = getUsageState(metric);
const hasUsage = metric.used !== null;
const valueText = hasUsage
? t('mobileBilling.usage.value', { used: metric.used, limit: metric.limit })
: t('mobileBilling.usage.limit', { limit: metric.limit });
const remainingText = metric.remaining !== null
? t('mobileBilling.usage.remainingOf', {
remaining: metric.remaining,
limit: metric.limit,
defaultValue: 'Remaining {{remaining}} of {{limit}}',
})
: null;
const fill = usagePercent(metric);
const statusLabel =
status === 'danger'
? t('mobileBilling.usage.statusDanger', 'Limit reached')
: status === 'warning'
? t('mobileBilling.usage.statusWarning', 'Low')
: null;
const fillColor = status === 'danger' ? danger : status === 'warning' ? warningText : primary;
return (
<YStack space="$1.5">
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$xs" color={muted}>
{labelMap[metric.key]}
</Text>
<XStack alignItems="center" space="$1.5">
{statusLabel ? <PillBadge tone={status === 'danger' ? 'danger' : 'warning'}>{statusLabel}</PillBadge> : null}
<Text fontSize="$xs" color={textStrong} fontWeight="700">
{valueText}
</Text>
</XStack>
</XStack>
<YStack height={6} borderRadius={999} backgroundColor={border} overflow="hidden">
<YStack height="100%" width={`${fill}%`} backgroundColor={hasUsage ? fillColor : subtle} />
</YStack>
{remainingText ? (
<Text fontSize="$xs" color={muted}>
{remainingText}
</Text>
) : null}
</YStack>
);
}
function formatAmount(value: number | null | undefined, currency: string | null | undefined): string {
if (value === null || value === undefined) {
return '—';
}
const cur = currency ?? 'EUR';
try {
return new Intl.NumberFormat(undefined, { style: 'currency', currency: cur }).format(value);
} catch {
return `${value} ${cur}`;
}
}
function AddonRow({ addon }: { addon: TenantAddonHistoryEntry }) {
const { t } = useTranslation('management');
const navigate = useNavigate();
const { border, textStrong, text, muted, subtle, primary } = useAdminTheme();
const labels: Record<TenantAddonHistoryEntry['status'], { tone: 'success' | 'warning' | 'muted'; text: string }> = {
completed: { tone: 'success', text: t('mobileBilling.status.completed', 'Completed') },
pending: { tone: 'warning', text: t('mobileBilling.status.pending', 'Pending') },
failed: { tone: 'muted', text: t('mobileBilling.status.failed', 'Failed') },
};
const status = labels[addon.status];
const eventName =
(addon.event?.name && typeof addon.event.name === 'string' && addon.event.name) ||
(addon.event?.name && typeof addon.event.name === 'object' ? addon.event.name?.en ?? addon.event.name?.de ?? Object.values(addon.event.name)[0] : null) ||
null;
const eventPath = addon.event?.slug ? ADMIN_EVENT_VIEW_PATH(addon.event.slug) : null;
const hasImpact = Boolean(addon.extra_photos || addon.extra_guests || addon.extra_gallery_days);
const impactBadges = hasImpact ? (
<XStack space="$2" marginTop="$1.5" flexWrap="wrap">
{addon.extra_photos ? (
<PillBadge tone="muted">{t('mobileBilling.extra.photos', '+{{count}} photos', { count: addon.extra_photos })}</PillBadge>
) : null}
{addon.extra_guests ? (
<PillBadge tone="muted">{t('mobileBilling.extra.guests', '+{{count}} guests', { count: addon.extra_guests })}</PillBadge>
) : null}
{addon.extra_gallery_days ? (
<PillBadge tone="muted">{t('mobileBilling.extra.days', '+{{count}} days', { count: addon.extra_gallery_days })}</PillBadge>
) : null}
</XStack>
) : null;
return (
<MobileCard borderColor={border} padding="$3" space="$1.5">
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{addon.label ?? addon.addon_key}
</Text>
<PillBadge tone={status.tone}>{status.text}</PillBadge>
</XStack>
{eventName ? (
eventPath ? (
<Pressable onPress={() => navigate(eventPath)}>
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$xs" color={textStrong} fontWeight="600">
{eventName}
</Text>
<Text fontSize="$xs" color={primary} fontWeight="700">
{t('mobileBilling.openEvent', 'Open event')}
</Text>
</XStack>
</Pressable>
) : (
<Text fontSize="$xs" color={subtle}>
{eventName}
</Text>
)
) : null}
{impactBadges}
<Text fontSize="$sm" color={text} marginTop="$1.5">
{formatAmount(addon.amount, addon.currency)}
</Text>
<Text fontSize="$xs" color={muted}>
{formatDate(addon.purchased_at)}
</Text>
</MobileCard>
);
}
function formatDate(value: string | null | undefined): string {
if (!value) return '—';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '—';
return date.toLocaleDateString(undefined, { day: '2-digit', month: 'short', year: 'numeric' });
}

View File

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

View File

@@ -1,12 +1,12 @@
import React from 'react'; import React from 'react';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Bell, CheckCircle2, Download, Image as ImageIcon, ListTodo, MessageCircle, QrCode, Settings, ShieldCheck, Smartphone, Users, Sparkles, TrendingUp } from 'lucide-react'; import { Bell, CalendarDays, Camera, CheckCircle2, ChevronDown, Download, Image as ImageIcon, Layout, ListTodo, MapPin, Megaphone, MessageCircle, Pencil, QrCode, Settings, ShieldCheck, Smartphone, Sparkles, TrendingUp, Tv, Users } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks'; import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text'; import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite'; import { Pressable } from '@tamagui/react-native-web-lite';
import { MobileShell, renderEventLocation } from './components/MobileShell'; import { MobileShell } from './components/MobileShell';
import { MobileCard, CTAButton, KpiTile, ActionTile, PillBadge, SkeletonCard } from './components/Primitives'; import { MobileCard, CTAButton, KpiTile, ActionTile, PillBadge, SkeletonCard } from './components/Primitives';
import { MobileSheet } from './components/Sheet'; import { MobileSheet } from './components/Sheet';
import { adminPath, ADMIN_WELCOME_BASE_PATH } from '../constants'; import { adminPath, ADMIN_WELCOME_BASE_PATH } from '../constants';
@@ -21,6 +21,7 @@ import { collectPackageFeatures, formatPackageLimit, getPackageFeatureLabel, get
import { trackOnboarding } from '../api'; import { trackOnboarding } from '../api';
import { useAuth } from '../auth/context'; import { useAuth } from '../auth/context';
import { ADMIN_ACTION_COLORS, ADMIN_MOTION, useAdminTheme } from './theme'; import { ADMIN_ACTION_COLORS, ADMIN_MOTION, useAdminTheme } from './theme';
import { isPastEvent } from './eventDate';
type DeviceSetupProps = { type DeviceSetupProps = {
installPrompt: ReturnType<typeof useInstallPrompt>; installPrompt: ReturnType<typeof useInstallPrompt>;
@@ -32,6 +33,7 @@ type DeviceSetupProps = {
export default function MobileDashboardPage() { export default function MobileDashboardPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const { slug: slugParam } = useParams<{ slug?: string }>();
const { t, i18n } = useTranslation('management'); const { t, i18n } = useTranslation('management');
const { events, activeEvent, hasEvents, hasMultipleEvents, isLoading, selectEvent } = useEventContext(); const { events, activeEvent, hasEvents, hasMultipleEvents, isLoading, selectEvent } = useEventContext();
const { status } = useAuth(); const { status } = useAuth();
@@ -42,11 +44,12 @@ export default function MobileDashboardPage() {
const [tourStep, setTourStep] = React.useState(0); const [tourStep, setTourStep] = React.useState(0);
const [summaryOpen, setSummaryOpen] = React.useState(false); const [summaryOpen, setSummaryOpen] = React.useState(false);
const [summarySeenOverride, setSummarySeenOverride] = React.useState<number | null>(null); const [summarySeenOverride, setSummarySeenOverride] = React.useState<number | null>(null);
const [eventSwitcherOpen, setEventSwitcherOpen] = React.useState(false);
const onboardingTrackedRef = React.useRef(false); const onboardingTrackedRef = React.useRef(false);
const installPrompt = useInstallPrompt(); const installPrompt = useInstallPrompt();
const pushState = useAdminPushSubscription(); const pushState = useAdminPushSubscription();
const devicePermissions = useDevicePermissions(); const devicePermissions = useDevicePermissions();
const { textStrong, muted, border, surface, accentSoft, primary } = useAdminTheme(); const { textStrong, muted, accentSoft, primary } = useAdminTheme();
const text = textStrong; const text = textStrong;
const accentText = primary; const accentText = primary;
@@ -84,6 +87,14 @@ export default function MobileDashboardPage() {
const tourTargetSlug = activeEvent?.slug ?? effectiveEvents[0]?.slug ?? null; const tourTargetSlug = activeEvent?.slug ?? effectiveEvents[0]?.slug ?? null;
const tourStepKeys = React.useMemo(() => resolveTourStepKeys(effectiveHasEvents), [effectiveHasEvents]); const tourStepKeys = React.useMemo(() => resolveTourStepKeys(effectiveHasEvents), [effectiveHasEvents]);
React.useEffect(() => {
if (!slugParam || slugParam === activeEvent?.slug) {
return;
}
selectEvent(slugParam);
}, [activeEvent?.slug, selectEvent, slugParam]);
React.useEffect(() => { React.useEffect(() => {
if (status !== 'authenticated' || onboardingTrackedRef.current) { if (status !== 'authenticated' || onboardingTrackedRef.current) {
return; return;
@@ -424,7 +435,7 @@ export default function MobileDashboardPage() {
onOpen={() => setSummaryOpen(true)} onOpen={() => setSummaryOpen(true)}
/> />
) : null} ) : null}
<EventPickerList events={effectiveEvents} locale={locale} text={text} muted={muted} border={border} /> <EventPickerList events={effectiveEvents} locale={locale} navigateOnSelect={false} />
{tourSheet} {tourSheet}
{packageSummarySheet} {packageSummarySheet}
</MobileShell> </MobileShell>
@@ -434,8 +445,7 @@ export default function MobileDashboardPage() {
return ( return (
<MobileShell <MobileShell
activeTab="home" activeTab="home"
title={resolveEventDisplayName(activeEvent ?? undefined)} title={t('mobileDashboard.title', 'Dashboard')}
subtitle={formatEventDate(activeEvent?.event_date, locale) ?? undefined}
> >
{showPackageSummaryBanner ? ( {showPackageSummaryBanner ? (
<PackageSummaryBanner <PackageSummaryBanner
@@ -443,28 +453,18 @@ export default function MobileDashboardPage() {
onOpen={() => setSummaryOpen(true)} onOpen={() => setSummaryOpen(true)}
/> />
) : null} ) : null}
<DeviceSetupCard <EventHeaderCard
installPrompt={installPrompt}
pushState={pushState}
devicePermissions={devicePermissions}
onOpenSettings={() => navigate(adminPath('/mobile/settings'))}
/>
<FeaturedActions
tasksEnabled={tasksEnabled}
onReviewPhotos={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/photos`))}
onManageTasks={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/tasks`))}
onShowQr={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/qr`))}
/>
<SecondaryGrid
event={activeEvent} event={activeEvent}
onGuests={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/members`))} locale={locale}
onPrint={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/qr`))} canSwitch={effectiveMultiple}
onInvites={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/members`))} onSwitch={() => setEventSwitcherOpen(true)}
onSettings={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}`))} onEdit={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/edit`))}
onAnalytics={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/analytics`))} />
<EventManagementGrid
event={activeEvent}
tasksEnabled={tasksEnabled}
onNavigate={(path) => navigate(path)}
/> />
<KpiStrip <KpiStrip
event={activeEvent} event={activeEvent}
stats={stats} stats={stats}
@@ -474,8 +474,20 @@ export default function MobileDashboardPage() {
/> />
<AlertsAndHints event={activeEvent} stats={stats} tasksEnabled={tasksEnabled} /> <AlertsAndHints event={activeEvent} stats={stats} tasksEnabled={tasksEnabled} />
<DeviceSetupCard
installPrompt={installPrompt}
pushState={pushState}
devicePermissions={devicePermissions}
onOpenSettings={() => navigate(adminPath('/mobile/settings'))}
/>
{tourSheet} {tourSheet}
{packageSummarySheet} {packageSummarySheet}
<EventSwitcherSheet
open={eventSwitcherOpen}
onClose={() => setEventSwitcherOpen(false)}
events={effectiveEvents}
locale={locale}
/>
</MobileShell> </MobileShell>
); );
} }
@@ -976,8 +988,20 @@ function OnboardingEmptyState({ installPrompt, pushState, devicePermissions, onO
); );
} }
function EventPickerList({ events, locale, text, muted, border }: { events: TenantEvent[]; locale: string; text: string; muted: string; border: string }) { function EventPickerList({
events,
locale,
onPick,
navigateOnSelect = true,
}: {
events: TenantEvent[];
locale: string;
onPick?: (event: TenantEvent) => void;
navigateOnSelect?: boolean;
}) {
const { t } = useTranslation('management'); const { t } = useTranslation('management');
const { textStrong, muted, border } = useAdminTheme();
const text = textStrong;
const { selectEvent } = useEventContext(); const { selectEvent } = useEventContext();
const navigate = useNavigate(); const navigate = useNavigate();
const [localEvents, setLocalEvents] = React.useState<TenantEvent[]>(events); const [localEvents, setLocalEvents] = React.useState<TenantEvent[]>(events);
@@ -1008,7 +1032,8 @@ function EventPickerList({ events, locale, text, muted, border }: { events: Tena
key={event.slug} key={event.slug}
onPress={() => { onPress={() => {
selectEvent(event.slug ?? null); selectEvent(event.slug ?? null);
if (event.slug) { onPick?.(event);
if (navigateOnSelect && event.slug) {
navigate(adminPath(`/mobile/events/${event.slug}`)); navigate(adminPath(`/mobile/events/${event.slug}`));
} }
}} }}
@@ -1036,140 +1061,232 @@ function EventPickerList({ events, locale, text, muted, border }: { events: Tena
); );
} }
function FeaturedActions({ function EventSwitcherSheet({
tasksEnabled, open,
onReviewPhotos, onClose,
onManageTasks, events,
onShowQr, locale,
}: { }: {
tasksEnabled: boolean; open: boolean;
onReviewPhotos: () => void; onClose: () => void;
onManageTasks: () => void; events: TenantEvent[];
onShowQr: () => void; locale: string;
}) { }) {
const { t } = useTranslation('management'); const { t } = useTranslation('management');
const { textStrong, muted, subtle } = useAdminTheme();
const text = textStrong;
const cards = [
{
key: 'photos',
label: t('mobileDashboard.photosLabel', 'Review photos'),
desc: t('mobileDashboard.photosDesc', 'Moderate uploads and highlights'),
icon: ImageIcon,
color: ADMIN_ACTION_COLORS.images,
action: onReviewPhotos,
},
{
key: 'tasks',
label: t('mobileDashboard.tasksLabel', 'Manage tasks & challenges'),
desc: tasksEnabled
? t('mobileDashboard.tasksDesc', 'Assign and track progress')
: t('mobileDashboard.tasksDisabledDesc', 'Guests do not see tasks (task mode off)'),
icon: ListTodo,
color: ADMIN_ACTION_COLORS.tasks,
action: onManageTasks,
},
{
key: 'qr',
label: t('mobileDashboard.qrLabel', 'Show / share QR code'),
desc: t('mobileDashboard.qrDesc', 'Posters, cards, and links'),
icon: QrCode,
color: ADMIN_ACTION_COLORS.qr,
action: onShowQr,
},
];
return ( return (
<YStack space="$2"> <MobileSheet open={open} title={t('mobileDashboard.pickEvent', 'Select an event')} onClose={onClose}>
{cards.map((card) => ( <EventPickerList events={events} locale={locale} navigateOnSelect={false} onPick={onClose} />
<Pressable key={card.key} onPress={card.action}> </MobileSheet>
<MobileCard borderColor={`${card.color}44`} backgroundColor={`${card.color}0f`} space="$2.5">
<XStack alignItems="center" space="$3">
<XStack width={44} height={44} borderRadius={14} backgroundColor={card.color} alignItems="center" justifyContent="center">
<card.icon size={20} color="white" />
</XStack>
<YStack space="$1" flex={1}>
<Text fontSize="$md" fontWeight="800" color={text}>
{card.label}
</Text>
<Text fontSize="$xs" color={muted}>
{card.desc}
</Text>
</YStack>
<Text fontSize="$xl" color={subtle}>
˃
</Text>
</XStack>
</MobileCard>
</Pressable>
))}
</YStack>
); );
} }
function SecondaryGrid({ function resolveLocation(event: TenantEvent | null, t: (key: string, fallback: string) => string): string {
if (!event) return t('events.detail.locationPlaceholder', 'Location');
const settings = (event.settings ?? {}) as Record<string, unknown>;
const candidate =
(settings.location as string | undefined) ??
(settings.address as string | undefined) ??
(settings.city as string | undefined);
if (candidate && candidate.trim()) {
return candidate;
}
return t('events.detail.locationPlaceholder', 'Location');
}
function EventHeaderCard({
event, event,
onGuests, locale,
onPrint, canSwitch,
onInvites, onSwitch,
onSettings, onEdit,
onAnalytics,
}: { }: {
event: TenantEvent | null; event: TenantEvent | null;
onGuests: () => void; locale: string;
onPrint: () => void; canSwitch: boolean;
onInvites: () => void; onSwitch: () => void;
onSettings: () => void; onEdit: () => void;
onAnalytics: () => void;
}) { }) {
const { t } = useTranslation('management'); const { t } = useTranslation('management');
const { textStrong, muted, border, surface, accentSoft, primary } = useAdminTheme(); const { textStrong, muted, border, surface, accentSoft, primary } = useAdminTheme();
const text = textStrong;
if (!event) {
return null;
}
const dateLabel = formatEventDate(event.event_date, locale) ?? t('events.detail.dateTbd', 'Date tbd');
const locationLabel = resolveLocation(event, t);
return (
<MobileCard space="$3" borderColor={border} backgroundColor={surface} position="relative">
<XStack alignItems="center" justifyContent="space-between" space="$2">
{canSwitch ? (
<Pressable onPress={onSwitch} aria-label={t('mobileDashboard.pickEvent', 'Select an event')}>
<XStack alignItems="center" space="$2">
<Text fontSize="$lg" fontWeight="800" color={textStrong}>
{resolveEventDisplayName(event)}
</Text>
<ChevronDown size={16} color={muted} />
</XStack>
</Pressable>
) : (
<Text fontSize="$lg" fontWeight="800" color={textStrong}>
{resolveEventDisplayName(event)}
</Text>
)}
<PillBadge tone={event.status === 'published' ? 'success' : 'warning'}>
{event.status === 'published'
? t('events.status.published', 'Live')
: t('events.status.draft', 'Draft')}
</PillBadge>
</XStack>
<XStack alignItems="center" space="$2">
<CalendarDays size={16} color={muted} />
<Text fontSize="$sm" color={muted}>
{dateLabel}
</Text>
<MapPin size={16} color={muted} />
<Text fontSize="$sm" color={muted}>
{locationLabel}
</Text>
</XStack>
<Pressable
aria-label={t('mobileEvents.edit', 'Edit event')}
onPress={onEdit}
style={{
position: 'absolute',
right: 16,
top: 16,
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: accentSoft,
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 6px 16px rgba(0,0,0,0.12)',
}}
>
<Pencil size={18} color={primary} />
</Pressable>
</MobileCard>
);
}
function EventManagementGrid({
event,
tasksEnabled,
onNavigate,
}: {
event: TenantEvent | null;
tasksEnabled: boolean;
onNavigate: (path: string) => void;
}) {
const { t } = useTranslation('management');
const { textStrong } = useAdminTheme();
const slug = event?.slug ?? null;
const brandingAllowed = isBrandingAllowed(event ?? null); const brandingAllowed = isBrandingAllowed(event ?? null);
if (!event) {
return null;
}
const tiles = [ const tiles = [
{
icon: Pencil,
label: t('mobileDashboard.shortcutSettings', 'Event settings'),
color: ADMIN_ACTION_COLORS.settings,
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/edit`)) : undefined,
disabled: !slug,
},
{
icon: Sparkles,
label: tasksEnabled
? t('events.quick.tasks', 'Tasks & Checklists')
: `${t('events.quick.tasks', 'Tasks & Checklists')} (${t('common:states.disabled', 'Disabled')})`,
color: ADMIN_ACTION_COLORS.tasks,
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/tasks`)) : undefined,
disabled: !tasksEnabled || !slug,
},
{
icon: QrCode,
label: t('events.quick.qr', 'QR Code Layouts'),
color: ADMIN_ACTION_COLORS.qr,
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/qr`)) : undefined,
disabled: !slug,
},
{
icon: ImageIcon,
label: t('events.quick.images', 'Image Management'),
color: ADMIN_ACTION_COLORS.images,
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/photos`)) : undefined,
disabled: !slug,
},
{
icon: Tv,
label: t('events.quick.liveShow', 'Live Show queue'),
color: ADMIN_ACTION_COLORS.images,
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/live-show`)) : undefined,
disabled: !slug,
},
{
icon: Settings,
label: t('events.quick.liveShowSettings', 'Live Show settings'),
color: ADMIN_ACTION_COLORS.images,
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/live-show/settings`)) : undefined,
disabled: !slug,
},
{ {
icon: Users, icon: Users,
label: t('mobileDashboard.shortcutGuests', 'Guest management'), label: t('events.quick.guests', 'Guest Management'),
color: ADMIN_ACTION_COLORS.guests, color: ADMIN_ACTION_COLORS.guests,
action: onGuests, onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/members`)) : undefined,
disabled: !slug,
},
{
icon: Megaphone,
label: t('events.quick.guestMessages', 'Guest messages'),
color: ADMIN_ACTION_COLORS.guestMessages,
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/guest-notifications`)) : undefined,
disabled: !slug,
},
{
icon: Layout,
label: t('events.quick.branding', 'Branding & Theme'),
color: ADMIN_ACTION_COLORS.branding,
onPress: slug && brandingAllowed ? () => onNavigate(adminPath(`/mobile/events/${slug}/branding`)) : undefined,
disabled: !brandingAllowed || !slug,
},
{
icon: Camera,
label: t('events.quick.photobooth', 'Photobooth'),
color: ADMIN_ACTION_COLORS.photobooth,
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/photobooth`)) : undefined,
disabled: !slug,
}, },
{ {
icon: TrendingUp, icon: TrendingUp,
label: t('mobileDashboard.shortcutAnalytics', 'Analytics'), label: t('mobileDashboard.shortcutAnalytics', 'Analytics'),
color: ADMIN_ACTION_COLORS.analytics, color: ADMIN_ACTION_COLORS.analytics,
action: onAnalytics, onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/analytics`)) : undefined,
}, disabled: !slug,
{
icon: QrCode,
label: t('mobileDashboard.shortcutPrints', 'Print & poster downloads'),
color: ADMIN_ACTION_COLORS.qr,
action: onPrint,
},
{
icon: Sparkles,
label: t('mobileDashboard.shortcutInvites', 'Team / helper invites'),
color: ADMIN_ACTION_COLORS.invites,
action: onInvites,
},
{
icon: Settings,
label: t('mobileDashboard.shortcutSettings', 'Event settings'),
color: ADMIN_ACTION_COLORS.success,
action: onSettings,
},
{
icon: Sparkles,
label: t('mobileDashboard.shortcutBranding', 'Branding & moderation'),
color: ADMIN_ACTION_COLORS.branding,
action: brandingAllowed ? onSettings : undefined,
disabled: !brandingAllowed,
}, },
]; ];
if (event && isPastEvent(event.event_date)) {
tiles.push({
icon: Sparkles,
label: t('events.quick.recap', 'Recap & Archive'),
color: ADMIN_ACTION_COLORS.recap,
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/recap`)) : undefined,
disabled: !slug,
});
}
return ( return (
<YStack space="$2" marginTop="$2"> <YStack space="$2">
<Text fontSize="$sm" fontWeight="800" color={text}> <Text fontSize="$sm" fontWeight="800" color={textStrong}>
{t('mobileDashboard.shortcutsTitle', 'Shortcuts')} {t('events.detail.managementTitle', 'Event management')}
</Text> </Text>
<XStack flexWrap="wrap" space="$2"> <XStack flexWrap="wrap" space="$2">
{tiles.map((tile, index) => ( {tiles.map((tile, index) => (
@@ -1178,22 +1295,12 @@ function SecondaryGrid({
icon={tile.icon} icon={tile.icon}
label={tile.label} label={tile.label}
color={tile.color} color={tile.color}
onPress={tile.action} onPress={tile.onPress}
disabled={tile.disabled} disabled={tile.disabled}
delayMs={index * ADMIN_MOTION.tileStaggerMs} delayMs={index * ADMIN_MOTION.tileStaggerMs}
/> />
))} ))}
</XStack> </XStack>
{event ? (
<MobileCard backgroundColor={surface} borderColor={border} space="$1.5">
<Text fontSize="$sm" fontWeight="700" color={text}>
{resolveEventDisplayName(event)}
</Text>
<Text fontSize="$xs" color={muted}>
{renderEventLocation(event)}
</Text>
</MobileCard>
) : null}
</YStack> </YStack>
); );
} }

View File

@@ -2,25 +2,24 @@ import React from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { BarChart2, TrendingUp, Users, ListTodo, Lock, Trophy, Calendar } from 'lucide-react'; import { TrendingUp, Users, ListTodo, Lock, Trophy } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks'; import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text'; import { SizableText as Text } from '@tamagui/text';
import { format, parseISO } from 'date-fns'; import { format, parseISO } from 'date-fns';
import { de, enGB } from 'date-fns/locale'; import { de, enGB } from 'date-fns/locale';
import { MobileShell } from './components/MobileShell'; import { MobileShell } from './components/MobileShell';
import { MobileCard, CTAButton, SkeletonCard } from './components/Primitives'; import { MobileCard, CTAButton, KpiTile, SkeletonCard } from './components/Primitives';
import { getEventAnalytics, EventAnalytics } from '../api'; import { getEventAnalytics, EventAnalytics } from '../api';
import { ApiError } from '../lib/apiError'; import { ApiError } from '../lib/apiError';
import { useAdminTheme } from './theme'; import { useAdminTheme } from './theme';
import { resolveMaxCount, resolveTimelineHours } from './lib/analytics';
import { adminPath } from '../constants'; import { adminPath } from '../constants';
import { useEventContext } from '../context/EventContext';
export default function MobileEventAnalyticsPage() { export default function MobileEventAnalyticsPage() {
const { slug } = useParams<{ slug: string }>(); const { slug } = useParams<{ slug: string }>();
const { t, i18n } = useTranslation('management'); const { t, i18n } = useTranslation('management');
const navigate = useNavigate(); const navigate = useNavigate();
const { activeEvent } = useEventContext();
const { textStrong, muted, border, surface, primary, accentSoft } = useAdminTheme(); const { textStrong, muted, border, surface, primary, accentSoft } = useAdminTheme();
const dateLocale = i18n.language.startsWith('de') ? de : enGB; const dateLocale = i18n.language.startsWith('de') ? de : enGB;
@@ -36,7 +35,7 @@ export default function MobileEventAnalyticsPage() {
if (isFeatureLocked) { if (isFeatureLocked) {
return ( return (
<MobileShell title={t('analytics.title', 'Analytics')} activeTab="events"> <MobileShell title={t('analytics.title', 'Analytics')} activeTab="home">
<MobileCard <MobileCard
space="$4" space="$4"
padding="$6" padding="$6"
@@ -75,7 +74,7 @@ export default function MobileEventAnalyticsPage() {
if (isLoading) { if (isLoading) {
return ( return (
<MobileShell title={t('analytics.title', 'Analytics')} activeTab="events"> <MobileShell title={t('analytics.title', 'Analytics')} activeTab="home">
<YStack space="$3"> <YStack space="$3">
<SkeletonCard height={200} /> <SkeletonCard height={200} />
<SkeletonCard height={150} /> <SkeletonCard height={150} />
@@ -87,7 +86,7 @@ export default function MobileEventAnalyticsPage() {
if (error || !data) { if (error || !data) {
return ( return (
<MobileShell title={t('analytics.title', 'Analytics')} activeTab="events"> <MobileShell title={t('analytics.title', 'Analytics')} activeTab="home">
<MobileCard borderColor={border} padding="$4"> <MobileCard borderColor={border} padding="$4">
<Text color={muted}>{t('common.error', 'Something went wrong')}</Text> <Text color={muted}>{t('common.error', 'Something went wrong')}</Text>
</MobileCard> </MobileCard>
@@ -99,18 +98,47 @@ export default function MobileEventAnalyticsPage() {
const hasTimeline = timeline.length > 0; const hasTimeline = timeline.length > 0;
const hasContributors = contributors.length > 0; const hasContributors = contributors.length > 0;
const hasTasks = tasks.length > 0; const hasTasks = tasks.length > 0;
const fallbackHours = 12;
const rawTimelineHours = resolveTimelineHours(timeline.map((point) => point.timestamp), fallbackHours);
const timeframeHours = Math.min(rawTimelineHours, fallbackHours);
const isTimeframeCapped = rawTimelineHours > fallbackHours;
// Prepare chart data // Prepare chart data
const maxCount = Math.max(...timeline.map((p) => p.count), 1); const maxTimelineCount = resolveMaxCount(timeline.map((point) => point.count));
const maxTaskCount = resolveMaxCount(tasks.map((task) => task.count));
const totalUploads = timeline.reduce((total, point) => total + point.count, 0);
const totalLikes = contributors.reduce((total, contributor) => total + contributor.likes, 0);
const totalContributors = contributors.length;
return ( return (
<MobileShell <MobileShell
title={t('analytics.title', 'Analytics')} title={t('analytics.title', 'Analytics')}
subtitle={activeEvent?.name as string} activeTab="home"
activeTab="events" onBack={() => navigate(-1)}
showBack
> >
<YStack space="$4"> <YStack space="$4">
<YStack space="$2">
<Text fontSize="$sm" fontWeight="800" color={textStrong}>
{t('analytics.kpiTitle', 'Event snapshot')}
</Text>
<XStack space="$2" flexWrap="wrap">
<KpiTile
icon={TrendingUp}
label={t('analytics.kpiUploads', 'Uploads')}
value={totalUploads}
/>
<KpiTile
icon={Users}
label={t('analytics.kpiContributors', 'Contributors')}
value={totalContributors}
/>
<KpiTile
icon={Trophy}
label={t('analytics.kpiLikes', 'Likes')}
value={totalLikes}
/>
</XStack>
</YStack>
{/* Activity Timeline */} {/* Activity Timeline */}
<MobileCard space="$3" borderColor={border} backgroundColor={surface}> <MobileCard space="$3" borderColor={border} backgroundColor={surface}>
<XStack alignItems="center" space="$2"> <XStack alignItems="center" space="$2">
@@ -119,12 +147,22 @@ export default function MobileEventAnalyticsPage() {
{t('analytics.activityTitle', 'Activity Timeline')} {t('analytics.activityTitle', 'Activity Timeline')}
</Text> </Text>
</XStack> </XStack>
<YStack space="$0.5">
<Text fontSize="$xs" color={muted}>
{t('analytics.timeframe', 'Last {{hours}} hours', { hours: timeframeHours })}
</Text>
{isTimeframeCapped ? (
<Text fontSize="$xs" color={muted}>
{t('analytics.timeframeHint', 'Older activity hidden')}
</Text>
) : null}
</YStack>
{hasTimeline ? ( {hasTimeline ? (
<YStack height={180} justifyContent="flex-end" space="$2"> <YStack height={180} justifyContent="flex-end" space="$2">
<XStack alignItems="flex-end" justifyContent="space-between" height={150} gap="$1"> <XStack alignItems="flex-end" justifyContent="space-between" height={150} gap="$1">
{timeline.map((point, index) => { {timeline.map((point, index) => {
const heightPercent = (point.count / maxCount) * 100; const heightPercent = (point.count / maxTimelineCount) * 100;
const date = parseISO(point.timestamp); const date = parseISO(point.timestamp);
// Show label every 3rd point or if few points // Show label every 3rd point or if few points
const showLabel = timeline.length < 8 || index % 3 === 0; const showLabel = timeline.length < 8 || index % 3 === 0;
@@ -141,7 +179,7 @@ export default function MobileEventAnalyticsPage() {
/> />
{showLabel && ( {showLabel && (
<Text fontSize={10} color={muted} numberOfLines={1}> <Text fontSize={10} color={muted} numberOfLines={1}>
{format(date, 'HH:mm')} {format(date, 'HH:mm', { locale: dateLocale })}
</Text> </Text>
)} )}
</YStack> </YStack>
@@ -153,7 +191,11 @@ export default function MobileEventAnalyticsPage() {
</Text> </Text>
</YStack> </YStack>
) : ( ) : (
<EmptyState message={t('analytics.noActivity', 'No uploads yet')} /> <EmptyState
message={t('analytics.noActivity', 'No uploads yet')}
actionLabel={t('analytics.emptyActionShareQr', 'Share your QR code')}
onAction={() => slug && navigate(adminPath(`/mobile/events/${slug}/qr`))}
/>
)} )}
</MobileCard> </MobileCard>
@@ -199,7 +241,11 @@ export default function MobileEventAnalyticsPage() {
))} ))}
</YStack> </YStack>
) : ( ) : (
<EmptyState message={t('analytics.noContributors', 'No contributors yet')} /> <EmptyState
message={t('analytics.noContributors', 'No contributors yet')}
actionLabel={t('analytics.emptyActionInvite', 'Invite guests')}
onAction={() => slug && navigate(adminPath(`/mobile/events/${slug}/members`))}
/>
)} )}
</MobileCard> </MobileCard>
@@ -215,7 +261,6 @@ export default function MobileEventAnalyticsPage() {
{hasTasks ? ( {hasTasks ? (
<YStack space="$3"> <YStack space="$3">
{tasks.map((task) => { {tasks.map((task) => {
const maxTaskCount = Math.max(...tasks.map(t => t.count), 1);
const percent = (task.count / maxTaskCount) * 100; const percent = (task.count / maxTaskCount) * 100;
return ( return (
<YStack key={task.task_id} space="$1"> <YStack key={task.task_id} space="$1">
@@ -240,7 +285,11 @@ export default function MobileEventAnalyticsPage() {
})} })}
</YStack> </YStack>
) : ( ) : (
<EmptyState message={t('analytics.noTasks', 'No task activity yet')} /> <EmptyState
message={t('analytics.noTasks', 'No task activity yet')}
actionLabel={t('analytics.emptyActionOpenTasks', 'Open tasks')}
onAction={() => slug && navigate(adminPath(`/mobile/events/${slug}/tasks`))}
/>
)} )}
</MobileCard> </MobileCard>
</YStack> </YStack>
@@ -248,13 +297,24 @@ export default function MobileEventAnalyticsPage() {
); );
} }
function EmptyState({ message }: { message: string }) { function EmptyState({
message,
actionLabel,
onAction,
}: {
message: string;
actionLabel?: string;
onAction?: () => void;
}) {
const { muted } = useAdminTheme(); const { muted } = useAdminTheme();
return ( return (
<YStack padding="$4" alignItems="center" justifyContent="center"> <YStack padding="$4" alignItems="center" justifyContent="center" space="$2">
<Text fontSize="$sm" color={muted}> <Text fontSize="$sm" color={muted}>
{message} {message}
</Text> </Text>
{actionLabel && onAction ? (
<CTAButton label={actionLabel} tone="ghost" fullWidth={false} onPress={onAction} />
) : null}
</YStack> </YStack>
); );
} }

View File

@@ -1,343 +0,0 @@
import React from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { CalendarDays, MapPin, Settings, Users, Camera, Sparkles, QrCode, Image, Shield, Layout, RefreshCcw, Pencil, Megaphone, Tv } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite';
import { MobileShell, HeaderActionButton } from './components/MobileShell';
import { MobileCard, PillBadge, KpiTile, ActionTile } from './components/Primitives';
import { TenantEvent, EventStats, EventToolkit, getEvent, getEventStats, getEventToolkit, getEvents } from '../api';
import { adminPath, ADMIN_EVENT_BRANDING_PATH, ADMIN_EVENT_GUEST_NOTIFICATIONS_PATH, ADMIN_EVENT_INVITES_PATH, ADMIN_EVENT_LIVE_SHOW_PATH, ADMIN_EVENT_LIVE_SHOW_SETTINGS_PATH, ADMIN_EVENT_MEMBERS_PATH, ADMIN_EVENT_PHOTOS_PATH, ADMIN_EVENT_TASKS_PATH } from '../constants';
import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage } from '../lib/apiError';
import { MobileSheet } from './components/Sheet';
import { useEventContext } from '../context/EventContext';
import { formatEventDate, isBrandingAllowed, resolveEngagementMode, resolveEventDisplayName } from '../lib/events';
import { isPastEvent } from './eventDate';
import { useBackNavigation } from './hooks/useBackNavigation';
import { ADMIN_ACTION_COLORS, ADMIN_MOTION, useAdminTheme } from './theme';
export default function MobileEventDetailPage() {
const { slug: slugParam } = useParams<{ slug?: string }>();
const slug = slugParam ?? null;
const navigate = useNavigate();
const { t } = useTranslation('management');
const [event, setEvent] = React.useState<TenantEvent | null>(null);
const [stats, setStats] = React.useState<EventStats | null>(null);
const [toolkit, setToolkit] = React.useState<EventToolkit | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const { events, activeEvent, selectEvent } = useEventContext();
const [showEventPicker, setShowEventPicker] = React.useState(false);
const back = useBackNavigation(adminPath('/mobile/events'));
const { textStrong, text, muted, danger, accentSoft } = useAdminTheme();
React.useEffect(() => {
if (!slug) return;
selectEvent(slug);
}, [slug, selectEvent]);
React.useEffect(() => {
if (!slug) return;
(async () => {
setLoading(true);
try {
const [eventData, statsData, toolkitData] = await Promise.all([getEvent(slug), getEventStats(slug), getEventToolkit(slug)]);
setEvent(eventData);
setStats(statsData);
setToolkit(toolkitData);
setError(null);
} catch (err) {
if (!isAuthError(err)) {
try {
const list = await getEvents({ force: true });
const fallback = list.find((ev: TenantEvent) => ev.slug === slug) ?? null;
if (fallback) {
setEvent(fallback);
setError(null);
} else {
setError(getApiErrorMessage(err, t('events.errors.loadFailed', 'Event konnte nicht geladen werden.')));
}
} catch (fallbackErr) {
setError(getApiErrorMessage(fallbackErr, t('events.errors.loadFailed', 'Event konnte nicht geladen werden.')));
}
}
} finally {
setLoading(false);
}
})();
}, [slug, t]);
const tasksEnabled = resolveEngagementMode(event ?? activeEvent ?? null) !== 'photo_only';
const brandingAllowed = isBrandingAllowed(event ?? activeEvent ?? null);
const kpis = [
{
label: t('events.detail.kpi.guests', 'Guests Registered'),
value: toolkit?.invites?.summary.total ?? event?.active_invites_count ?? '—',
icon: Users,
},
{
label: t('events.detail.kpi.photos', 'Images Uploaded'),
value: stats?.uploads_total ?? event?.photo_count ?? '—',
icon: Camera,
},
];
if (tasksEnabled) {
kpis.unshift({
label: t('events.detail.kpi.tasks', 'Active Tasks'),
value: event?.tasks_count ?? toolkit?.tasks?.summary?.total ?? '—',
icon: Sparkles,
});
}
return (
<MobileShell
activeTab="home"
title={resolveEventDisplayName(event ?? activeEvent ?? undefined)}
subtitle={
event?.event_date || activeEvent?.event_date
? formatDate(event?.event_date ?? activeEvent?.event_date, t)
: undefined
}
onBack={back}
headerActions={
<XStack space="$3" alignItems="center">
<HeaderActionButton onPress={() => navigate(adminPath('/mobile/settings'))} ariaLabel={t('mobileSettings.title', 'Settings')}>
<Settings size={18} color={textStrong} />
</HeaderActionButton>
<HeaderActionButton onPress={() => navigate(0)} ariaLabel={t('common.refresh', 'Refresh')}>
<RefreshCcw size={18} color={textStrong} />
</HeaderActionButton>
</XStack>
}
>
{error ? (
<MobileCard>
<Text fontWeight="700" color={danger}>
{error}
</Text>
</MobileCard>
) : null}
<MobileCard space="$3">
<Text fontSize="$lg" fontWeight="800" color={textStrong}>
{event ? renderName(event.name, t) : t('events.placeholders.untitled', 'Unbenanntes Event')}
</Text>
<XStack alignItems="center" space="$2">
<CalendarDays size={16} color={muted} />
<Text fontSize="$sm" color={muted}>
{formatDate(event?.event_date, t)}
</Text>
<MapPin size={16} color={muted} />
<Text fontSize="$sm" color={muted}>
{resolveLocation(event, t)}
</Text>
</XStack>
<PillBadge tone={event?.status === 'published' ? 'success' : 'warning'}>
{event?.status === 'published' ? t('events.status.published', 'Live') : t('events.status.draft', 'Draft')}
</PillBadge>
<Pressable
aria-label={t('mobileEvents.edit', 'Edit event')}
onPress={() => slug && navigate(adminPath(`/mobile/events/${slug}/edit`))}
style={{
position: 'absolute',
right: 16,
top: 16,
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: accentSoft,
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 6px 16px rgba(0,0,0,0.12)',
}}
>
<Pencil size={18} color={textStrong} />
</Pressable>
</MobileCard>
<YStack space="$2">
{loading ? (
<XStack space="$2" flexWrap="wrap">
{Array.from({ length: 3 }).map((_, idx) => (
<MobileCard key={`kpi-${idx}`} height={90} width="32%" />
))}
</XStack>
) : (
<XStack space="$2" flexWrap="wrap">
{kpis.map((kpi) => (
<KpiTile key={kpi.label} icon={kpi.icon} label={kpi.label} value={kpi.value} />
))}
</XStack>
)}
</YStack>
<MobileSheet
open={showEventPicker}
onClose={() => setShowEventPicker(false)}
title={t('events.detail.pickEvent', 'Event wählen')}
footer={null}
bottomOffsetPx={120}
>
<YStack space="$2">
{events.length === 0 ? (
<Text fontSize={12.5} color={muted}>
{t('events.list.empty.description', 'Starte jetzt mit deinem ersten Event.')}
</Text>
) : (
events.map((ev) => (
<Pressable
key={ev.slug}
onPress={() => {
selectEvent(ev.slug ?? null);
setShowEventPicker(false);
navigate(adminPath(`/mobile/events/${ev.slug}`));
}}
>
<XStack alignItems="center" justifyContent="space-between" paddingVertical="$2">
<YStack space="$1">
<Text fontSize={13} fontWeight="700" color={textStrong}>
{renderName(ev.name, t)}
</Text>
<XStack alignItems="center" space="$1.5">
<CalendarDays size={14} color={muted} />
<Text fontSize={12} color={muted}>
{formatDate(ev.event_date, t)}
</Text>
</XStack>
</YStack>
<PillBadge tone={ev.slug === activeEvent?.slug ? 'success' : 'muted'}>
{ev.slug === activeEvent?.slug ? t('events.detail.active', 'Aktiv') : t('events.actions.open', 'Öffnen')}
</PillBadge>
</XStack>
</Pressable>
))
)}
</YStack>
</MobileSheet>
<YStack space="$2">
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('events.detail.managementTitle', 'Event Management')}
</Text>
<XStack flexWrap="wrap" space="$2">
<ActionTile
icon={Sparkles}
label={
tasksEnabled
? t('events.quick.tasks', 'Tasks & Checklists')
: `${t('events.quick.tasks', 'Tasks & Checklists')} (${t('common:states.disabled', 'Disabled')})`
}
color={ADMIN_ACTION_COLORS.tasks}
onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/tasks`))}
delayMs={0}
/>
<ActionTile
icon={QrCode}
label={t('events.quick.qr', 'QR Code Layouts')}
color={ADMIN_ACTION_COLORS.qr}
onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/qr`))}
delayMs={ADMIN_MOTION.tileStaggerMs}
/>
<ActionTile
icon={Image}
label={t('events.quick.images', 'Image Management')}
color={ADMIN_ACTION_COLORS.images}
onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/photos`))}
delayMs={ADMIN_MOTION.tileStaggerMs * 2}
/>
<ActionTile
icon={Tv}
label={t('events.quick.liveShow', 'Live Show queue')}
color={ADMIN_ACTION_COLORS.images}
onPress={() => slug && navigate(ADMIN_EVENT_LIVE_SHOW_PATH(slug))}
disabled={!slug}
delayMs={ADMIN_MOTION.tileStaggerMs * 3}
/>
<ActionTile
icon={Settings}
label={t('events.quick.liveShowSettings', 'Live Show settings')}
color={ADMIN_ACTION_COLORS.images}
onPress={() => slug && navigate(ADMIN_EVENT_LIVE_SHOW_SETTINGS_PATH(slug))}
disabled={!slug}
delayMs={ADMIN_MOTION.tileStaggerMs * 4}
/>
<ActionTile
icon={Users}
label={t('events.quick.guests', 'Guest Management')}
color={ADMIN_ACTION_COLORS.guests}
onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/members`))}
delayMs={ADMIN_MOTION.tileStaggerMs * 5}
/>
<ActionTile
icon={Megaphone}
label={t('events.quick.guestMessages', 'Guest messages')}
color={ADMIN_ACTION_COLORS.guestMessages}
onPress={() => slug && navigate(ADMIN_EVENT_GUEST_NOTIFICATIONS_PATH(slug))}
disabled={!slug}
delayMs={ADMIN_MOTION.tileStaggerMs * 6}
/>
<ActionTile
icon={Layout}
label={t('events.quick.branding', 'Branding & Theme')}
color={ADMIN_ACTION_COLORS.branding}
onPress={
brandingAllowed ? () => navigate(adminPath(`/mobile/events/${slug ?? ''}/branding`)) : undefined
}
disabled={!brandingAllowed}
delayMs={ADMIN_MOTION.tileStaggerMs * 7}
/>
<ActionTile
icon={Camera}
label={t('events.quick.photobooth', 'Photobooth')}
color={ADMIN_ACTION_COLORS.photobooth}
onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/photobooth`))}
delayMs={ADMIN_MOTION.tileStaggerMs * 8}
/>
{isPastEvent(event?.event_date) ? (
<ActionTile
icon={Sparkles}
label={t('events.quick.recap', 'Recap & Archive')}
color={ADMIN_ACTION_COLORS.recap}
onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/recap`))}
delayMs={ADMIN_MOTION.tileStaggerMs * 9}
/>
) : null}
</XStack>
</YStack>
</MobileShell>
);
}
function renderName(name: TenantEvent['name'], t: (key: string, fallback: string) => string): string {
const fallback = t('events.placeholders.untitled', 'Untitled event');
if (typeof name === 'string' && name.trim()) return name;
if (name && typeof name === 'object') {
return name.de ?? name.en ?? Object.values(name)[0] ?? fallback;
}
return fallback;
}
function formatDate(iso: string | null | undefined, t: (key: string, fallback: string) => string): string {
if (!iso) return t('events.detail.dateTbd', 'Date tbd');
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return t('events.detail.dateTbd', 'Date tbd');
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
}
function resolveLocation(event: TenantEvent | null, t: (key: string, fallback: string) => string): string {
if (!event) return t('events.detail.locationPlaceholder', 'Location');
const settings = (event.settings ?? {}) as Record<string, unknown>;
const candidate =
(settings.location as string | undefined) ??
(settings.address as string | undefined) ??
(settings.city as string | undefined);
if (candidate && candidate.trim()) {
return candidate;
}
return t('events.detail.locationPlaceholder', 'Location');
}

Some files were not shown because too many files have changed in this diff Show More