Compare commits

...

111 Commits

Author SHA1 Message Date
Codex Agent
7030e8b5b9 Add superadmin task collections resource
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-19 21:19:37 +01:00
Codex Agent
b61507ea04 Hochzeitsaufgaben auf 44 reduziert und Spezialthemenpakete vorbereitet. 2026-01-19 19:45:48 +01:00
Codex Agent
dfaf21898a chore: sync bd issues
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-19 18:50:51 +01:00
Codex Agent
fbd48afbd6 feat: add task multi-select on long-press
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-19 18:49:40 +01:00
Codex Agent
6f6d8901ec Route /api requests to Laravel in nginx
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-19 14:27:31 +01:00
Codex Agent
d4ab9a3a20 Adjust watermark permissions and transparency
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-19 13:45:43 +01:00
Codex Agent
fbff2afa3e Update admin PWA events, branding, and packages
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-19 11:35:38 +01:00
Codex Agent
926bc7d070 feat(admin-pwa): add floating action button to event form
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-18 11:33:09 +01:00
Codex Agent
f1f552ad2d fix(admin-pwa): fix location saving and dashboard refresh delay
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-18 11:14:42 +01:00
Codex Agent
4219daba25 feat(admin-pwa): modernize dashboard KPI section with unified glass strip
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-18 11:02:04 +01:00
Codex Agent
1e821a2fb4 refactor(dashboard): refine setup checklist UI
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
- Removed progress bar from hero for cleaner look
- Made setup checklist collapsible (auto-collapsed when complete)
- Improved checklist item styling with active/inactive states
2026-01-18 10:08:39 +01:00
Codex Agent
48d4716ab1 feat(dashboard): implement transparent setup roadmap and fix translations
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
- Added SetupChecklist component for clear progress visualization
- Refactored LifecycleHero to show readiness state
- Fixed remaining untranslated keys in tool grid and readiness hook
2026-01-18 10:02:59 +01:00
Codex Agent
45f0cea264 feat(mobile): implement event switcher sheet in header
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
- Replaced direct navigation with a bottom sheet for event switching
- Created reusable EventSwitcherSheet component
- Preserves context when switching events
2026-01-17 19:17:19 +01:00
Codex Agent
9d7990fe71 fix(dashboard): correct translation keys for tasks, settings, analytics
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
- Updated grid item labels to use valid i18next keys
- Ensured consistent German localization for all dashboard widgets
2026-01-17 18:29:01 +01:00
Codex Agent
0c5939e541 fix(dashboard): resolve missing translations and refine alert styling
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
- Updated useEventReadiness hook to use 'Bearbeiten' instead of untranslated string
- Fixed 'guestsBlocked' literal appearing in alerts by passing translator correctly
- Refined limit warning styles to respect danger tone
- Localized pulse strip labels (Fotos, Gäste) properly
2026-01-17 18:06:14 +01:00
Codex Agent
e7e095cec9 fix(theme): correct text color mapping for light/dark modes
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
- Updated useAdminTheme to derive muted/subtle colors from theme.color using alpha
- Fixed issue where muted text was invisible in light mode
- Updated global gradients to match new Slate palette
2026-01-17 16:39:22 +01:00
Codex Agent
d905ba8e6c fix(admin): refine dashboard translations and label mapping
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
- Mapped 'Photobooth' and 'Guests' grid items to correct translation keys
- Localized pulse strip labels (Fotos, Gäste)
- Updated readiness hook to use translated CTAs
2026-01-17 16:35:30 +01:00
Codex Agent
40bed1e44e feat(admin): modernize tenant admin PWA with cockpit layout and slate theme
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
- Replaced rainbow grid with phase-aware cockpit layout
- Implemented smart lifecycle hero with readiness logic
- Introduced dark command bar header with context pill and search placeholder
- Updated global Tamagui theme to slate/indigo palette
- Refined bottom navigation with minimalist spotlight style
2026-01-17 14:46:19 +01:00
Codex Agent
7e77dd2931 Refresh mobile dashboard and header
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-16 22:06:41 +01:00
Codex Agent
b316beb522 Allow partial event updates
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-16 15:12:03 +01:00
Codex Agent
6d3f4f36e8 Update tasks toggle copy 2026-01-16 15:06:48 +01:00
Codex Agent
9e4ea3dafb Add tasks toggle card 2026-01-16 14:58:24 +01:00
Codex Agent
1517eb8631 Add tasks setup nudge and prompt
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-16 14:41:09 +01:00
Codex Agent
9a4ece33bf Refresh event list after create
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-16 14:25:52 +01:00
Codex Agent
30c653913d Show endcustomer event allowance on dashboard
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-16 14:17:27 +01:00
Codex Agent
4c37f874bd Preserve null remaining_events in package normalization
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-16 14:11:44 +01:00
Codex Agent
05fdda811b Avoid billing redirect for endcustomer packages
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-16 14:05:20 +01:00
Codex Agent
eeeca0eed5 Show event-per-purchase for endcustomer packages
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-16 14:00:12 +01:00
Codex Agent
fa6a5678f0 Set starter event quota in package seeder
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-16 13:56:03 +01:00
Codex Agent
63956087a4 Fix demo starter package seeding
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-16 13:52:32 +01:00
Codex Agent
a3f153de6f Allow dashboard access with active package
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-16 13:44:58 +01:00
Codex Agent
8d729c6a86 Fix dashboard empty state permissions
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-16 13:39:27 +01:00
Codex Agent
7ad43a3661 Sync bd issues
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-16 13:36:29 +01:00
Codex Agent
7aa0a4c847 Enforce tenant member permissions
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-16 13:33:36 +01:00
Codex Agent
df60be826d Sync beads issues
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-16 12:15:38 +01:00
Codex Agent
918bff08aa Fix auth translations and admin PWA UI
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-16 12:14:53 +01:00
Codex Agent
292c8f0b26 Refine admin PWA layout and tamagui usage
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-15 22:24:10 +01:00
Codex Agent
11018f273d chore: sync bd issues
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-15 19:54:53 +01:00
Codex Agent
7e32d8f706 feat: update package copy and admin control room 2026-01-15 19:54:04 +01:00
Codex Agent
ad829ae509 Update partner packages, copy, and demo switcher
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-15 17:33:36 +01:00
Codex Agent
2f93271d94 Route billing upgrade CTA to 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-15 10:28:18 +01:00
Codex Agent
62255dc9e7 Add missing branding watermark translations
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-15 10:21:17 +01:00
Codex Agent
738659112d Add upgrade CTAs for branding and watermarks
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-15 10:17:05 +01:00
Codex Agent
89d9b656de Add watermark tier labels to marketing translations
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-15 09:58:02 +01:00
Codex Agent
5d0ae0faa5 Customize watermark labels in package comparison
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-15 09:54:47 +01:00
Codex Agent
2ecd417b55 Enable watermarks for premium package
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-15 09:48:44 +01:00
Codex Agent
3755213010 Align demo seed branding defaults
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-15 09:38:24 +01:00
Codex Agent
9cb236f123 Update default branding palette for tenants and guests
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-15 09:32:51 +01:00
Codex Agent
10232cf40e Adjust default branding accent color
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-15 09:23:34 +01:00
Codex Agent
3ce6507268 Collapse branding controls on default mode
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-15 09:17:06 +01:00
Codex Agent
a39295a0f0 Fix branding translations in locale overrides
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-15 09:06:49 +01:00
Codex Agent
5dc69fb187 Adopt Tamagui sliders in admin
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-15 09:01:43 +01:00
Codex Agent
92b341bdcd Use Tamagui slider for branding controls
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:58:09 +01:00
Codex Agent
725a7a29b3 Refine branding labels and access checks
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:51:06 +01:00
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
289 changed files with 42319 additions and 33685 deletions

6
.beads/.gitignore vendored
View File

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

View File

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

View File

@@ -10,6 +10,7 @@
{"id":"fotospiel-app-25q","title":"Security review: payments/webhooks code audit (signatures, idempotency, linkage)","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:05:25.747336642+01:00","created_by":"soeren","updated_at":"2026-01-01T16:05:25.747336642+01:00"}
{"id":"fotospiel-app-29o","title":"Paddle catalog sync: PackageResource sync status badges + timestamp","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:01:10.009385187+01:00","created_by":"soeren","updated_at":"2026-01-01T16:01:15.639525807+01:00","closed_at":"2026-01-01T16:01:15.639525807+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-29r","title":"Photobooth uploader: add watch-folder upload pipeline + persist creds","status":"closed","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-12T16:51:27.198056063+01:00","created_by":"Codex Agent","updated_at":"2026-01-12T17:07:04.06719869+01:00","closed_at":"2026-01-12T17:07:04.06719869+01:00","close_reason":"Closed"}
{"id":"fotospiel-app-2b5","title":"Uploader: connect code expiry countdown","description":"Part of epic fotospiel-app-5aa. Show time-to-expiry for the active connect code in the client.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:04:05.74962406+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:04:05.74962406+01:00"}
{"id":"fotospiel-app-2hq","title":"Security review: marketing/API controller+validation review","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:05:08.862737923+01:00","created_by":"soeren","updated_at":"2026-01-01T16:05:08.862737923+01:00"}
{"id":"fotospiel-app-2yn","title":"Event-Admin: Reset link routing + notifications + tests","description":"Point password reset emails to event-admin reset page; add rate limiting and tests for the new flow.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T10:45:09.279245468+01:00","created_by":"soeren","updated_at":"2026-01-06T11:01:49.083154811+01:00","closed_at":"2026-01-06T11:01:49.083154811+01:00","close_reason":"Closed"}
{"id":"fotospiel-app-33m","title":"Security review checklist: Guest PWA dynamic tests","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:04:40.730459361+01:00","created_by":"soeren","updated_at":"2026-01-01T16:04:40.730459361+01:00"}
@@ -20,6 +21,7 @@
{"id":"fotospiel-app-4en","title":"Add translations for Mobile Package Shop","description":"The new MobilePackageShopPage.tsx uses translation keys like 'shop.title', 'shop.legal.agb', etc. Ensure these are added to the management.json files for de and en.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T18:05:50.469751088+01:00","created_by":"soeren","updated_at":"2026-01-06T18:14:19.984343737+01:00","closed_at":"2026-01-06T18:14:19.984346372+01:00"}
{"id":"fotospiel-app-4i4","title":"Security review: map roles/data","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:02:58.370301875+01:00","created_by":"soeren","updated_at":"2026-01-01T16:03:03.997327414+01:00","closed_at":"2026-01-01T16:03:03.997327414+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-4zu","title":"SEC-IO-02 Refresh-token management UI + audit logs","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:51:50.24186222+01:00","created_by":"soeren","updated_at":"2026-01-04T16:10:39.752587431+01:00","closed_at":"2026-01-04T16:10:39.752587431+01:00","close_reason":"Obsolete: authentication now uses Sanctum PATs; OAuth/refresh-token tables removed and no refresh-token flow remains. See docs/archive/prp/13-backend-authentication.md and docs/archive/prp/marketing-checkout-payment-architecture.md."}
{"id":"fotospiel-app-4zy","title":"Refine Dashboard Translations","description":"Fix missing translations in the modern dashboard UI and use proper i18n keys for stats and status labels.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-17T16:35:14.464529363+01:00","created_by":"Codex Agent","updated_at":"2026-01-17T16:35:14.464529363+01:00"}
{"id":"fotospiel-app-539","title":"Live Show: public player view with effects engine","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-01-05T11:11:36.821959901+01:00","created_by":"soeren","updated_at":"2026-01-05T18:30:13.318396255+01:00","closed_at":"2026-01-05T18:30:13.318396255+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-539","depends_on_id":"fotospiel-app-qne","type":"blocks","created_at":"2026-01-05T11:12:58.721858159+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-539","depends_on_id":"fotospiel-app-6zc","type":"blocks","created_at":"2026-01-05T11:13:07.289796993+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-539","depends_on_id":"fotospiel-app-h5d","type":"blocks","created_at":"2026-01-05T11:44:42.719445471+01:00","created_by":"soeren"}]}
{"id":"fotospiel-app-539.2","title":"Live Show player shell + routing + data layer","description":"Add /show/{token} route + guest player page shell, Live Show API client, SSE/polling subscription and state model.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-05T15:57:41.587003393+01:00","created_by":"soeren","updated_at":"2026-01-05T16:44:39.577762479+01:00","closed_at":"2026-01-05T16:44:39.577762479+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-539.2","depends_on_id":"fotospiel-app-539","type":"parent-child","created_at":"2026-01-05T15:57:41.641767879+01:00","created_by":"soeren"}]}
{"id":"fotospiel-app-539.3","title":"Live Show playback engine (queue, pacing, layouts)","description":"Implement player playback scheduler, queue management, and layout rendering for single/split/grid.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-05T15:57:56.531080931+01:00","created_by":"soeren","updated_at":"2026-01-05T17:40:45.929168571+01:00","closed_at":"2026-01-05T17:40:45.929168571+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-539.3","depends_on_id":"fotospiel-app-539","type":"parent-child","created_at":"2026-01-05T15:57:56.631147026+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-539.3","depends_on_id":"fotospiel-app-539.2","type":"blocks","created_at":"2026-01-05T15:57:56.655278463+01:00","created_by":"soeren"}]}
@@ -29,6 +31,7 @@
{"id":"fotospiel-app-574","title":"Paddle catalog sync: extend PaddleClient tests/mocks for catalog endpoints","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:59:03.486301225+01:00","created_by":"soeren","updated_at":"2026-01-02T21:11:39.626820206+01:00","closed_at":"2026-01-02T21:11:39.626820206+01:00","close_reason":"Deprioritized"}
{"id":"fotospiel-app-576","title":"Tenant admin onboarding: legacy asset audit + component inventory","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:07:59.996563146+01:00","created_by":"soeren","updated_at":"2026-01-01T16:08:05.599274641+01:00","closed_at":"2026-01-01T16:08:05.599274641+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-579","title":"Live Show: tests (backend + UI smoke)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-05T11:11:57.246607374+01:00","created_by":"soeren","updated_at":"2026-01-05T19:37:35.590123482+01:00","closed_at":"2026-01-05T19:37:35.590123482+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-579","depends_on_id":"fotospiel-app-539","type":"blocks","created_at":"2026-01-05T11:13:27.729131522+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-579","depends_on_id":"fotospiel-app-xg5","type":"blocks","created_at":"2026-01-05T11:13:37.425191011+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-579","depends_on_id":"fotospiel-app-qne","type":"blocks","created_at":"2026-01-05T11:13:46.257175231+01:00","created_by":"soeren"}]}
{"id":"fotospiel-app-5aa","title":"Photobooth uploader: reliability + UX upgrades","status":"open","priority":2,"issue_type":"epic","owner":"codex-agent@example.com","created_at":"2026-01-13T11:01:29.745168595+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:01:29.745168595+01:00"}
{"id":"fotospiel-app-5dl","title":"Paddle catalog sync: PaddleCatalogService scaffold","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:00:24.916655836+01:00","created_by":"soeren","updated_at":"2026-01-01T16:00:30.566084195+01:00","closed_at":"2026-01-01T16:00:30.566084195+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-5hk","title":"Fix staging coupon seed 500 for E2E","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-03T15:12:53.643644221+01:00","created_by":"soeren","updated_at":"2026-01-04T16:21:46.441797374+01:00","closed_at":"2026-01-04T16:21:46.441797374+01:00","close_reason":"Resolved elsewhere; staging coupon seed 500 no longer reproducible after recent backend changes."}
{"id":"fotospiel-app-5ie","title":"Help docs: Live Show how-to + recommended hardware (DE/EN)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-05T11:12:05.973844187+01:00","created_by":"soeren","updated_at":"2026-01-05T19:42:44.39939087+01:00","closed_at":"2026-01-05T19:42:44.39939087+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-5ie","depends_on_id":"fotospiel-app-vro","type":"blocks","created_at":"2026-01-05T11:13:54.925412888+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-5ie","depends_on_id":"fotospiel-app-539","type":"blocks","created_at":"2026-01-05T11:14:03.257649076+01:00","created_by":"soeren"}]}
@@ -39,10 +42,15 @@
{"id":"fotospiel-app-6dp","title":"Coupon ops enhancements (redemption service, preview endpoint, widget, export)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:09:09.275919717+01:00","created_by":"soeren","updated_at":"2026-01-01T16:09:14.882264149+01:00","closed_at":"2026-01-01T16:09:14.882264149+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-6oj","title":"Security review: media pipeline code audit (AV/EXIF, signed URLs, storage separation)","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:05:31.390878341+01:00","created_by":"soeren","updated_at":"2026-01-01T16:05:31.390878341+01:00"}
{"id":"fotospiel-app-6yt","title":"Paddle migration: register sandbox webhooks + document events consumed","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:56:34.333714988+01:00","created_by":"soeren","updated_at":"2026-01-02T22:23:52.212191068+01:00","closed_at":"2026-01-02T22:23:52.212191068+01:00","close_reason":"Completed"}
{"id":"fotospiel-app-6yz","title":"Uploader: activity log export","description":"Part of epic fotospiel-app-5aa. Add in-app log view and export/copy diagnostics for support.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:04:27.73767403+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:04:27.73767403+01:00"}
{"id":"fotospiel-app-6zc","title":"Live Show: Admin app settings \u0026 effect presets","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-01-05T11:11:27.038815978+01:00","created_by":"soeren","updated_at":"2026-01-05T15:02:42.035082497+01:00","closed_at":"2026-01-05T15:02:42.035082497+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-6zc","depends_on_id":"fotospiel-app-vro","type":"blocks","created_at":"2026-01-05T11:12:50.048055484+01:00","created_by":"soeren"}]}
{"id":"fotospiel-app-7bu","title":"Paddle migration: extend config/env handling for Paddle keys/webhook secrets","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:57:27.242854801+01:00","created_by":"soeren","updated_at":"2026-01-01T15:57:32.890355888+01:00","closed_at":"2026-01-01T15:57:32.890355888+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-7u1","title":"Paddle catalog sync: PaddlePackagePull job","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:00:47.468892178+01:00","created_by":"soeren","updated_at":"2026-01-01T16:00:53.126602817+01:00","closed_at":"2026-01-01T16:00:53.126602817+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-7uu","title":"Uploader: improve file readiness detection","description":"Part of epic fotospiel-app-5aa. Use size + last-write stabilization to avoid partial uploads.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:01:54.142231578+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:01:54.142231578+01:00"}
{"id":"fotospiel-app-7x1","title":"Uploader: response format manual override","description":"Part of epic fotospiel-app-5aa. Allow manual response format override when connect code doesn't set it.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:03:54.824613016+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:03:54.824613016+01:00"}
{"id":"fotospiel-app-83q","title":"Implement Advanced Analytics","description":"Full plan: Phase 1 (MVP) includes Activity Timeline, Top Contributors, and Task Stats. Phase 2 includes Engagement Funnel, Vibe Check, and PDF Export. See chat history for details.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T15:40:08.826105426+01:00","created_by":"soeren","updated_at":"2026-01-06T16:15:17.722450844+01:00","closed_at":"2026-01-06T16:15:17.722455019+01:00"}
{"id":"fotospiel-app-8iw","title":"Modernize Tenant Admin PWA UI","status":"open","priority":1,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-17T14:36:39.802617182+01:00","created_by":"Codex Agent","updated_at":"2026-01-17T14:36:39.802617182+01:00"}
{"id":"fotospiel-app-8ui","title":"Uploader: persist queue across restarts","description":"Part of epic fotospiel-app-5aa. Persist pending upload queue to disk (settings or local DB) so restarts don't lose files.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:01:42.213478619+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:01:42.213478619+01:00"}
{"id":"fotospiel-app-95m","title":"Paddle migration: admin catalog sync UI for packages","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:57:49.790409261+01:00","created_by":"soeren","updated_at":"2026-01-01T15:57:55.418180246+01:00","closed_at":"2026-01-01T15:57:55.418180246+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-99d","title":"Paddle migration: marketing checkout uses Paddle-hosted checkout","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:58:12.298063897+01:00","created_by":"soeren","updated_at":"2026-01-01T15:58:17.968032021+01:00","closed_at":"2026-01-01T15:58:17.968032021+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-99o","title":"Fix German welcome phrasing with article-safe app_name","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-04T11:50:17.410390085+01:00","created_by":"soeren","updated_at":"2026-01-04T12:19:55.741616753+01:00","closed_at":"2026-01-04T12:19:55.741616753+01:00","close_reason":"Closed"}
@@ -63,9 +71,12 @@
{"id":"fotospiel-app-bqm","title":"Paddle catalog sync: unit tests for service + jobs","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:01:22.090498843+01:00","created_by":"soeren","updated_at":"2026-01-01T16:01:27.71412122+01:00","closed_at":"2026-01-01T16:01:27.71412122+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-bxu","title":"Checkout refactor: Stripe/Paddle payment integration + webhooks","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:06:32.279485614+01:00","created_by":"soeren","updated_at":"2026-01-01T16:06:37.876950599+01:00","closed_at":"2026-01-01T16:06:37.876950599+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-bzb","title":"Paddle catalog sync: migration for paddle sync columns","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:00:02.362257158+01:00","created_by":"soeren","updated_at":"2026-01-01T16:00:08.018770606+01:00","closed_at":"2026-01-01T16:00:08.018770606+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-cht","title":"Uploader: disk space low warning","description":"Part of epic fotospiel-app-5aa. Highlight low disk space thresholds in UI.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:03:32.710631234+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:03:32.710631234+01:00"}
{"id":"fotospiel-app-ci5","title":"Paddle catalog sync: configure log channel/Slack hook for sync outcomes","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:59:20.543083527+01:00","created_by":"soeren","updated_at":"2026-01-02T22:02:15.857149244+01:00","closed_at":"2026-01-02T22:02:15.857149244+01:00","close_reason":"Completed"}
{"id":"fotospiel-app-cwq","title":"Integrations health: unified Paddle/RevenueCat/webhook status dashboard","description":"Add a superadmin integrations health dashboard for Paddle/RevenueCat/webhooks.\nScope: show latest webhook processing status/lag, recent failures, retry backlog, and config presence (env set) without exposing secrets.\nInclude per-provider status badges and time-window filters, plus links to related logs/actions.\n","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-02T17:34:20.84661157+01:00","created_by":"soeren","updated_at":"2026-01-02T18:33:07.133704488+01:00","closed_at":"2026-01-02T18:33:07.133704488+01:00","close_reason":"Closed"}
{"id":"fotospiel-app-d39","title":"Superadmin control surface spec and access matrix","description":"Define the minimal superadmin control surface, permissions, and mapping to tenant/guest responsibilities. Document scope and non-goals.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T14:16:06.994379577+01:00","updated_at":"2026-01-01T14:20:43.080701114+01:00","closed_at":"2026-01-01T14:20:43.080701114+01:00"}
{"id":"fotospiel-app-dar","title":"Uploader: retry policy for failed uploads","description":"Part of epic fotospiel-app-5aa. Auto-retry with backoff and retry limit before marking failed.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:03:00.808893045+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:03:00.808893045+01:00"}
{"id":"fotospiel-app-de7","title":"Re-run admin Playwright tests with valid E2E credentials","status":"open","priority":3,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-15T19:53:26.674926731+01:00","created_by":"Codex Agent","updated_at":"2026-01-15T19:53:26.674926731+01:00"}
{"id":"fotospiel-app-dl5","title":"SEC-API-01 Signed URL middleware + asset migration","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:52:24.24098702+01:00","created_by":"soeren","updated_at":"2026-01-01T15:52:29.8793891+01:00","closed_at":"2026-01-01T15:52:29.8793891+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-dm4","title":"SEC-BILL-01 Checkout session linkage + idempotency locks","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:53:26.350238207+01:00","created_by":"soeren","updated_at":"2026-01-01T15:53:31.997737421+01:00","closed_at":"2026-01-01T15:53:31.997737421+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-dmb","title":"Security review checklist: Event Admin dynamic tests","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:04:46.359468828+01:00","created_by":"soeren","updated_at":"2026-01-01T16:04:46.359468828+01:00"}
@@ -86,6 +97,7 @@
{"id":"fotospiel-app-iyh","title":"Security review follow-ups: signed URL TTLs, guest asset throttles, CORS allowlist, logging hygiene","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:05:42.642109576+01:00","created_by":"soeren","updated_at":"2026-01-01T16:05:42.642109576+01:00"}
{"id":"fotospiel-app-jk4","title":"Checkout refactor: CheckoutController + marketing route alignment","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:06:21.088319132+01:00","created_by":"soeren","updated_at":"2026-01-01T16:06:26.663419594+01:00","closed_at":"2026-01-01T16:06:26.663419594+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-jqy","title":"Tenant admin onboarding: Playwright skeleton for welcome flow","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:08:11.226297707+01:00","created_by":"soeren","updated_at":"2026-01-01T16:08:16.827679424+01:00","closed_at":"2026-01-01T16:08:16.827679424+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-jy1","title":"Uploader: clear failed uploads UI","description":"Part of epic fotospiel-app-5aa. Add action to clear/reset failed items and counters.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:03:13.134661157+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:03:13.134661157+01:00"}
{"id":"fotospiel-app-ko0","title":"Security review checklist: Webhooks/Billing dynamic tests","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:04:51.987093237+01:00","created_by":"soeren","updated_at":"2026-01-01T16:04:51.987093237+01:00"}
{"id":"fotospiel-app-kry","title":"Paddle catalog sync: add DTO helpers for Paddle product/price responses","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:58:57.817750548+01:00","created_by":"soeren","updated_at":"2026-01-02T21:11:27.970220923+01:00","closed_at":"2026-01-02T21:11:27.970220923+01:00","close_reason":"Deprioritized"}
{"id":"fotospiel-app-kso","title":"SEC-MS-02 Streaming upload refactor + tests","description":"Current state (code scan)\n- Guest uploads: App\\\\Http\\\\Controllers\\\\Api\\\\EventPublicController@upload uses Storage::disk()-\u003eputFile (stream-friendly) but still does watermark/thumbnail work inline.\n- Tenant admin uploads: App\\\\Http\\\\Controllers\\\\Api\\\\Tenant\\\\PhotoController@store and @uploadDirect use Storage::disk()-\u003eput($path, file_get_contents(...)) which loads entire file into memory.\n- Photobooth ingest already streams from import disk via readStream -\u003e Storage::disk()-\u003eput($path, $stream).\n- Presigned upload flow is stubbed to a local upload-direct endpoint; no true presigned S3 handling yet.\n- No tenant upload feature tests exist; guest upload tests exist and cover limits/security.\n\nGoal\n- Stream uploads to disk (avoid full in-memory buffers) for tenant-admin upload endpoints and keep behavior consistent across sources.\n\nPlan\n1) Introduce a small streaming upload helper/service\n - New service (e.g. App\\\\Services\\\\Storage\\\\UploadStreamService) that accepts UploadedFile + disk + destination path.\n - Use fopen on UploadedFile::getRealPath (or $file-\u003egetStream()) and Storage::disk($disk)-\u003eput($path, $stream) / writeStream.\n - Always close stream; return stored size and checksum (hash_file on stored path) for asset metadata.\n\n2) Refactor tenant upload endpoints to use streaming\n - Update PhotoController@store and @uploadDirect to use the helper instead of file_get_contents.\n - Use Storage::disk()-\u003eputFileAs (or helper) to preserve deterministic paths without buffering.\n - Keep existing validation, watermark, thumbnail, asset recording, and package usage logic.\n\n3) Optional consistency pass on guest upload\n - Consider routing EventPublicController@upload through the same helper for consistent storage + checksum handling, while keeping current validation/limits.\n\n4) Tests\n - Add Feature tests for tenant upload endpoints:\n - /api/v1/tenant/events/{slug}/photos (store) uploads a fake image and persists Photo + EventMediaAsset with expected path/size.\n - /api/v1/tenant/events/{slug}/upload-direct (presigned) uploads a fake image and stores asset + thumbnail.\n - Ensure existing guest upload tests still pass (no behavioral changes).\n\n5) Safety/ops\n - Verify streaming logic handles empty/invalid files gracefully and still reports errors via ApiError.\n - Keep request-time processing (thumb/watermark) unchanged for now; consider queuing in a follow-up if CPU spikes persist.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:53:03.729137616+01:00","created_by":"soeren","updated_at":"2026-01-02T20:51:17.752365339+01:00","closed_at":"2026-01-02T20:51:17.752365339+01:00","close_reason":"Closed"}
@@ -93,6 +105,8 @@
{"id":"fotospiel-app-l3n","title":"Session changes 2025-09-08 (PRP split, PWA scaffolding, Filament resources, API)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:10:18.204088457+01:00","created_by":"soeren","updated_at":"2026-01-01T16:10:23.815135505+01:00","closed_at":"2026-01-01T16:10:23.815135505+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-l6a","title":"Registration flow fixes: JSON redirect, error clearing, role handling","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:07:16.253760139+01:00","created_by":"soeren","updated_at":"2026-01-01T16:07:21.964843904+01:00","closed_at":"2026-01-01T16:07:21.964843904+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-l8q","title":"SEC-GT-02 Join-token analytics dashboard (Grafana)","description":"Logging + Filament summaries exist; Grafana dashboard still missing.","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:52:12.920875329+01:00","created_by":"soeren","updated_at":"2026-01-01T15:52:12.920875329+01:00"}
{"id":"fotospiel-app-lj6","title":"Uploader: folder health enhancements","description":"Part of epic fotospiel-app-5aa. Track last file seen, write permissions, and show clearer folder status.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:03:22.843330813+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:03:22.843330813+01:00"}
{"id":"fotospiel-app-llq","title":"Uploader: lock settings after connect","description":"Part of epic fotospiel-app-5aa. Prevent accidental changes to base URL/credentials unless explicitly unlocked.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:03:43.40971185+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:03:43.40971185+01:00"}
{"id":"fotospiel-app-ln3","title":"Paddle catalog sync: announce workflow change to admin users","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:59:49.021233635+01:00","created_by":"soeren","updated_at":"2026-01-02T21:11:09.349495631+01:00","closed_at":"2026-01-02T21:11:09.349495631+01:00","close_reason":"Deprioritized"}
{"id":"fotospiel-app-lnb","title":"SEC-GT-01 Hash join tokens + data migration","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:52:01.658868778+01:00","created_by":"soeren","updated_at":"2026-01-01T15:52:07.314317124+01:00","closed_at":"2026-01-01T15:52:07.314317124+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-lnf","title":"Remove legacy registration page assets","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-06T08:37:39.419274918+01:00","created_by":"soeren","updated_at":"2026-01-06T08:37:39.419274918+01:00"}
@@ -102,6 +116,7 @@
{"id":"fotospiel-app-ml7","title":"SEC-GT-03 Tighten gallery/photo rate limits + alerting","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:52:18.593415508+01:00","created_by":"soeren","updated_at":"2026-01-01T15:52:18.593415508+01:00"}
{"id":"fotospiel-app-mol","title":"Coupon ops: wire analytics into Matomo dashboard","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:09:27.722458747+01:00","created_by":"soeren","updated_at":"2026-01-02T23:28:18.178704873+01:00","closed_at":"2026-01-02T23:28:18.178704873+01:00","close_reason":"Closed"}
{"id":"fotospiel-app-mpu","title":"Checkout refactor: test coverage + rollout notes","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:06:43.488302531+01:00","created_by":"soeren","updated_at":"2026-01-01T16:06:49.13645691+01:00","closed_at":"2026-01-01T16:06:49.13645691+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-mwi","title":"Uploader: duplicate detection / upload cache","description":"Part of epic fotospiel-app-5aa. Track uploaded files (path/hash) to avoid re-uploads after restart.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:02:06.432781468+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:02:06.432781468+01:00"}
{"id":"fotospiel-app-mx5","title":"Localized SEO: sitemap updated with locale alternates","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:02:15.177013722+01:00","created_by":"soeren","updated_at":"2026-01-01T16:02:20.812287917+01:00","closed_at":"2026-01-01T16:02:20.812287917+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-mxw","title":"Security review: configure env assumptions for dynamic testing","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:04:29.498402235+01:00","created_by":"soeren","updated_at":"2026-01-01T16:04:29.498402235+01:00"}
{"id":"fotospiel-app-n8q","title":"Paddle migration: draft production cutover procedure","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:56:51.427425262+01:00","created_by":"soeren","updated_at":"2026-01-02T22:28:41.469357437+01:00","closed_at":"2026-01-02T22:28:41.469357437+01:00","close_reason":"Completed"}
@@ -119,11 +134,16 @@
{"id":"fotospiel-app-qlj","title":"Paddle catalog sync: verify legacy packages mapped before auto-sync","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:59:43.333792314+01:00","created_by":"soeren","updated_at":"2026-01-02T21:46:52.797515024+01:00","closed_at":"2026-01-02T21:46:52.797515024+01:00","close_reason":"Completed"}
{"id":"fotospiel-app-qne","title":"Live Show: realtime delivery channel (WS/SSE) + fallback polling","acceptance_criteria":"- Public Live Show endpoints exist for state, updates, and SSE stream\\n- Updates endpoint supports cursor (after_approved_at + after_id)\\n- SSE emits photo.approved and ping, with settings updates when version changes\\n- Feature tests cover state, updates, invalid token","notes":"Added LiveShowController with public endpoints: /api/v1/live-show/{token} (state), /updates (polling), /stream (SSE). Provides live-show settings (defaults + event.settings.live_show merge), settings_version hash, ordered approved photo feed with cursor. SSE emits photo.approved, settings.updated, ping. Added routes in routes/api.php. Added Photo live_status default. Tests: tests/Feature/LiveShowRealtimeTest.php. Ran Pint + test.","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-01-05T11:11:06.028871737+01:00","created_by":"soeren","updated_at":"2026-01-05T13:08:33.936740582+01:00","closed_at":"2026-01-05T13:08:33.936740582+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-qne","depends_on_id":"fotospiel-app-t1k","type":"blocks","created_at":"2026-01-05T11:12:30.363982215+01:00","created_by":"soeren"}]}
{"id":"fotospiel-app-qtn","title":"Security review kickoff mitigations (CORS allowlist, headers, upload hardening, signed URLs)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:09:46.310873311+01:00","created_by":"soeren","updated_at":"2026-01-01T16:09:51.914359487+01:00","closed_at":"2026-01-01T16:09:51.914359487+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-rpv","title":"Uploader: connection test (no upload)","description":"Part of epic fotospiel-app-5aa. Add lightweight ping/test for upload URL + credentials.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:02:39.061938692+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:02:39.061938692+01:00"}
{"id":"fotospiel-app-sbs","title":"Compliance tools: data export + retention overrides","description":"GDPR-compliant export requests and retention override workflows for tenants/events.","status":"closed","priority":3,"issue_type":"feature","created_at":"2026-01-01T14:20:16.530289009+01:00","updated_at":"2026-01-02T20:13:31.704875591+01:00","closed_at":"2026-01-02T20:13:31.704875591+01:00","close_reason":"Closed"}
{"id":"fotospiel-app-sdg","title":"Uploader: watch include/exclude patterns","description":"Part of epic fotospiel-app-5aa. Configurable file patterns (ignore tmp/preview) for watcher.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:02:17.188267106+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:02:17.188267106+01:00"}
{"id":"fotospiel-app-sju","title":"Live Show link sharing + QR in admin","description":"Expose Live Show link in Event Admin with copy/share/open actions and embedded QR (use simplesoftwareio/simple-qrcode, no external service). Add API endpoints for link fetch/rotate, admin UI card with rotate confirmation, and tests.","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-05T20:00:25.427132538+01:00","created_by":"soeren","updated_at":"2026-01-05T20:00:25.427132538+01:00"}
{"id":"fotospiel-app-spq8","title":"Eslint fails due to existing repo violations","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-19T18:49:19.208323875+01:00","created_by":"Codex Agent","updated_at":"2026-01-19T18:49:19.208323875+01:00"}
{"id":"fotospiel-app-swb","title":"Security review: replace public asset URLs with signed routes","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:04:05.610098299+01:00","created_by":"soeren","updated_at":"2026-01-01T16:04:11.215921463+01:00","closed_at":"2026-01-01T16:04:11.215921463+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-t1k","title":"Live Show: data model \u0026 status workflow (pending/approved/ready)","acceptance_criteria":"- DB migrations add event token + photo live fields + indexes\\n- Token generation supports rotation (no expiry)\\n- Photo live workflow methods set timestamps/reviewer consistently\\n- Feature test covers token + workflow","notes":"Implemented Live Show data model: events.live_show_token + live_show_token_rotated_at; photos.live_status + timestamps/reviewer/rejection fields + indexes. Added PhotoLiveStatus enum and Photo workflow methods (markLivePending/approveForLiveShow/rejectForLiveShow). Added Event helpers (ensureLiveShowToken/rotateLiveShowToken). Tests: tests/Feature/LiveShowDataModelTest.php.","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-01-05T11:10:56.560421826+01:00","created_by":"soeren","updated_at":"2026-01-05T12:22:51.967913423+01:00","closed_at":"2026-01-05T12:22:51.967913423+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-t1k","depends_on_id":"fotospiel-app-vro","type":"blocks","created_at":"2026-01-05T11:12:20.345646244+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-t1k","depends_on_id":"fotospiel-app-h5d","type":"blocks","created_at":"2026-01-05T11:44:12.439413712+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-t1k","depends_on_id":"fotospiel-app-1eu","type":"blocks","created_at":"2026-01-05T11:44:22.588642567+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-t1k","depends_on_id":"fotospiel-app-1we","type":"blocks","created_at":"2026-01-05T11:44:31.775634827+01:00","created_by":"soeren"}]}
{"id":"fotospiel-app-t2s","title":"Uploader: multiple event profiles","description":"Part of epic fotospiel-app-5aa. Save multiple event profiles and allow quick switching.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:04:18.20222112+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:04:18.20222112+01:00"}
{"id":"fotospiel-app-tqg","title":"Tenant admin onboarding: staging E2E validation","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:08:57.448899354+01:00","created_by":"soeren","updated_at":"2026-01-01T16:08:57.448899354+01:00"}
{"id":"fotospiel-app-tsb","title":"Uploader: upload throttling presets","description":"Part of epic fotospiel-app-5aa. Add optional delay/presets to smooth upload bursts.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:02:27.111436345+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:02:27.111436345+01:00"}
{"id":"fotospiel-app-ty9","title":"Security review: data classes \u0026 retention baseline","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:03:09.595870306+01:00","created_by":"soeren","updated_at":"2026-01-01T16:03:15.211042718+01:00","closed_at":"2026-01-01T16:03:15.211042718+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-tym","title":"Ops health dashboard (queues, storage, upload pipeline)","description":"Superadmin ops dashboard showing queue backlog, failed jobs, storage thresholds, and upload pipeline health.","notes":"Implemented Ops Health dashboard with storage+queue widgets, new translations, and navigation wiring.","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-01-01T14:20:04.991351193+01:00","updated_at":"2026-01-02T17:34:10.326367902+01:00","closed_at":"2026-01-02T17:34:10.326367902+01:00","close_reason":"Closed"}
{"id":"fotospiel-app-ugk","title":"Paddle catalog sync: feature test for artisan command","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:01:33.309716868+01:00","created_by":"soeren","updated_at":"2026-01-01T16:01:38.940407157+01:00","closed_at":"2026-01-01T16:01:38.940407157+01:00","close_reason":"Completed in codebase (verified)"}
@@ -142,6 +162,7 @@
{"id":"fotospiel-app-wku","title":"Security review: run dynamic testing harness (identities, DAST, fuzz uploads)","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:05:37.008239379+01:00","created_by":"soeren","updated_at":"2026-01-01T16:05:37.008239379+01:00"}
{"id":"fotospiel-app-xg5","title":"Live Show: Admin app moderation queue UI","acceptance_criteria":"- Dedicated Live Show moderation API endpoints exist for list + approve/reject/clear\\n- Admin mobile UI exposes Live Show queue with status filter and actions\\n- PhotoResource includes live_* fields for admin UI\\n- Feature tests cover list + approve/reject/clear workflows","notes":"Added dedicated Live Show moderation API (tenant admin): /events/{slug}/live-show/photos + approve/reject/clear actions. Added LiveShowPhotoController + FormRequests. PhotoResource now exposes live_* fields. Admin app: new Live Show queue page, route, and Event detail shortcut tile. Admin API updated with Live Show functions + types. Added translations (EN/DE) for Live Show queue UI. Tests: tests/Feature/LiveShowPhotoControllerTest.php.","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-01-05T11:11:15.006484132+01:00","created_by":"soeren","updated_at":"2026-01-05T14:03:41.410176482+01:00","closed_at":"2026-01-05T14:03:41.410176482+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-xg5","depends_on_id":"fotospiel-app-t1k","type":"blocks","created_at":"2026-01-05T11:12:38.94145573+01:00","created_by":"soeren"}]}
{"id":"fotospiel-app-xht","title":"Paddle migration: tenant ↔ Paddle customer sync + webhook handlers","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:58:01.028435913+01:00","created_by":"soeren","updated_at":"2026-01-01T15:58:06.685122343+01:00","closed_at":"2026-01-01T15:58:06.685122343+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-xik","title":"Uploader: richer error details","description":"Part of epic fotospiel-app-5aa. Surface HTTP status/body summary in last error and recent uploads.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:02:49.591107008+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:02:49.591107008+01:00"}
{"id":"fotospiel-app-y1f","title":"Compliance tools: superadmin data export + retention override UI","description":"Add superadmin compliance tools for data exports and retention overrides.\nScope: list export requests, status, expiry, and allow manual retry/cancel; add per-tenant/event retention override UI with audit logging.\nEnsure access is restricted to superadmins and no PII is exposed beyond existing export metadata.\n","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-02T17:34:29.825347299+01:00","created_by":"soeren","updated_at":"2026-01-02T22:49:53.586758621+01:00","closed_at":"2026-01-02T22:49:53.586758621+01:00","close_reason":"Closed"}
{"id":"fotospiel-app-yii","title":"Implement 'Upgrade to Premium' flow for Analytics Upsell","description":"The Analytics page currently has an upsell screen for non-premium users. The 'Upgrade to Premium' button redirects to the billing page, but the actual upgrade/purchase flow needs to be fully implemented and verified to allow users to unlock the feature.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T16:13:55.446495378+01:00","created_by":"soeren","updated_at":"2026-01-06T16:35:41.968964977+01:00","closed_at":"2026-01-06T16:35:41.968970147+01:00"}
{"id":"fotospiel-app-z2k","title":"Ops health widget visual polish","description":"Replace Tailwind utility styling in ops health widget with Filament components and icon-driven layout.","notes":"Updated queue health widget layout to use Filament cards, badges, empty states, and grid utilities; added status strip and alert rail.","status":"closed","priority":3,"issue_type":"task","created_at":"2026-01-01T21:34:39.851728527+01:00","created_by":"soeren","updated_at":"2026-01-01T21:34:59.834597413+01:00","closed_at":"2026-01-01T21:34:59.834597413+01:00","close_reason":"completed"}

View File

@@ -1 +1 @@
fotospiel-app-29r
fotospiel-app-spq8

1
.gitignore vendored
View File

@@ -12,6 +12,7 @@ fotospiel-tenant-app
/resources/js/wayfinder
/storage/*.key
/storage/pail
/C:\\wwwroot\\fotospiel-app\\storage\\app/
/vendor
/clients/photobooth-uploader/**/bin
/clients/photobooth-uploader/**/obj

View File

@@ -337,8 +337,8 @@ Tokens are design system values that can be referenced using the `$` prefix.
### Color Tokens
- `accent`: #FFB6C1
- `accentSoft`: #FFE5EC
- `accent`: #3D5AFE
- `accentSoft`: #E8ECFF
- `blue10Dark`: hsl(209, 100%, 60.6%)
- `blue10Light`: hsl(208, 100%, 47.3%)
- `blue11Dark`: hsl(210, 100%, 66.1%)
@@ -363,8 +363,8 @@ Tokens are design system values that can be referenced using the `$` prefix.
- `blue8Light`: hsl(206, 81.9%, 65.3%)
- `blue9Dark`: hsl(206, 100%, 50.0%)
- `blue9Light`: hsl(206, 100%, 50.0%)
- `border`: #F2E4DA
- `danger`: #E04848
- `border`: #F3D6C9
- `danger`: #EF4444
- `gray10Dark`: hsl(0, 0%, 49.4%)
- `gray10Light`: hsl(0, 0%, 52.3%)
- `gray11Dark`: hsl(0, 0%, 62.8%)
@@ -413,7 +413,7 @@ Tokens are design system values that can be referenced using the `$` prefix.
- `green8Light`: hsl(151, 40.2%, 54.1%)
- `green9Dark`: hsl(151, 55.0%, 41.5%)
- `green9Light`: hsl(151, 55.0%, 41.5%)
- `muted`: #F4ECE8
- `muted`: #FFF6F0
- `orange10Dark`: hsl(24, 100%, 58.5%)
- `orange10Light`: hsl(24, 100%, 46.5%)
- `orange11Dark`: hsl(24, 100%, 62.2%)
@@ -462,7 +462,7 @@ Tokens are design system values that can be referenced using the `$` prefix.
- `pink8Light`: hsl(323, 60.3%, 72.4%)
- `pink9Dark`: hsl(322, 65.0%, 54.5%)
- `pink9Light`: hsl(322, 65.0%, 54.5%)
- `primary`: #FF5A5F
- `primary`: #FF5C5C
- `purple10Dark`: hsl(273, 57.3%, 59.1%)
- `purple10Light`: hsl(272, 46.8%, 50.3%)
- `purple11Dark`: hsl(275, 80.0%, 71.0%)
@@ -511,10 +511,10 @@ Tokens are design system values that can be referenced using the `$` prefix.
- `red8Light`: hsl(359, 69.5%, 74.3%)
- `red9Dark`: hsl(358, 75.0%, 59.0%)
- `red9Light`: hsl(358, 75.0%, 59.0%)
- `success`: #06D6A0
- `success`: #22C55E
- `surface`: #ffffff
- `text`: #1F2937
- `warning`: #F5C542
- `text`: #0B132B
- `warning`: #FBBF24
- `yellow10Dark`: hsl(54, 100%, 68.0%)
- `yellow10Light`: hsl(50, 100%, 48.5%)
- `yellow11Dark`: hsl(48, 100%, 47.0%)

View File

@@ -5152,7 +5152,7 @@ var require_useMergeRefs = __commonJS({
}
return React83.useMemo(
() => (0, _mergeRefs.default)(...args),
// eslint-disable-next-line
[...args]
);
}
@@ -12243,7 +12243,7 @@ var require_useMergeRefs2 = __commonJS({
}
},
[...refs]
// eslint-disable-line react-hooks/exhaustive-deps
);
}
__name(useMergeRefs, "useMergeRefs");
@@ -12938,7 +12938,7 @@ var require_VirtualizedSectionList = __commonJS({
}
};
this._renderItem = (listItemCount) => (
// eslint-disable-next-line react/no-unstable-nested-components
(_ref2) => {
var item = _ref2.item, index5 = _ref2.index;
var info = this._subExtractor(index5);
@@ -30935,17 +30935,17 @@ function useInteractions(propsList) {
const itemDeps = propsList.map((key) => key == null ? void 0 : key.item);
const getReferenceProps = React51.useCallback(
(userProps) => mergeProps(userProps, propsList, "reference"),
// eslint-disable-next-line react-hooks/exhaustive-deps
referenceDeps
);
const getFloatingProps = React51.useCallback(
(userProps) => mergeProps(userProps, propsList, "floating"),
// eslint-disable-next-line react-hooks/exhaustive-deps
floatingDeps
);
const getItemProps = React51.useCallback(
(userProps) => mergeProps(userProps, propsList, "item"),
// eslint-disable-next-line react-hooks/exhaustive-deps
itemDeps
);
return React51.useMemo(() => ({
@@ -33866,7 +33866,7 @@ function FloatingFocusManager(props) {
queueMicrotask(() => {
const tabbableReturnElement = getFirstTabbableElement(returnElement);
if (
// eslint-disable-next-line react-hooks/exhaustive-deps
returnFocusRef.current && !preventReturnFocusRef.current && isHTMLElement(tabbableReturnElement) && // If the focus moved somewhere else after mount, avoid returning focus
// since it likely entered a different element which should be
// respected: https://github.com/floating-ui/floating-ui/issues/2607
@@ -34615,17 +34615,17 @@ function useInteractions2(propsList) {
const itemDeps = propsList.map((key) => key == null ? void 0 : key.item);
const getReferenceProps = React60.useCallback(
(userProps) => mergeProps2(userProps, propsList, "reference"),
// eslint-disable-next-line react-hooks/exhaustive-deps
referenceDeps
);
const getFloatingProps = React60.useCallback(
(userProps) => mergeProps2(userProps, propsList, "floating"),
// eslint-disable-next-line react-hooks/exhaustive-deps
floatingDeps
);
const getItemProps = React60.useCallback(
(userProps) => mergeProps2(userProps, propsList, "item"),
// eslint-disable-next-line react-hooks/exhaustive-deps
itemDeps
);
return React60.useMemo(() => ({
@@ -39482,17 +39482,17 @@ function useInteractions3(propsList) {
const itemDeps = propsList.map((key) => key == null ? void 0 : key.item);
const getReferenceProps = React76.useCallback(
(userProps) => mergeProps3(userProps, propsList, "reference"),
// eslint-disable-next-line react-hooks/exhaustive-deps
referenceDeps
);
const getFloatingProps = React76.useCallback(
(userProps) => mergeProps3(userProps, propsList, "floating"),
// eslint-disable-next-line react-hooks/exhaustive-deps
floatingDeps
);
const getItemProps = React76.useCallback(
(userProps) => mergeProps3(userProps, propsList, "item"),
// eslint-disable-next-line react-hooks/exhaustive-deps
itemDeps
);
return React76.useMemo(() => ({

View File

@@ -4160,27 +4160,33 @@ var tokens3 = {
...tokens2,
color: {
...tokens2.color,
primary: "#FF5A5F",
accent: "#FFB6C1",
accentSoft: "#FFE5EC",
success: "#06D6A0",
warning: "#F5C542",
danger: "#E04848",
primary: "#4F46E5",
// Indigo 600
accent: "#F43F5E",
// Rose 500
accentSoft: "#E0E7FF",
// Indigo 100
success: "#10B981",
// Emerald 500
warning: "#F59E0B",
// Amber 500
danger: "#EF4444",
// Red 500
surface: "#ffffff",
muted: "#F4ECE8",
border: "#F2E4DA",
text: "#1F2937"
muted: "#F8FAFC",
// Slate 50
border: "#E2E8F0",
// Slate 200
text: "#0F172A"
// Slate 900
},
radius: {
...tokens2.radius,
card: 20,
// ... existing radius tokens ...
card: 16,
tile: 14,
pill: 999
},
size: {
...tokens2.size,
card: 20
}
// ...
};
var themes3 = {
...themes2,
@@ -4188,53 +4194,56 @@ var themes3 = {
...themes2.light,
primary: tokens3.color.primary,
accent: tokens3.color.accent,
background: "#FFF8F5",
backgroundHover: "#FFF1EC",
backgroundPress: "#FFE7E0",
background: "#F1F5F9",
// Slate 100
backgroundHover: "#E2E8F0",
backgroundPress: "#CBD5E1",
backgroundStrong: tokens3.color.surface,
backgroundTransparent: "rgba(255, 248, 245, 0)",
backgroundTransparent: "rgba(241, 245, 249, 0)",
color: tokens3.color.text,
colorHover: "#111827",
colorPress: "#0F172A",
colorFocus: "#0F172A",
colorHover: "#1E293B",
colorPress: "#1E293B",
colorFocus: "#1E293B",
borderColor: tokens3.color.border,
borderColorHover: "#EAD5C9",
borderColorPress: "#E0C9BC",
shadowColor: "rgba(31, 41, 55, 0.12)",
shadowColorPress: "rgba(31, 41, 55, 0.16)",
shadowColorFocus: "rgba(31, 41, 55, 0.18)",
borderColorHover: "#CBD5E1",
borderColorPress: "#94A3B8",
shadowColor: "rgba(15, 23, 42, 0.08)",
shadowColorPress: "rgba(15, 23, 42, 0.12)",
shadowColorFocus: "rgba(15, 23, 42, 0.12)",
surface: tokens3.color.surface,
muted: tokens3.color.muted,
blue3: tokens3.color.accentSoft,
blue6: tokens3.color.accent,
blue6: "#6366F1",
// Indigo 500
blue10: tokens3.color.primary,
blue11: "#C2413B"
blue11: "#4338CA"
// Indigo 700
},
dark: {
...themes2.dark,
primary: tokens3.color.primary,
accent: tokens3.color.accent,
background: "#171219",
backgroundHover: "#1F1A23",
backgroundPress: "#26212B",
backgroundStrong: "#1F1A23",
backgroundTransparent: "rgba(23, 18, 25, 0)",
color: "#F8F6F2",
background: "#0B132B",
backgroundHover: "#101A36",
backgroundPress: "#132142",
backgroundStrong: "#101A36",
backgroundTransparent: "rgba(11, 19, 43, 0)",
color: "#F8FAFF",
colorHover: "#FFFFFF",
colorPress: "#FDF8F5",
colorPress: "#F2F6FF",
colorFocus: "#FFFFFF",
borderColor: "#2C2531",
borderColorHover: "#3A3240",
borderColorPress: "#443C4A",
borderColor: "#1F2A4A",
borderColorHover: "#29345A",
borderColorPress: "#313D67",
shadowColor: "rgba(0, 0, 0, 0.55)",
shadowColorPress: "rgba(0, 0, 0, 0.65)",
shadowColorFocus: "rgba(0, 0, 0, 0.6)",
surface: "#1F1A23",
muted: "#241E28",
blue3: "#2B1D23",
blue6: "#5A2D34",
blue10: "#FF7A7F",
blue11: "#FFB3B6"
surface: "#0F1B36",
muted: "#121F3D",
blue3: "#1B2550",
blue6: "#3D5AFE",
blue10: "#FF5C5C",
blue11: "#FF8A8A"
}
};
var sharedWeights = {
@@ -4254,12 +4263,12 @@ var fonts2 = {
},
heading: {
...defaultConfig.fonts.heading,
family: "Manrope",
family: "Archivo Black",
weight: sharedWeights
},
display: {
...defaultConfig.fonts.heading,
family: "Fraunces",
family: "Archivo Black",
weight: sharedWeights
}
};

File diff suppressed because it is too large Load Diff

209
AGENTS.md
View File

@@ -129,7 +129,7 @@ The Laravel Boost guidelines are specifically curated by Laravel maintainers for
## Foundational Context
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
- php - 8.3.24
- php - 8.3.6
- filament/filament (FILAMENT) - v4
- inertiajs/inertia-laravel (INERTIA) - v2
- laravel/framework (LARAVEL) - v12
@@ -151,7 +151,7 @@ This application is a Laravel application and its main Laravel ecosystems packag
- prettier (PRETTIER) - v3
## Conventions
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming.
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
- Check for existing components to reuse before writing a new one.
@@ -159,7 +159,7 @@ This application is a Laravel application and its main Laravel ecosystems packag
- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important.
## Application Structure & Architecture
- Stick to existing directory structure - don't create new base folders without approval.
- Stick to existing directory structure; don't create new base folders without approval.
- Do not change the application's dependencies without approval.
## Frontend Bundling
@@ -171,17 +171,16 @@ This application is a Laravel application and its main Laravel ecosystems packag
## Documentation Files
- You must only create documentation files if explicitly requested by the user.
=== boost rules ===
## Laravel Boost
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
## Artisan
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters.
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters.
## URLs
- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port.
- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port.
## Tinker / Debugging
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
@@ -192,22 +191,21 @@ This application is a Laravel application and its main Laravel ecosystems packag
- Only recent browser logs will be useful - ignore old logs.
## Searching Documentation (Critically Important)
- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches.
- Boost comes with a powerful `search-docs` tool you should use before any other approaches when dealing with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
- The `search-docs` tool is perfect for all Laravel-related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
- You must use this tool to search for Laravel ecosystem documentation before falling back to other approaches.
- Search the documentation before making code changes to ensure we are taking the correct approach.
- Use multiple, broad, simple, topic based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
- Use multiple, broad, simple, topic-based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
- Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
### Available Search Syntax
- You can and should pass multiple queries at once. The most relevant results will be returned first.
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit"
3. Quoted Phrases (Exact Position) - query="infinite scroll" - Words must be adjacent and in that order
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit"
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'.
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit".
3. Quoted Phrases (Exact Position) - query="infinite scroll" - words must be adjacent and in that order.
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit".
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms.
=== php rules ===
@@ -218,7 +216,7 @@ This application is a Laravel application and its main Laravel ecosystems packag
### Constructors
- Use PHP 8 constructor property promotion in `__construct()`.
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
- Do not allow empty `__construct()` methods with zero parameters.
- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private.
### Type Declarations
- Always use explicit return type declarations for methods and functions.
@@ -232,7 +230,7 @@ protected function isAccessible(User $user, ?string $path = null): bool
</code-snippet>
## Comments
- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on.
- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless there is something very complex going on.
## PHPDoc Blocks
- Add useful array shape type definitions for arrays when appropriate.
@@ -240,32 +238,22 @@ protected function isAccessible(User $user, ?string $path = null): bool
## Enums
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
=== herd rules ===
## Laravel Herd
- The application is served by Laravel Herd and will be available at: https?://[kebab-case-project-dir].test. Use the `get-absolute-url` tool to generate URLs for the user to ensure valid URLs.
- You must not run any commands to make the site available via HTTP(s). It is _always_ available through Laravel Herd.
=== tests rules ===
## Test Enforcement
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test` with a specific filename or filter.
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter.
=== inertia-laravel/core rules ===
## Inertia Core
## Inertia
- Inertia.js components should be placed in the `resources/js/pages` directory unless specified differently in the JS bundler (vite.config.js).
- Inertia.js components should be placed in the `resources/js/Pages` directory unless specified differently in the JS bundler (`vite.config.js`).
- Use `Inertia::render()` for server-side routing instead of traditional Blade views.
- Use `search-docs` for accurate guidance on all things Inertia.
- Use the `search-docs` tool for accurate guidance on all things Inertia.
<code-snippet lang="php" name="Inertia::render Example">
<code-snippet name="Inertia Render Example" lang="php">
// routes/web.php example
Route::get('/users', function () {
return Inertia::render('Users/Index', [
@@ -274,28 +262,26 @@ Route::get('/users', function () {
});
</code-snippet>
=== inertia-laravel/v2 rules ===
## Inertia v2
- Make use of all Inertia features from v1 & v2. Check the documentation before making any changes to ensure we are taking the correct approach.
- Make use of all Inertia features from v1 and v2. Check the documentation before making any changes to ensure we are taking the correct approach.
### Inertia v2 New Features
- Polling
- Prefetching
- Deferred props
- Infinite scrolling using merging props and `WhenVisible`
- Lazy loading data on scroll
- Deferred props.
- Infinite scrolling using merging props and `WhenVisible`.
- Lazy loading data on scroll.
- Polling.
- Prefetching.
### Deferred Props & Empty States
- When using deferred props on the frontend, you should add a nice empty state with pulsing / animated skeleton.
- When using deferred props on the frontend, you should add a nice empty state with pulsing/animated skeleton.
### Inertia Form General Guidance
- The recommended way to build forms when using Inertia is with the `<Form>` component - a useful example is below. Use `search-docs` with a query of `form component` for guidance.
- Forms can also be built using the `useForm` helper for more programmatic control, or to follow existing conventions. Use `search-docs` with a query of `useForm helper` for guidance.
- `resetOnError`, `resetOnSuccess`, and `setDefaultsOnSuccess` are available on the `<Form>` component. Use `search-docs` with a query of 'form component resetting' for guidance.
- The recommended way to build forms when using Inertia is with the `<Form>` component - a useful example is below. Use the `search-docs` tool with a query of `form component` for guidance.
- Forms can also be built using the `useForm` helper for more programmatic control, or to follow existing conventions. Use the `search-docs` tool with a query of `useForm helper` for guidance.
- `resetOnError`, `resetOnSuccess`, and `setDefaultsOnSuccess` are available on the `<Form>` component. Use the `search-docs` tool with a query of `form component resetting` for guidance.
=== laravel/core rules ===
@@ -307,7 +293,7 @@ Route::get('/users', function () {
### Database
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
- Use Eloquent models and relationships before suggesting raw database queries
- Use Eloquent models and relationships before suggesting raw database queries.
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
- Generate code that prevents N+1 query problems by using eager loading.
- Use Laravel's query builder for very complex database operations.
@@ -342,52 +328,56 @@ Route::get('/users', function () {
### Vite Error
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
=== laravel/v12 rules ===
## Laravel 12
- Use the `search-docs` tool to get version specific documentation.
- Use the `search-docs` tool to get version-specific documentation.
- This project upgraded from Laravel 10 without migrating to the new streamlined Laravel file structure.
- This is **perfectly fine** and recommended by Laravel. Follow the existing structure from Laravel 10. We do not to need migrate to the new Laravel structure unless the user explicitly requests that.
- This is **perfectly fine** and recommended by Laravel. Follow the existing structure from Laravel 10. We do not need to migrate to the new Laravel structure unless the user explicitly requests it.
### Laravel 10 Structure
- Middleware typically lives in `app/Http/Middleware/` and service providers in `app/Providers/`.
- There is no `bootstrap/app.php` application configuration in a Laravel 10 structure:
- Middleware registration happens in `app/Http/Kernel.php`
- Exception handling is in `app/Exceptions/Handler.php`
- Console commands and schedule register in `app/Console/Kernel.php`
- Rate limits likely exist in `RouteServiceProvider` or `app/Http/Kernel.php`
### Database
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
### Models
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
=== wayfinder/core rules ===
## Laravel Wayfinder
Wayfinder generates TypeScript functions and types for Laravel controllers and routes which you can import into your client side code. It provides type safety and automatic synchronization between backend routes and frontend code.
Wayfinder generates TypeScript functions and types for Laravel controllers and routes which you can import into your client-side code. It provides type safety and automatic synchronization between backend routes and frontend code.
### Development Guidelines
- Always use `search-docs` to check wayfinder correct usage before implementing any features.
- Always Prefer named imports for tree-shaking (e.g., `import { show } from '@/actions/...'`)
- Avoid default controller imports (prevents tree-shaking)
- Run `php artisan wayfinder:generate` after route changes if Vite plugin isn't installed
- Always use the `search-docs` tool to check Wayfinder correct usage before implementing any features.
- Always prefer named imports for tree-shaking (e.g., `import { show } from '@/actions/...'`).
- Avoid default controller imports (prevents tree-shaking).
- Run `php artisan wayfinder:generate` after route changes if Vite plugin isn't installed.
### Feature Overview
- Form Support: Use `.form()` with `--with-form` flag for HTML form attributes — `<form {...store.form()}>``action="/posts" method="post"`
- HTTP Methods: Call `.get()`, `.post()`, `.patch()`, `.put()`, `.delete()` for specific methods — `show.head(1)``{ url: "/posts/1", method: "head" }`
- Invokable Controllers: Import and invoke directly as functions. For example, `import StorePost from '@/actions/.../StorePostController'; StorePost()`
- Named Routes: Import from `@/routes/` for non-controller routes. For example, `import { show } from '@/routes/post'; show(1)` for route name `post.show`
- Parameter Binding: Detects route keys (e.g., `{post:slug}`) and accepts matching object properties — `show("my-post")` or `show({ slug: "my-post" })`
- Query Merging: Use `mergeQuery` to merge with `window.location.search`, set values to `null` to remove — `show(1, { mergeQuery: { page: 2, sort: null } })`
- Query Parameters: Pass `{ query: {...} }` in options to append params — `show(1, { query: { page: 1 } })``"/posts/1?page=1"`
- Route Objects: Functions return `{ url, method }` shaped objects — `show(1)``{ url: "/posts/1", method: "get" }`
- URL Extraction: Use `.url()` to get URL string — `show.url(1)``"/posts/1"`
- Form Support: Use `.form()` with `--with-form` flag for HTML form attributes — `<form {...store.form()}>``action="/posts" method="post"`.
- HTTP Methods: Call `.get()`, `.post()`, `.patch()`, `.put()`, `.delete()` for specific methods — `show.head(1)``{ url: "/posts/1", method: "head" }`.
- Invokable Controllers: Import and invoke directly as functions. For example, `import StorePost from '@/actions/.../StorePostController'; StorePost()`.
- Named Routes: Import from `@/routes/` for non-controller routes. For example, `import { show } from '@/routes/post'; show(1)` for route name `post.show`.
- Parameter Binding: Detects route keys (e.g., `{post:slug}`) and accepts matching object properties — `show("my-post")` or `show({ slug: "my-post" })`.
- Query Merging: Use `mergeQuery` to merge with `window.location.search`, set values to `null` to remove — `show(1, { mergeQuery: { page: 2, sort: null } })`.
- Query Parameters: Pass `{ query: {...} }` in options to append params — `show(1, { query: { page: 1 } })``"/posts/1?page=1"`.
- Route Objects: Functions return `{ url, method }` shaped objects — `show(1)``{ url: "/posts/1", method: "get" }`.
- URL Extraction: Use `.url()` to get URL string — `show.url(1)``"/posts/1"`.
### Example Usage
<code-snippet name="Wayfinder Basic Usage" lang="typescript">
// Import controller methods (tree-shakable)
// Import controller methods (tree-shakable)...
import { show, store, update } from '@/actions/App/Http/Controllers/PostController'
// Get route object with URL and method...
@@ -405,7 +395,6 @@ Wayfinder generates TypeScript functions and types for Laravel controllers and r
postShow(1) // { url: "/posts/1", method: "get" }
</code-snippet>
### Wayfinder + Inertia
If your application uses the `<Form>` component from Inertia, you can use Wayfinder to generate form action and method automatically.
<code-snippet name="Wayfinder Form Component (React)" lang="typescript">
@@ -414,14 +403,14 @@ If your application uses the `<Form>` component from Inertia, you can use Wayfin
</code-snippet>
=== livewire/core rules ===
## Livewire Core
- Use the `search-docs` tool to find exact version specific documentation for how to write Livewire & Livewire tests.
- Use the `php artisan make:livewire [Posts\CreatePost]` artisan command to create new components
## Livewire
- Use the `search-docs` tool to find exact version-specific documentation for how to write Livewire and Livewire tests.
- Use the `php artisan make:livewire [Posts\CreatePost]` Artisan command to create new components.
- State should live on the server, with the UI reflecting it.
- All Livewire requests hit the Laravel backend, they're like regular HTTP requests. Always validate form data, and run authorization checks in Livewire actions.
- All Livewire requests hit the Laravel backend; they're like regular HTTP requests. Always validate form data and run authorization checks in Livewire actions.
## Livewire Best Practices
- Livewire components require a single root element.
@@ -438,15 +427,14 @@ If your application uses the `<Form>` component from Inertia, you can use Wayfin
- Prefer lifecycle hooks like `mount()`, `updatedFoo()` for initialization and reactive side effects:
<code-snippet name="Lifecycle hook examples" lang="php">
<code-snippet name="Lifecycle Hook Examples" lang="php">
public function mount(User $user) { $this->user = $user; }
public function updatedSearch() { $this->resetPage(); }
</code-snippet>
## Testing Livewire
<code-snippet name="Example Livewire component test" lang="php">
<code-snippet name="Example Livewire Component Test" lang="php">
Livewire::test(Counter::class)
->assertSet('count', 0)
->call('increment')
@@ -455,19 +443,17 @@ If your application uses the `<Form>` component from Inertia, you can use Wayfin
->assertStatus(200);
</code-snippet>
<code-snippet name="Testing a Livewire component exists within a page" lang="php">
$this->get('/posts/create')
->assertSeeLivewire(CreatePost::class);
</code-snippet>
<code-snippet name="Testing Livewire Component Exists on Page" lang="php">
$this->get('/posts/create')
->assertSeeLivewire(CreatePost::class);
</code-snippet>
=== livewire/v3 rules ===
## Livewire 3
### Key Changes From Livewire 2
- These things changed in Livewire 2, but may not have been updated in this application. Verify this application's setup to ensure you conform with application conventions.
- These things changed in Livewire 3, but may not have been updated in this application. Verify this application's setup to ensure you conform with application conventions.
- Use `wire:model.live` for real-time updates, `wire:model` is now deferred by default.
- Components now use the `App\Livewire` namespace (not `App\Http\Livewire`).
- Use `$this->dispatch()` to dispatch events (not `emit` or `dispatchBrowserEvent`).
@@ -477,13 +463,13 @@ If your application uses the `<Form>` component from Inertia, you can use Wayfin
- `wire:show`, `wire:transition`, `wire:cloak`, `wire:offline`, `wire:target` are available for use. Use the documentation to find usage examples.
### Alpine
- Alpine is now included with Livewire, don't manually include Alpine.js.
- Alpine is now included with Livewire; don't manually include Alpine.js.
- Plugins included with Alpine: persist, intersect, collapse, and focus.
### Lifecycle Hooks
- You can listen for `livewire:init` to hook into Livewire initialization, and `fail.status === 419` for the page expiring:
<code-snippet name="livewire:load example" lang="js">
<code-snippet name="Livewire Init Hook Example" lang="js">
document.addEventListener('livewire:init', function () {
Livewire.hook('request', ({ fail }) => {
if (fail && fail.status === 419) {
@@ -497,7 +483,6 @@ document.addEventListener('livewire:init', function () {
});
</code-snippet>
=== pint/core rules ===
## Laravel Pint Code Formatter
@@ -505,24 +490,22 @@ document.addEventListener('livewire:init', function () {
- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
- Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues.
=== phpunit/core rules ===
## PHPUnit Core
## PHPUnit
- This application uses PHPUnit for testing. All tests must be written as PHPUnit classes. Use `php artisan make:test --phpunit {name}` to create a new test.
- If you see a test using "Pest", convert it to PHPUnit.
- Every time a test has been updated, run that singular test.
- When the tests relating to your feature are passing, ask the user if they would like to also run the entire test suite to make sure everything is still passing.
- Tests should test all of the happy paths, failure paths, and weird paths.
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files, these are core to the application.
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files; these are core to the application.
### Running Tests
- Run the minimal number of tests, using an appropriate filter, before finalizing.
- To run all tests: `php artisan test`.
- To run all tests in a file: `php artisan test tests/Feature/ExampleTest.php`.
- To filter on a particular test name: `php artisan test --filter=testName` (recommended after making a change to a related file).
- To run all tests: `php artisan test --compact`.
- To run all tests in a file: `php artisan test --compact tests/Feature/ExampleTest.php`.
- To filter on a particular test name: `php artisan test --compact --filter=testName` (recommended after making a change to a related file).
=== inertia-react/core rules ===
@@ -537,10 +520,9 @@ import { Link } from '@inertiajs/react'
</code-snippet>
=== inertia-react/v2/forms rules ===
## Inertia + React Forms
## Inertia v2 + React Forms
<code-snippet name="`<Form>` Component Example" lang="react">
@@ -575,39 +557,37 @@ export default () => (
</code-snippet>
=== tailwindcss/core rules ===
## Tailwind Core
## Tailwind CSS
- Use Tailwind CSS classes to style HTML, check and use existing tailwind conventions within the project before writing your own.
- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc..)
- Think through class placement, order, priority, and defaults - remove redundant classes, add classes to parent or child carefully to limit repetition, group elements logically
- Use Tailwind CSS classes to style HTML; check and use existing Tailwind conventions within the project before writing your own.
- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc.).
- Think through class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child carefully to limit repetition, and group elements logically.
- You can use the `search-docs` tool to get exact examples from the official documentation when needed.
### Spacing
- When listing items, use gap utilities for spacing, don't use margins.
<code-snippet name="Valid Flex Gap Spacing Example" lang="html">
<div class="flex gap-8">
<div>Superior</div>
<div>Michigan</div>
<div>Erie</div>
</div>
</code-snippet>
- When listing items, use gap utilities for spacing; don't use margins.
<code-snippet name="Valid Flex Gap Spacing Example" lang="html">
<div class="flex gap-8">
<div>Superior</div>
<div>Michigan</div>
<div>Erie</div>
</div>
</code-snippet>
### Dark Mode
- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`.
=== tailwindcss/v4 rules ===
## Tailwind 4
## Tailwind CSS 4
- Always use Tailwind CSS v4 - do not use the deprecated utilities.
- Always use Tailwind CSS v4; do not use the deprecated utilities.
- `corePlugins` is not supported in Tailwind v4.
- In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed.
<code-snippet name="Extending Theme in CSS" lang="css">
@theme {
--color-brand: oklch(0.72 0.11 178);
@@ -623,9 +603,8 @@ export default () => (
+ @import "tailwindcss";
</code-snippet>
### Replaced Utilities
- Tailwind v4 removed deprecated utilities. Do not use the deprecated option - use the replacement.
- Tailwind v4 removed deprecated utilities. Do not use the deprecated option; use the replacement.
- Opacity values are still numeric.
| Deprecated | Replacement |

View File

@@ -26,7 +26,7 @@ class SeedDemoSwitcherTenants extends Command
{
protected $signature = 'demo:seed-switcher {--with-photos : Download sample photos from Pexels} {--photos-per-event=18 : Target photos per event when downloading} {--cleanup : Remove demo switcher tenants/events/photos instead of seeding}';
protected $description = 'Seeds demo tenants used by the DevTenantSwitcher (endcustomer + reseller profiles)';
protected $description = 'Seeds demo tenants used by the DevTenantSwitcher (endcustomer + partner profiles)';
public function __construct(private EventStorageManager $eventStorageManager)
{
@@ -52,7 +52,7 @@ class SeedDemoSwitcherTenants extends Command
DB::transaction(function () use ($packages, $eventTypes) {
$this->seedCustomerStandardEmpty($packages, $eventTypes);
$this->seedCustomerStarterWedding($packages, $eventTypes);
$this->seedCustomerStandardWedding($packages, $eventTypes);
$this->seedResellerActive($packages, $eventTypes);
$this->seedResellerFull($packages, $eventTypes);
});
@@ -129,7 +129,7 @@ class SeedDemoSwitcherTenants extends Command
$slugs = [
'starter' => 'Starter',
'standard' => 'Standard',
's-small-reseller' => 'Reseller S',
's-small-reseller' => 'Partner Start',
];
$packages = [];
@@ -165,10 +165,10 @@ class SeedDemoSwitcherTenants extends Command
{
$tenant = $this->upsertTenant(
slug: 'demo-standard-empty',
name: 'Demo Standard (ohne Event)',
name: 'Demo Starter (ohne Event)',
contactEmail: 'standard-empty@demo.fotospiel',
attributes: [
'subscription_tier' => 'standard',
'subscription_tier' => 'starter',
'subscription_status' => 'active',
],
);
@@ -176,9 +176,9 @@ class SeedDemoSwitcherTenants extends Command
$this->upsertAdmin($tenant, 'standard-empty@demo.fotospiel');
TenantPackage::updateOrCreate(
['tenant_id' => $tenant->id, 'package_id' => $packages['standard']->id],
['tenant_id' => $tenant->id, 'package_id' => $packages['starter']->id],
[
'price' => $packages['standard']->price,
'price' => $packages['starter']->price,
'purchased_at' => Carbon::now()->subDays(1),
'expires_at' => Carbon::now()->addMonths(12),
'used_events' => 0,
@@ -186,17 +186,17 @@ class SeedDemoSwitcherTenants extends Command
]
);
$this->comment('Seeded Standard tenant without events.');
$this->comment('Seeded Starter tenant without events.');
}
private function seedCustomerStarterWedding(array $packages, array $eventTypes): void
private function seedCustomerStandardWedding(array $packages, array $eventTypes): void
{
$tenant = $this->upsertTenant(
slug: 'demo-starter-wedding',
name: 'Demo Starter Wedding',
name: 'Demo Standard Wedding',
contactEmail: 'starter-wedding@demo.fotospiel',
attributes: [
'subscription_tier' => 'starter',
'subscription_tier' => 'standard',
'subscription_status' => 'active',
],
);
@@ -209,7 +209,7 @@ class SeedDemoSwitcherTenants extends Command
'price' => $packages['standard']->price,
'purchased_at' => Carbon::now()->subDays(1),
'expires_at' => Carbon::now()->addMonths(12),
'used_events' => 0,
'used_events' => 1,
'active' => true,
]
);
@@ -232,17 +232,18 @@ class SeedDemoSwitcherTenants extends Command
private function seedResellerActive(array $packages, array $eventTypes): void
{
$eventPackage = $this->resolveIncludedPackage($packages['s-small-reseller'], $packages);
$tenant = $this->upsertTenant(
slug: 'demo-reseller-active',
name: 'Demo Reseller Active',
contactEmail: 'reseller-active@demo.fotospiel',
name: 'Demo Partner Active',
contactEmail: 'partner-active@demo.fotospiel',
attributes: [
'subscription_tier' => 'reseller',
'subscription_status' => 'active',
],
);
$this->upsertAdmin($tenant, 'reseller-active@demo.fotospiel');
$this->upsertAdmin($tenant, 'partner-active@demo.fotospiel');
TenantPackage::updateOrCreate(
['tenant_id' => $tenant->id, 'package_id' => $packages['s-small-reseller']->id],
@@ -279,7 +280,7 @@ class SeedDemoSwitcherTenants extends Command
foreach ($events as $index => $config) {
$event = $this->upsertEvent(
tenant: $tenant,
package: $packages['standard'],
package: $eventPackage,
eventType: $config['type'],
attributes: [
'name' => $config['name'],
@@ -296,17 +297,18 @@ class SeedDemoSwitcherTenants extends Command
private function seedResellerFull(array $packages, array $eventTypes): void
{
$eventPackage = $this->resolveIncludedPackage($packages['s-small-reseller'], $packages);
$tenant = $this->upsertTenant(
slug: 'demo-reseller-full',
name: 'Demo Reseller Voll',
contactEmail: 'reseller-full@demo.fotospiel',
name: 'Demo Partner Voll',
contactEmail: 'partner-full@demo.fotospiel',
attributes: [
'subscription_tier' => 'reseller',
'subscription_status' => 'active',
],
);
$this->upsertAdmin($tenant, 'reseller-full@demo.fotospiel');
$this->upsertAdmin($tenant, 'partner-full@demo.fotospiel');
TenantPackage::updateOrCreate(
['tenant_id' => $tenant->id, 'package_id' => $packages['s-small-reseller']->id],
@@ -330,7 +332,7 @@ class SeedDemoSwitcherTenants extends Command
foreach ($eventConfigs as $index => $config) {
$event = $this->upsertEvent(
tenant: $tenant,
package: $packages['standard'],
package: $eventPackage,
eventType: $config['type'],
attributes: [
'name' => $config['name'],
@@ -357,8 +359,8 @@ class SeedDemoSwitcherTenants extends Command
'settings' => [
'branding' => [
'logo_url' => null,
'primary_color' => '#1D4ED8',
'secondary_color' => '#0F172A',
'primary_color' => '#FF5A5F',
'secondary_color' => '#FFF8F5',
'font_family' => 'Inter, sans-serif',
],
'features' => [
@@ -435,6 +437,19 @@ class SeedDemoSwitcherTenants extends Command
return $event;
}
private function resolveIncludedPackage(Package $resellerPackage, array $packages): Package
{
$includedSlug = $resellerPackage->included_package_slug;
if ($includedSlug && isset($packages[$includedSlug])) {
return $packages[$includedSlug];
}
$fallback = $packages['starter'] ?? $packages['standard'] ?? null;
return $fallback ?? $resellerPackage;
}
private function fallbackEventType(): ?EventType
{
$fallback = EventType::first();

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Filament\Clusters\WeeklyOps\Resources\TaskCollections\Pages;
use App\Filament\Clusters\WeeklyOps\Resources\TaskCollections\TaskCollectionResource;
use App\Models\TaskCollection;
use App\Services\Audit\SuperAdminAuditLogger;
use Filament\Actions;
use Filament\Resources\Pages\ManageRecords;
class ManageTaskCollections extends ManageRecords
{
protected static string $resource = TaskCollectionResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make()
->mutateDataUsing(fn (array $data): array => TaskCollectionResource::normalizeData($data))
->after(fn (array $data, TaskCollection $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
'created',
$record,
SuperAdminAuditLogger::fieldsMetadata($data),
static::class
)),
];
}
}

View File

@@ -0,0 +1,136 @@
<?php
namespace App\Filament\Clusters\WeeklyOps\Resources\TaskCollections\RelationManagers;
use App\Models\Task;
use Filament\Actions\AttachAction;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DetachAction;
use Filament\Actions\DetachBulkAction;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Arr;
class TasksRelationManager extends RelationManager
{
protected static string $relationship = 'tasks';
public function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('title')
->label(__('admin.tasks.table.title'))
->getStateUsing(fn (Task $record) => $this->formatTaskTitle($record->title))
->searchable(['title->de', 'title->en'])
->limit(60),
TextColumn::make('emotion.name')
->label(__('admin.tasks.fields.emotion'))
->getStateUsing(function (Task $record) {
$value = optional($record->emotion)->name;
if (is_array($value)) {
$locale = app()->getLocale();
return $value[$locale] ?? ($value['de'] ?? ($value['en'] ?? ''));
}
return (string) ($value ?? '');
})
->sortable(),
TextColumn::make('difficulty')
->label(__('admin.tasks.fields.difficulty.label'))
->badge(),
IconColumn::make('is_active')
->label(__('admin.tasks.table.is_active'))
->boolean(),
TextColumn::make('sort_order')
->label(__('admin.tasks.table.sort_order'))
->sortable(),
])
->headerActions([
AttachAction::make()
->recordTitle(fn (Task $record) => $this->formatTaskTitle($record->title))
->recordSelectOptionsQuery(function (Builder $query): Builder {
$collectionId = $this->getOwnerRecord()->getKey();
return $query
->whereNull('tenant_id')
->where(function (Builder $inner) use ($collectionId): void {
$inner->whereNull('collection_id')
->orWhere('collection_id', $collectionId);
});
})
->multiple()
->after(function (array $data): void {
$collection = $this->getOwnerRecord();
$recordIds = Arr::wrap($data['recordId'] ?? []);
if ($recordIds === []) {
return;
}
Task::query()
->whereIn('id', $recordIds)
->update(['collection_id' => $collection->getKey()]);
}),
])
->recordActions([
DetachAction::make()
->after(function (?Task $record): void {
if (! $record) {
return;
}
$collectionId = $this->getOwnerRecord()->getKey();
if ($record->collection_id === $collectionId) {
$record->update(['collection_id' => null]);
}
}),
])
->toolbarActions([
BulkActionGroup::make([
DetachBulkAction::make()
->after(function (Collection $records): void {
$collectionId = $this->getOwnerRecord()->getKey();
$ids = $records
->filter(fn (Task $record) => $record->collection_id === $collectionId)
->pluck('id')
->all();
if ($ids === []) {
return;
}
Task::query()
->whereIn('id', $ids)
->update(['collection_id' => null]);
}),
]),
]);
}
/**
* @param array<string, string>|string|null $value
*/
protected function formatTaskTitle(array|string|null $value): string
{
if (is_array($value)) {
$locale = app()->getLocale();
return $value[$locale]
?? ($value['de'] ?? ($value['en'] ?? Arr::first($value) ?? ''));
}
if (is_string($value)) {
return $value;
}
return '';
}
}

View File

@@ -0,0 +1,276 @@
<?php
namespace App\Filament\Clusters\WeeklyOps\Resources\TaskCollections;
use App\Filament\Clusters\WeeklyOps\Resources\TaskCollections\Pages\ManageTaskCollections;
use App\Filament\Clusters\WeeklyOps\Resources\TaskCollections\RelationManagers\TasksRelationManager;
use App\Filament\Clusters\WeeklyOps\WeeklyOpsCluster;
use App\Models\EventType;
use App\Models\TaskCollection;
use App\Services\Audit\SuperAdminAuditLogger;
use BackedEnum;
use Filament\Actions;
use Filament\Forms\Components\MarkdownEditor;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Tabs as SchemaTabs;
use Filament\Schemas\Components\Tabs\Tab as SchemaTab;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use UnitEnum;
class TaskCollectionResource extends Resource
{
protected static ?string $model = TaskCollection::class;
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-rectangle-stack';
protected static ?string $cluster = WeeklyOpsCluster::class;
protected static ?string $recordTitleAttribute = 'name';
protected static ?int $navigationSort = 31;
public static function form(Schema $schema): Schema
{
return $schema
->schema([
TextInput::make('slug')
->label(__('admin.common.slug'))
->maxLength(255)
->unique(ignoreRecord: true)
->required(),
Select::make('event_type_id')
->relationship('eventType', 'name')
->getOptionLabelFromRecordUsing(fn (EventType $record) => is_array($record->name) ? ($record->name['de'] ?? $record->name['en'] ?? __('admin.common.unnamed')) : $record->name)
->searchable()
->preload()
->label(__('admin.task_collections.fields.event_type_optional')),
SchemaTabs::make('content_tabs')
->label(__('admin.task_collections.fields.content_localization'))
->tabs([
SchemaTab::make(__('admin.common.german'))
->icon('heroicon-o-language')
->schema([
TextInput::make('name_translations.de')
->label(__('admin.task_collections.fields.name_de'))
->required(),
MarkdownEditor::make('description_translations.de')
->label(__('admin.task_collections.fields.description_de'))
->columnSpanFull(),
]),
SchemaTab::make(__('admin.common.english'))
->icon('heroicon-o-language')
->schema([
TextInput::make('name_translations.en')
->label(__('admin.task_collections.fields.name_en'))
->required(),
MarkdownEditor::make('description_translations.en')
->label(__('admin.task_collections.fields.description_en'))
->columnSpanFull(),
]),
])
->columnSpanFull(),
Toggle::make('is_default')
->label(__('admin.task_collections.fields.is_default'))
->default(false),
TextInput::make('position')
->label(__('admin.task_collections.fields.position'))
->numeric()
->default(0),
])
->columns(2);
}
public static function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('id')
->label('#')
->sortable(),
TextColumn::make('name')
->label(__('admin.task_collections.table.name'))
->getStateUsing(fn (TaskCollection $record) => static::formatTranslation($record->name_translations))
->searchable(['name_translations->de', 'name_translations->en'])
->limit(60),
TextColumn::make('eventType.name')
->label(__('admin.task_collections.table.event_type'))
->getStateUsing(function (TaskCollection $record) {
$value = optional($record->eventType)->name;
if (is_array($value)) {
$locale = app()->getLocale();
return $value[$locale] ?? ($value['de'] ?? ($value['en'] ?? ''));
}
return (string) ($value ?? '');
})
->toggleable(),
TextColumn::make('slug')
->label(__('admin.task_collections.table.slug'))
->toggleable()
->searchable(),
IconColumn::make('is_default')
->label(__('admin.task_collections.table.is_default'))
->boolean(),
TextColumn::make('position')
->label(__('admin.task_collections.table.position'))
->sortable(),
TextColumn::make('tasks_count')
->label(__('admin.task_collections.table.tasks'))
->sortable(),
TextColumn::make('events_count')
->label(__('admin.task_collections.table.events'))
->sortable(),
])
->filters([
SelectFilter::make('event_type_id')
->label(__('admin.task_collections.table.event_type'))
->relationship(
'eventType',
'name',
fn (Builder $query): Builder => $query->orderBy('name->de')
)
->getOptionLabelFromRecordUsing(fn (EventType $record) => is_array($record->name) ? ($record->name['de'] ?? $record->name['en'] ?? __('admin.common.unnamed')) : $record->name),
SelectFilter::make('is_default')
->label(__('admin.task_collections.table.is_default'))
->options([
'1' => __('admin.common.yes'),
'0' => __('admin.common.no'),
]),
])
->recordActions([
Actions\EditAction::make()
->mutateDataUsing(fn (array $data, TaskCollection $record): array => static::normalizeData($data, $record))
->after(fn (array $data, TaskCollection $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
'updated',
$record,
SuperAdminAuditLogger::fieldsMetadata($data),
static::class
)),
Actions\DeleteAction::make()
->after(fn (TaskCollection $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
'deleted',
$record,
source: static::class
)),
])
->bulkActions([
Actions\DeleteBulkAction::make()
->after(function (Collection $records): void {
$logger = app(SuperAdminAuditLogger::class);
foreach ($records as $record) {
$logger->recordModelMutation(
'deleted',
$record,
source: static::class
);
}
}),
]);
}
public static function getNavigationLabel(): string
{
return __('admin.task_collections.menu');
}
public static function getNavigationGroup(): UnitEnum|string|null
{
return __('admin.nav.curation');
}
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()
->whereNull('tenant_id')
->with('eventType')
->withCount(['tasks', 'events']);
}
/**
* @param array<string, mixed> $data
*/
public static function normalizeData(array $data, ?TaskCollection $record = null): array
{
$data['tenant_id'] = null;
$data['slug'] = static::resolveSlug($data, $record);
return $data;
}
/**
* @param array<string, mixed> $data
*/
protected static function resolveSlug(array $data, ?TaskCollection $record = null): string
{
$rawSlug = trim((string) ($data['slug'] ?? ''));
$translations = Arr::wrap($data['name_translations'] ?? []);
$fallbackName = (string) ($translations['en'] ?? $translations['de'] ?? '');
$base = $rawSlug !== '' ? $rawSlug : $fallbackName;
$slugBase = Str::slug($base) ?: 'collection';
$query = TaskCollection::query()->where('slug', $slugBase);
if ($record) {
$query->whereKeyNot($record->getKey());
}
if (! $query->exists()) {
return $slugBase;
}
do {
$candidate = $slugBase.'-'.Str::random(4);
$candidateQuery = TaskCollection::query()->where('slug', $candidate);
if ($record) {
$candidateQuery->whereKeyNot($record->getKey());
}
} while ($candidateQuery->exists());
return $candidate;
}
/**
* @param array<string, string>|null $translations
*/
protected static function formatTranslation(?array $translations): string
{
if (! is_array($translations)) {
return '';
}
$locale = app()->getLocale();
return $translations[$locale]
?? ($translations['de'] ?? ($translations['en'] ?? Arr::first($translations) ?? ''));
}
public static function getPages(): array
{
return [
'index' => ManageTaskCollections::route('/'),
];
}
public static function getRelations(): array
{
return [
TasksRelationManager::class,
];
}
}

View File

@@ -143,7 +143,7 @@ class PackageResource extends Resource
->nullable()
->visible(fn ($get) => $get('type') === 'reseller'),
Toggle::make('watermark_allowed')
->label('Wasserzeichen erlaubt')
->label('Eigenes Wasserzeichen erlaubt')
->default(true),
Toggle::make('branding_allowed')
->label('Eigenes Branding erlaubt')

View File

@@ -27,7 +27,7 @@ class WatermarkSettingsPage extends Page
return __('admin.nav.branding');
}
public ?string $asset = null;
public $asset = [];
public string $position = 'bottom-right';
@@ -46,7 +46,7 @@ class WatermarkSettingsPage extends Page
$settings = WatermarkSetting::query()->first();
if ($settings) {
$this->asset = $settings->asset;
$this->asset = $settings->asset ? [$settings->asset] : [];
$this->position = $settings->position;
$this->opacity = (float) $settings->opacity;
$this->scale = (float) $settings->scale;
@@ -119,8 +119,14 @@ class WatermarkSettingsPage extends Page
{
$this->validate();
$state = $this->form->getState();
$asset = $state['asset'] ?? $this->asset;
if (is_array($asset)) {
$asset = $asset[0] ?? null;
}
$settings = WatermarkSetting::query()->firstOrNew([]);
$settings->asset = $this->asset;
$settings->asset = $asset;
$settings->position = $this->position;
$settings->opacity = $this->opacity;
$settings->scale = $this->scale;

View File

@@ -1025,10 +1025,10 @@ class EventPublicController extends BaseController
private function resolveBrandingPayload(Event $event): array
{
$defaults = [
'primary' => '#f43f5e',
'secondary' => '#fb7185',
'background' => '#ffffff',
'surface' => '#ffffff',
'primary' => '#FF5A5F',
'secondary' => '#FFF8F5',
'background' => '#FFF8F5',
'surface' => '#FFF8F5',
'font' => null,
'size' => 'm',
'logo_position' => 'left',
@@ -1298,7 +1298,7 @@ class EventPublicController extends BaseController
);
}
$diskName = config('filesystems.default', 'public');
$diskName = 'public';
try {
$storage = Storage::disk($diskName);

View File

@@ -277,13 +277,13 @@ class PackageController extends Controller
'purchased_at' => now(),
]);
} else {
// Reseller subscription
// Partner / reseller Event-Kontingent package
\App\Models\TenantPackage::create([
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'price' => $package->price,
'purchased_at' => now(),
'expires_at' => now()->addYear(),
'expires_at' => null,
'active' => true,
]);
}

View File

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

View File

@@ -19,6 +19,8 @@ use App\Models\Tenant;
use App\Models\User;
use App\Services\EventJoinTokenService;
use App\Support\ApiError;
use App\Support\TenantMemberPermissions;
use App\Support\WatermarkConfigResolver;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
@@ -83,6 +85,8 @@ class EventController extends Controller
public function store(EventStoreRequest $request): JsonResponse
{
TenantMemberPermissions::ensureTenantPermission($request, 'events:manage');
$tenant = $request->attributes->get('tenant');
if (! $tenant instanceof Tenant) {
$tenantId = $request->attributes->get('tenant_id');
@@ -99,6 +103,9 @@ class EventController extends Controller
$requestedPackageId = $isSuperAdmin ? $request->integer('package_id') : null;
unset($validated['package_id']);
$requestedServiceSlug = $request->input('service_package_slug');
$requestedServiceSlug = is_string($requestedServiceSlug) && $requestedServiceSlug !== '' ? $requestedServiceSlug : null;
unset($validated['service_package_slug']);
$tenantPackage = $tenant->tenantPackages()
->with('package')
@@ -116,6 +123,18 @@ class EventController extends Controller
$package = $this->resolveOwnerPackage();
}
$billingTenantPackage = null;
if (! $package) {
$billingTenantPackage = $requestedServiceSlug
? $tenant->getActiveResellerPackageFor($requestedServiceSlug)
: $tenant->getActiveResellerPackage();
if ($billingTenantPackage && $billingTenantPackage->package) {
$package = $billingTenantPackage->package;
$requestedServiceSlug = $requestedServiceSlug ?: $package->included_package_slug;
}
}
if (! $package && $tenantPackage) {
$package = $tenantPackage->package ?? Package::query()->find($tenantPackage->package_id);
}
@@ -126,6 +145,11 @@ class EventController extends Controller
]);
}
$billingIsReseller = $package->isReseller();
$eventServicePackage = $billingIsReseller
? $this->resolveResellerEventPackageForSlug($requestedServiceSlug ?: $package->included_package_slug)
: $package;
$requiresWaiver = $package->isEndcustomer();
$latestPurchase = $requiresWaiver ? $this->resolveLatestPackagePurchase($tenant, $package) : null;
$existingWaiver = $latestPurchase ? data_get($latestPurchase->metadata, 'consents.digital_content_waiver_at') : null;
@@ -161,8 +185,8 @@ class EventController extends Controller
unset($eventData['features']);
}
$settings['branding_allowed'] = $package->branding_allowed !== false;
$settings['watermark_allowed'] = $package->watermark_allowed !== false;
$settings['branding_allowed'] = $eventServicePackage->branding_allowed !== false;
$settings['watermark_allowed'] = $eventServicePackage->watermark_allowed !== false;
$eventData['settings'] = $settings;
@@ -190,21 +214,23 @@ class EventController extends Controller
$eventData = Arr::only($eventData, $allowed);
$event = DB::transaction(function () use ($tenant, $eventData, $package, $isSuperAdmin) {
$event = DB::transaction(function () use ($tenant, $eventData, $eventServicePackage, $billingIsReseller, $isSuperAdmin) {
$event = Event::create($eventData);
EventPackage::create([
'event_id' => $event->id,
'package_id' => $package->id,
'purchased_price' => $package->price,
'package_id' => $eventServicePackage->id,
'purchased_price' => $billingIsReseller ? 0 : $eventServicePackage->price,
'purchased_at' => now(),
'gallery_expires_at' => $package->gallery_days ? now()->addDays($package->gallery_days) : null,
'gallery_expires_at' => $eventServicePackage->gallery_days
? now()->addDays($eventServicePackage->gallery_days)
: null,
]);
if ($package->isReseller() && ! $isSuperAdmin) {
if ($billingIsReseller && ! $isSuperAdmin) {
$note = sprintf('Event #%d created (%s)', $event->id, $event->name);
if (! $tenant->consumeEventAllowance(1, 'event.create', $note)) {
if (! $tenant->consumeEventAllowanceFor($eventServicePackage->slug, 1, 'event.create', $note)) {
throw new HttpException(402, 'Insufficient package allowance.');
}
}
@@ -227,6 +253,47 @@ class EventController extends Controller
], 201);
}
private function resolveResellerDefaultEventPackage(): Package
{
return $this->resolveResellerEventPackageForSlug('standard');
}
private function resolveResellerEventPackageForSlug(?string $slug): Package
{
if (is_string($slug) && $slug !== '') {
$match = Package::query()
->where('type', 'endcustomer')
->where('slug', $slug)
->first();
if ($match) {
return $match;
}
}
$default = Package::query()
->where('type', 'endcustomer')
->where('slug', 'standard')
->first();
if ($default) {
return $default;
}
$fallback = Package::query()
->where('type', 'endcustomer')
->orderBy('price')
->first();
if (! $fallback) {
throw ValidationException::withMessages([
'package_id' => __('Aktuell ist kein Endkunden-Paket verfügbar. Bitte kontaktiere den Support.'),
]);
}
return $fallback;
}
private function resolveLatestPackagePurchase(Tenant $tenant, Package $package): ?PackagePurchase
{
return PackagePurchase::query()
@@ -320,14 +387,24 @@ class EventController extends Controller
);
}
TenantMemberPermissions::ensureEventPermission($request, $event, 'events:manage');
$validated = $request->validated();
$nameProvided = array_key_exists('name', $validated);
$validated = array_merge([
'name' => $event->name,
'event_type_id' => $event->event_type_id,
'event_date' => $event->date?->toDateString(),
'status' => $event->status,
], $validated);
if (isset($validated['event_date'])) {
$validated['date'] = $validated['event_date'];
unset($validated['event_date']);
}
if ($validated['name'] !== $event->name) {
if ($nameProvided && $validated['name'] !== $event->name) {
$validated['slug'] = $this->generateUniqueSlug($validated['name'], $tenantId, $event->id);
}
@@ -338,6 +415,7 @@ class EventController extends Controller
$package = $event->eventPackage?->package;
$brandingAllowed = optional($package)->branding_allowed !== false;
$watermarkAllowed = optional($package)->watermark_allowed !== false;
$watermarkRemovalAllowed = WatermarkConfigResolver::determineRemovalAllowed($event);
if (isset($validated['settings']) && is_array($validated['settings'])) {
$validated['settings'] = array_merge($event->settings ?? [], $validated['settings']);
@@ -347,32 +425,37 @@ class EventController extends Controller
$validated['settings']['branding_allowed'] = $brandingAllowed;
$validated['settings']['watermark_allowed'] = $watermarkAllowed;
$validated['settings']['watermark_removal_allowed'] = $watermarkRemovalAllowed;
$settings = $validated['settings'];
$branding = Arr::get($settings, 'branding', []);
$watermark = Arr::get($settings, 'watermark', []);
$existingWatermark = is_array($watermark) ? $watermark : [];
if (is_array($branding)) {
$settings['branding'] = $this->normalizeBrandingSettings($branding, $event, $brandingAllowed);
}
if (is_array($watermark)) {
$mode = $watermark['mode'] ?? 'base';
$policy = $watermarkAllowed ? 'basic' : 'none';
if (! $watermarkAllowed) {
$mode = 'off';
$mode = 'base';
} elseif (! $brandingAllowed) {
$mode = 'base';
} elseif ($mode === 'off' && $policy === 'basic') {
} elseif ($mode === 'off' && ! $watermarkRemovalAllowed) {
$mode = 'base';
}
$assetPath = $watermark['asset'] ?? null;
$assetDataUrl = $watermark['asset_data_url'] ?? null;
if (! $watermarkAllowed) {
if (! $watermarkAllowed || $mode === 'off') {
$assetPath = null;
}
if ($assetDataUrl && $mode === 'custom' && $brandingAllowed) {
if (! preg_match('/^data:image\\/(png|webp|jpe?g);base64,(.+)$/i', $assetDataUrl, $matches)) {
if (! preg_match('/^data:image\\/(png|webp|jpe?g|svg\\+xml);base64,(.+)$/i', $assetDataUrl, $matches)) {
throw ValidationException::withMessages([
'settings.watermark.asset_data_url' => __('Ungültiges Wasserzeichen-Bild.'),
]);
@@ -392,7 +475,12 @@ class EventController extends Controller
]);
}
$extension = str_starts_with(strtolower($matches[1]), 'jp') ? 'jpg' : strtolower($matches[1]);
$mime = strtolower($matches[1]);
$extension = match (true) {
str_starts_with($mime, 'jp') => 'jpg',
str_starts_with($mime, 'svg') => 'svg',
default => $mime,
};
$path = sprintf('branding/watermarks/event-%s.%s', $event->id, $extension);
Storage::disk('public')->put($path, $decoded);
$assetPath = $path;
@@ -442,6 +530,68 @@ class EventController extends Controller
]);
}
/**
* @param array<string, mixed> $branding
* @return array<string, mixed>
*/
private function normalizeBrandingSettings(array $branding, Event $event, bool $brandingAllowed): array
{
$logoDataUrl = $branding['logo_data_url'] ?? null;
if (! $brandingAllowed) {
unset($branding['logo_data_url']);
return $branding;
}
if (! is_string($logoDataUrl) || trim($logoDataUrl) === '') {
unset($branding['logo_data_url']);
return $branding;
}
if (! preg_match('/^data:image\\/(png|webp|jpe?g);base64,(.+)$/i', $logoDataUrl, $matches)) {
throw ValidationException::withMessages([
'settings.branding.logo_data_url' => __('Ungültiges Branding-Logo.'),
]);
}
$decoded = base64_decode($matches[2], true);
if ($decoded === false) {
throw ValidationException::withMessages([
'settings.branding.logo_data_url' => __('Branding-Logo konnte nicht gelesen werden.'),
]);
}
if (strlen($decoded) > 1024 * 1024) { // 1 MB
throw ValidationException::withMessages([
'settings.branding.logo_data_url' => __('Branding-Logo ist zu groß (max. 1 MB).'),
]);
}
$extension = str_starts_with(strtolower($matches[1]), 'jp') ? 'jpg' : strtolower($matches[1]);
$path = sprintf('branding/logos/event-%s.%s', $event->id, $extension);
Storage::disk('public')->put($path, $decoded);
$branding['logo_url'] = $path;
$branding['logo_mode'] = 'upload';
$branding['logo_value'] = $path;
$logo = $branding['logo'] ?? [];
if (! is_array($logo)) {
$logo = [];
}
$logo['mode'] = 'upload';
$logo['value'] = $path;
$branding['logo'] = $logo;
unset($branding['logo_data_url']);
return $branding;
}
public function destroy(Request $request, Event $event): JsonResponse
{
$tenantId = $request->attributes->get('tenant_id');
@@ -456,6 +606,8 @@ class EventController extends Controller
);
}
TenantMemberPermissions::ensureEventPermission($request, $event, 'events:manage');
$event->delete();
return response()->json([

View File

@@ -11,6 +11,7 @@ use App\Models\Event;
use App\Models\GuestNotification;
use App\Models\GuestPolicySetting;
use App\Services\GuestNotificationService;
use App\Support\TenantMemberPermissions;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
@@ -23,6 +24,7 @@ class EventGuestNotificationController extends Controller
public function index(Request $request, Event $event): JsonResponse
{
$this->assertEventTenant($request, $event);
TenantMemberPermissions::ensureEventPermission($request, $event, 'guest-notifications:manage');
$limit = max(1, min(100, (int) $request->integer('limit', 25)));
@@ -38,6 +40,7 @@ class EventGuestNotificationController extends Controller
public function store(BroadcastGuestNotificationRequest $request, Event $event): JsonResponse
{
$this->assertEventTenant($request, $event);
TenantMemberPermissions::ensureEventPermission($request, $event, 'guest-notifications:manage');
$data = $request->validated();

View File

@@ -7,6 +7,7 @@ use App\Http\Resources\Tenant\EventJoinTokenResource;
use App\Models\Event;
use App\Models\EventJoinToken;
use App\Services\EventJoinTokenService;
use App\Support\TenantMemberPermissions;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
@@ -19,7 +20,7 @@ class EventJoinTokenController extends Controller
public function index(Request $request, Event $event): AnonymousResourceCollection
{
$this->authorizeEvent($request, $event);
$this->authorizeEvent($request, $event, 'join-tokens:manage');
$tokens = $event->joinTokens()
->orderByDesc('created_at')
@@ -30,7 +31,7 @@ class EventJoinTokenController extends Controller
public function store(Request $request, Event $event): JsonResponse
{
$this->authorizeEvent($request, $event);
$this->authorizeEvent($request, $event, 'join-tokens:manage');
$validated = $this->validatePayload($request);
@@ -45,7 +46,7 @@ class EventJoinTokenController extends Controller
public function update(Request $request, Event $event, EventJoinToken $joinToken): EventJoinTokenResource
{
$this->authorizeEvent($request, $event);
$this->authorizeEvent($request, $event, 'join-tokens:manage');
if ($joinToken->event_id !== $event->id) {
abort(404);
@@ -89,7 +90,7 @@ class EventJoinTokenController extends Controller
public function destroy(Request $request, Event $event, EventJoinToken $joinToken): EventJoinTokenResource
{
$this->authorizeEvent($request, $event);
$this->authorizeEvent($request, $event, 'join-tokens:manage');
if ($joinToken->event_id !== $event->id) {
abort(404);
@@ -101,13 +102,17 @@ class EventJoinTokenController extends Controller
return new EventJoinTokenResource($token);
}
private function authorizeEvent(Request $request, Event $event): void
private function authorizeEvent(Request $request, Event $event, ?string $permission = null): void
{
$tenantId = $request->attributes->get('tenant_id');
if ($event->tenant_id !== $tenantId) {
abort(404, 'Event not found');
}
if ($permission) {
TenantMemberPermissions::ensureEventPermission($request, $event, $permission);
}
}
private function validatePayload(Request $request, bool $partial = false): array

View File

@@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
use App\Models\Event;
use App\Models\EventJoinToken;
use App\Support\JoinTokenLayoutRegistry;
use App\Support\TenantMemberPermissions;
use Dompdf\Dompdf;
use Dompdf\Options;
use Illuminate\Http\Request;
@@ -28,6 +29,7 @@ class EventJoinTokenLayoutController extends Controller
public function index(Request $request, Event $event, EventJoinToken $joinToken)
{
$this->ensureBelongsToEvent($event, $joinToken);
TenantMemberPermissions::ensureEventPermission($request, $event, 'join-tokens:manage');
$layouts = JoinTokenLayoutRegistry::toResponse(function (string $layoutId, string $format) use ($event, $joinToken) {
return route('api.v1.tenant.events.join-tokens.layouts.download', [
@@ -46,6 +48,7 @@ class EventJoinTokenLayoutController extends Controller
public function download(Request $request, Event $event, EventJoinToken $joinToken, string $layout, string $format)
{
$this->ensureBelongsToEvent($event, $joinToken);
TenantMemberPermissions::ensureEventPermission($request, $event, 'join-tokens:manage');
$layoutConfig = JoinTokenLayoutRegistry::find($layout);

View File

@@ -9,6 +9,7 @@ use App\Models\Event;
use App\Models\EventMember;
use App\Models\Tenant;
use App\Models\User;
use App\Support\TenantMemberPermissions;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -22,6 +23,7 @@ class EventMemberController extends Controller
public function index(Request $request, Event $event): JsonResponse
{
$this->assertEventTenant($request, $event);
TenantMemberPermissions::ensureEventPermission($request, $event, 'members:manage');
/** @var LengthAwarePaginator $members */
$members = $event->members()
@@ -34,6 +36,7 @@ class EventMemberController extends Controller
public function store(EventMemberInviteRequest $request, Event $event): JsonResponse
{
$this->assertEventTenant($request, $event);
TenantMemberPermissions::ensureEventPermission($request, $event, 'members:manage');
$data = $request->validated();
$tenant = $this->resolveTenantFromRequest($request);
@@ -92,6 +95,7 @@ class EventMemberController extends Controller
public function destroy(Request $request, Event $event, EventMember $member): JsonResponse
{
$this->assertEventTenant($request, $event);
TenantMemberPermissions::ensureEventPermission($request, $event, 'members:manage');
if ((int) $member->event_id !== (int) $event->id) {
throw ValidationException::withMessages([

View File

@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\Tenant;
use App\Http\Controllers\Controller;
use App\Models\Event;
use App\Support\TenantMemberPermissions;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use SimpleSoftwareIO\QrCode\Facades\QrCode;
@@ -13,6 +14,7 @@ class LiveShowLinkController extends Controller
public function show(Request $request, Event $event): JsonResponse
{
$this->authorizeEvent($request, $event);
TenantMemberPermissions::ensureEventPermission($request, $event, 'live-show:manage');
$token = $event->ensureLiveShowToken();
@@ -24,6 +26,7 @@ class LiveShowLinkController extends Controller
public function rotate(Request $request, Event $event): JsonResponse
{
$this->authorizeEvent($request, $event);
TenantMemberPermissions::ensureEventPermission($request, $event, 'live-show:manage');
$token = $event->rotateLiveShowToken();

View File

@@ -10,6 +10,7 @@ use App\Http\Resources\Tenant\PhotoResource;
use App\Models\Event;
use App\Models\Photo;
use App\Support\ApiError;
use App\Support\TenantMemberPermissions;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
@@ -23,6 +24,7 @@ class LiveShowPhotoController extends Controller
$event = Event::where('slug', $eventSlug)
->where('tenant_id', $tenantId)
->firstOrFail();
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
$liveStatus = $request->string('live_status', 'pending')->toString();
$perPage = (int) $request->input('per_page', 20);
@@ -51,6 +53,7 @@ class LiveShowPhotoController extends Controller
$event = Event::where('slug', $eventSlug)
->where('tenant_id', $tenantId)
->firstOrFail();
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
if ($photo->event_id !== $event->id) {
return ApiError::response(
@@ -94,6 +97,7 @@ class LiveShowPhotoController extends Controller
$event = Event::where('slug', $eventSlug)
->where('tenant_id', $tenantId)
->firstOrFail();
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
if ($photo->event_id !== $event->id) {
return ApiError::response(
@@ -146,6 +150,7 @@ class LiveShowPhotoController extends Controller
$event = Event::where('slug', $eventSlug)
->where('tenant_id', $tenantId)
->firstOrFail();
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
if ($photo->event_id !== $event->id) {
return ApiError::response(
@@ -173,6 +178,7 @@ class LiveShowPhotoController extends Controller
$event = Event::where('slug', $eventSlug)
->where('tenant_id', $tenantId)
->firstOrFail();
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
if ($photo->event_id !== $event->id) {
return ApiError::response(

View File

@@ -14,6 +14,7 @@ use App\Services\Packages\PackageUsageTracker;
use App\Services\Storage\EventStorageManager;
use App\Support\ApiError;
use App\Support\ImageHelper;
use App\Support\TenantMemberPermissions;
use App\Support\UploadStream;
use App\Support\WatermarkConfigResolver;
use Illuminate\Http\JsonResponse;
@@ -524,15 +525,8 @@ class PhotoController extends Controller
'alt_text' => ['sometimes', 'string', 'max:255'],
]);
// Only tenant admins can moderate
if (isset($validated['status']) && ! $this->tokenHasScope($request, 'tenant-admin')) {
return ApiError::response(
'insufficient_scope',
'Insufficient Scopes',
'You are not allowed to moderate photos for this event.',
Response::HTTP_FORBIDDEN,
['required_scope' => 'tenant-admin']
);
if (isset($validated['status'])) {
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
}
$photo->update($validated);
@@ -634,6 +628,7 @@ class PhotoController extends Controller
$event = Event::where('slug', $eventSlug)
->where('tenant_id', $tenantId)
->firstOrFail();
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
if ($photo->event_id !== $event->id) {
return ApiError::response(
@@ -657,6 +652,7 @@ class PhotoController extends Controller
$event = Event::where('slug', $eventSlug)
->where('tenant_id', $tenantId)
->firstOrFail();
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
if ($photo->event_id !== $event->id) {
return ApiError::response(
@@ -680,6 +676,7 @@ class PhotoController extends Controller
$event = Event::where('slug', $eventSlug)
->where('tenant_id', $tenantId)
->firstOrFail();
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
$request->validate([
'photo_ids' => 'required|array',
@@ -725,6 +722,7 @@ class PhotoController extends Controller
$event = Event::where('slug', $eventSlug)
->where('tenant_id', $tenantId)
->firstOrFail();
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
$request->validate([
'photo_ids' => 'required|array',

View File

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

View File

@@ -113,8 +113,8 @@ class SettingsController extends Controller
$defaultSettings = [
'branding' => [
'logo_url' => null,
'primary_color' => '#3B82F6',
'secondary_color' => '#1F2937',
'primary_color' => '#FF5A5F',
'secondary_color' => '#FFF8F5',
'font_family' => 'Inter, sans-serif',
],
'features' => [

View File

@@ -11,6 +11,7 @@ use App\Models\Task;
use App\Models\TaskCollection;
use App\Models\Tenant;
use App\Support\ApiError;
use App\Support\TenantMemberPermissions;
use App\Support\TenantRequestResolver;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -66,6 +67,8 @@ class TaskController extends Controller
*/
public function store(TaskStoreRequest $request): JsonResponse
{
TenantMemberPermissions::ensureTenantPermission($request, 'tasks:manage');
$tenant = $this->currentTenant($request);
$collectionId = $request->input('collection_id');
$collection = $collectionId ? $this->resolveAccessibleCollection($request, $collectionId) : null;
@@ -107,6 +110,8 @@ class TaskController extends Controller
*/
public function update(TaskUpdateRequest $request, Task $task): JsonResponse
{
TenantMemberPermissions::ensureTenantPermission($request, 'tasks:manage');
$tenant = $this->currentTenant($request);
if ($task->tenant_id !== $tenant->id) {
@@ -138,6 +143,8 @@ class TaskController extends Controller
*/
public function destroy(Request $request, Task $task): JsonResponse
{
TenantMemberPermissions::ensureTenantPermission($request, 'tasks:manage');
if ($task->tenant_id !== $this->currentTenant($request)->id) {
abort(404, 'Task nicht gefunden.');
}
@@ -154,6 +161,8 @@ class TaskController extends Controller
*/
public function assignToEvent(Request $request, Task $task, Event $event): JsonResponse
{
TenantMemberPermissions::ensureEventPermission($request, $event, 'tasks:manage');
$tenantId = $this->currentTenant($request)->id;
if (($task->tenant_id && $task->tenant_id !== $tenantId) || $event->tenant_id !== $tenantId) {
@@ -176,6 +185,8 @@ class TaskController extends Controller
*/
public function bulkAssignToEvent(Request $request, Event $event): JsonResponse
{
TenantMemberPermissions::ensureEventPermission($request, $event, 'tasks:manage');
$tenantId = $this->currentTenant($request)->id;
if ($event->tenant_id !== $tenantId) {
@@ -230,6 +241,8 @@ class TaskController extends Controller
public function bulkDetachFromEvent(Request $request, Event $event): JsonResponse
{
TenantMemberPermissions::ensureEventPermission($request, $event, 'tasks:manage');
$tenantId = $this->currentTenant($request)->id;
if ($event->tenant_id !== $tenantId) {
@@ -256,6 +269,8 @@ class TaskController extends Controller
public function reorderForEvent(Request $request, Event $event): JsonResponse
{
TenantMemberPermissions::ensureEventPermission($request, $event, 'tasks:manage');
$tenantId = $this->currentTenant($request)->id;
if ($event->tenant_id !== $tenantId) {

View File

@@ -39,7 +39,9 @@ class TenantPackageController extends Controller
$activePackage = $tenant->activeResellerPackage?->load('package');
if ($activePackage instanceof TenantPackage) {
if (! ($activePackage instanceof TenantPackage)) {
$activePackage = $packages->firstWhere('active', true);
} else {
$this->hydratePackageSnapshot($activePackage, $usageEventPackage);
}
@@ -60,6 +62,7 @@ class TenantPackageController extends Controller
$pkg?->limits ?? [],
$this->buildUsageSnapshot($eventPackage),
[
'included_package_slug' => $pkg?->included_package_slug,
'branding_allowed' => $pkg?->branding_allowed,
'watermark_allowed' => $pkg?->watermark_allowed,
'features' => $pkg?->features ?? [],

View File

@@ -47,6 +47,15 @@ class AuthenticatedSessionController extends Controller
$user = Auth::user();
if ($user && $user->email_verified_at === null) {
$intended = $request->session()->get('url.intended');
$intended = is_string($intended) ? trim($intended) : null;
if ($this->isVerificationLink($intended)) {
$request->session()->forget('url.intended');
return Inertia::location($intended);
}
return Inertia::location(route('verification.notice'));
}
@@ -116,6 +125,29 @@ class AuthenticatedSessionController extends Controller
);
}
private function isVerificationLink(?string $target): bool
{
if (! is_string($target) || trim($target) === '') {
return false;
}
$path = trim($target);
if (str_starts_with($path, '/verify-email/')) {
return true;
}
$parsed = parse_url($path);
if ($parsed === false) {
return false;
}
$path = $parsed['path'] ?? '';
return $path !== '' && str_starts_with($path, '/verify-email/');
}
private function decodeReturnTo(string $value, Request $request): ?string
{
$candidate = $this->decodeBase64Url($value) ?? $value;

View File

@@ -108,8 +108,8 @@ class CheckoutController extends Controller
'settings' => json_encode([
'branding' => [
'logo_url' => null,
'primary_color' => '#3B82F6',
'secondary_color' => '#1F2937',
'primary_color' => '#FF5A5F',
'secondary_color' => '#FFF8F5',
'font_family' => 'Inter, sans-serif',
],
'features' => [

View File

@@ -146,8 +146,8 @@ class CheckoutGoogleController extends Controller
'settings' => json_encode([
'branding' => [
'logo_url' => null,
'primary_color' => '#3B82F6',
'secondary_color' => '#1F2937',
'primary_color' => '#FF5A5F',
'secondary_color' => '#FFF8F5',
'font_family' => 'Inter, sans-serif',
],
'features' => [

View File

@@ -58,8 +58,8 @@ class TestGuestEventController extends Controller
'date' => ($validated['date'] ?? Carbon::now()->addWeeks(2)->toDateString()),
'settings' => [
'branding' => [
'primary_color' => '#f43f5e',
'secondary_color' => '#fb7185',
'primary_color' => '#FF5A5F',
'secondary_color' => '#FFF8F5',
'font_family' => 'Inter, sans-serif',
],
],

View File

@@ -28,7 +28,12 @@ class CreditCheckMiddleware
}
if ($this->requiresCredits($request) && ! $this->shouldBypassCreditCheck($request, $tenant)) {
$violation = $this->limitEvaluator->assessEventCreation($tenant);
$includedSlug = $request->input('service_package_slug');
$violation = $this->limitEvaluator->assessEventCreation(
$tenant,
is_string($includedSlug) && $includedSlug !== '' ? $includedSlug : null
);
if ($violation !== null) {
return ApiError::response(

View File

@@ -73,7 +73,12 @@ class PackageMiddleware
private function detectViolation(Request $request, Tenant $tenant): ?array
{
if ($request->routeIs('api.v1.tenant.events.store')) {
return $this->limitEvaluator->assessEventCreation($tenant);
$includedSlug = $request->input('service_package_slug');
return $this->limitEvaluator->assessEventCreation(
$tenant,
is_string($includedSlug) && $includedSlug !== '' ? $includedSlug : null
);
}
if ($request->routeIs('api.v1.tenant.events.photos.store')) {

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

@@ -23,14 +23,21 @@ class EventStoreRequest extends FormRequest
public function rules(): array
{
$tenantId = request()->attributes->get('tenant_id');
$creating = $this->isMethod('post');
return [
'name' => ['required', 'string', 'max:255'],
'name' => [$creating ? 'required' : 'sometimes', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'event_date' => ['required', 'date', 'after_or_equal:today'],
'event_date' => $creating ? ['required', 'date', 'after_or_equal:today'] : ['sometimes', 'date'],
'location' => ['nullable', 'string', 'max:255'],
'event_type_id' => ['required', 'exists:event_types,id'],
'event_type_id' => [$creating ? 'required' : 'sometimes', 'exists:event_types,id'],
'package_id' => ['nullable', 'integer', 'exists:packages,id'],
'service_package_slug' => [
'nullable',
'string',
'max:64',
Rule::exists('packages', 'slug')->where('type', 'endcustomer'),
],
'max_participants' => ['nullable', 'integer', 'min:1', 'max:10000'],
'public_url' => ['nullable', 'url', 'max:500'],
'custom_domain' => ['nullable', 'string', 'max:255'],
@@ -43,9 +50,12 @@ class EventStoreRequest extends FormRequest
'features' => ['nullable', 'array'],
'features.*' => ['string'],
'settings' => ['nullable', 'array'],
'settings.location' => ['nullable', 'string', 'max:255'],
'settings.branding' => ['nullable', 'array'],
'settings.branding.*' => ['nullable'],
'settings.engagement_mode' => ['nullable', Rule::in(['tasks', 'photo_only'])],
'settings.guest_downloads_enabled' => ['nullable', 'boolean'],
'settings.guest_sharing_enabled' => ['nullable', 'boolean'],
'settings.guest_upload_visibility' => ['nullable', Rule::in(['review', 'immediate'])],
'settings.live_show' => ['nullable', 'array'],
'settings.live_show.moderation_mode' => ['nullable', Rule::in(['off', 'manual', 'trusted_only'])],

View File

@@ -2,11 +2,16 @@
namespace App\Http\Resources\Tenant;
use App\Models\WatermarkSetting;
use App\Services\Packages\PackageLimitEvaluator;
use App\Support\TenantMemberPermissions;
use App\Support\WatermarkConfigResolver;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Http\Resources\MissingValue;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\URL;
use function app;
@@ -17,7 +22,14 @@ class EventResource extends JsonResource
$tenantId = $request->attributes->get('tenant_id');
$showSensitive = $this->tenant_id === $tenantId;
$settings = is_array($this->settings) ? $this->settings : [];
$settings = $this->attachWatermarkAssetUrl($settings);
$eventPackage = null;
$memberPermissions = null;
$user = $request->user();
if ($user && $user->role === 'member') {
$memberPermissions = TenantMemberPermissions::resolveEventPermissions($request, $this->resource);
}
if ($this->relationLoaded('eventPackages')) {
$related = $this->getRelation('eventPackages');
@@ -38,6 +50,8 @@ class EventResource extends JsonResource
$limitEvaluator = app()->make(PackageLimitEvaluator::class);
}
$settings['watermark_removal_allowed'] = WatermarkConfigResolver::determineRemovalAllowed($this->resource);
return [
'id' => $this->id,
'name' => $this->name,
@@ -86,9 +100,89 @@ class EventResource extends JsonResource
? $limitEvaluator->summarizeEventPackage($eventPackage)
: null,
'addons' => $eventPackage ? $this->formatAddons($eventPackage) : [],
'member_permissions' => $memberPermissions,
];
}
/**
* @param array<string, mixed> $settings
* @return array<string, mixed>
*/
protected function attachWatermarkAssetUrl(array $settings): array
{
$watermark = Arr::get($settings, 'watermark');
$base = config('watermark.base', []);
$base = is_array($base) ? $base : [];
$baseSetting = null;
if (class_exists(WatermarkSetting::class) && Schema::hasTable('watermark_settings')) {
try {
$baseSetting = WatermarkSetting::query()->first();
} catch (\Throwable) {
$baseSetting = null;
}
}
if ($baseSetting) {
$base = array_merge($base, array_filter([
'asset' => $baseSetting->asset,
'position' => $baseSetting->position,
'opacity' => $baseSetting->opacity,
'scale' => $baseSetting->scale,
'padding' => $baseSetting->padding,
'offset_x' => $baseSetting->offset_x,
'offset_y' => $baseSetting->offset_y,
], static fn ($value) => $value !== null));
}
if (! is_array($watermark)) {
$watermark = [];
}
$mode = $watermark['mode'] ?? null;
if (! is_string($mode) || $mode === '') {
$mode = 'base';
$watermark['mode'] = $mode;
}
if ($mode !== 'off') {
foreach (['position', 'opacity', 'scale', 'padding', 'offset_x', 'offset_y'] as $key) {
if (! array_key_exists($key, $watermark) && array_key_exists($key, $base)) {
$watermark[$key] = $base[$key];
}
}
}
$asset = $watermark['asset'] ?? null;
if ((! is_string($asset) || $asset === '') && $mode !== 'off') {
$asset = $base['asset'] ?? null;
if (is_string($asset) && $asset !== '') {
$watermark['asset'] = $asset;
}
}
if (! is_string($asset) || $asset === '') {
$settings['watermark'] = $watermark;
return $settings;
}
$normalized = ltrim($asset, '/');
if (str_starts_with($normalized, 'storage/')) {
$normalized = substr($normalized, strlen('storage/'));
}
$watermark['asset_url'] = URL::temporarySignedRoute(
'api.v1.branding.asset',
now()->addSeconds(3600),
['path' => $normalized]
);
$settings['watermark'] = $watermark;
return $settings;
}
protected function formatAddons(?\App\Models\EventPackage $eventPackage): array
{
if (! $eventPackage) {

View File

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

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

@@ -19,6 +19,7 @@ class Package extends Model
'name_translations',
'slug',
'type',
'included_package_slug',
'price',
'max_photos',
'max_guests',

View File

@@ -100,7 +100,14 @@ class Tenant extends Model
public function activeResellerPackage(): HasOne
{
return $this->hasOne(TenantPackage::class)->where('active', true);
return $this->hasOne(TenantPackage::class)
->where('active', true)
->where(function ($query) {
$query->whereNull('expires_at')->orWhere('expires_at', '>', now());
})
->whereHas('package', fn ($query) => $query->withTrashed()->where('type', 'reseller'))
->orderBy('purchased_at')
->orderBy('id');
}
public function notificationLogs(): HasMany
@@ -151,6 +158,13 @@ class Tenant extends Model
return false;
}
public function hasEventAllowanceFor(?string $includedPackageSlug): bool
{
$package = $this->getActiveResellerPackageFor($includedPackageSlug);
return $package !== null && $package->canCreateEvent();
}
public function consumeEventAllowance(int $amount = 1, string $reason = 'event.create', ?string $note = null): bool
{
$package = $this->getActiveResellerPackage();
@@ -183,13 +197,68 @@ class Tenant extends Model
return false;
}
public function consumeEventAllowanceFor(?string $includedPackageSlug, int $amount = 1, string $reason = 'event.create', ?string $note = null): bool
{
$package = $this->getActiveResellerPackageFor($includedPackageSlug);
if ($package && $package->canCreateEvent()) {
$previousUsed = (int) $package->used_events;
$package->increment('used_events', $amount);
$package->refresh();
app(\App\Services\Packages\TenantUsageTracker::class)->recordEventUsage(
$package,
$previousUsed,
$amount
);
Log::info('Tenant package usage recorded', [
'tenant_id' => $this->id,
'tenant_package_id' => $package->id,
'used_events' => $package->used_events,
'amount' => $amount,
]);
return true;
}
Log::warning('Event allowance missing for tenant', [
'tenant_id' => $this->id,
'reason' => $reason,
'included_package_slug' => $includedPackageSlug,
]);
return false;
}
public function getActiveResellerPackage(): ?TenantPackage
{
return $this->activeResellerPackage()
->whereHas('package', fn ($query) => $query->withTrashed()->where('type', 'reseller'))
return $this->activeResellerPackage()->with('package')->first();
}
public function getActiveResellerPackageFor(?string $includedPackageSlug): ?TenantPackage
{
$query = $this->tenantPackages()
->with('package')
->where('active', true)
->orderByDesc('expires_at')
->first();
->where(function ($query) {
$query->whereNull('expires_at')->orWhere('expires_at', '>', now());
})
->whereHas('package', fn ($query) => $query->withTrashed()->where('type', 'reseller'))
->orderBy('purchased_at')
->orderBy('id');
if (is_string($includedPackageSlug) && $includedPackageSlug !== '') {
$query->whereHas('package', function ($query) use ($includedPackageSlug) {
$query->where('included_package_slug', $includedPackageSlug);
if ($includedPackageSlug === 'standard') {
$query->orWhereNull('included_package_slug');
}
});
}
return $query->first();
}
public function activeSubscription(): Attribute

View File

@@ -66,18 +66,30 @@ class TenantPackage extends Model
return false;
}
$maxEvents = $this->package->max_events_per_year ?? 0;
$maxEvents = $this->package->max_events_per_year;
if ($maxEvents === null) {
return true;
}
$maxEvents = max(0, (int) $maxEvents);
return $this->used_events < $maxEvents;
}
public function getRemainingEventsAttribute(): int
public function getRemainingEventsAttribute(): ?int
{
if (! $this->package->isReseller()) {
return 0;
}
$max = $this->package->max_events_per_year ?? 0;
$max = $this->package->max_events_per_year;
if ($max === null) {
return null;
}
$max = max(0, (int) $max);
return max(0, $max - $this->used_events);
}
@@ -94,9 +106,7 @@ class TenantPackage extends Model
$package = $tenantPackage->package;
if ($package && $package->isReseller()) {
if (! $tenantPackage->expires_at) {
$tenantPackage->expires_at = now()->addYear();
}
// Reseller packages represent prepaid Event-Kontingente and should not expire by default.
} elseif (! $tenantPackage->expires_at) {
$tenantPackage->expires_at = now()->addYear();
}

View File

@@ -94,18 +94,34 @@ class CheckoutAssignmentService
]
);
$tenantPackage = TenantPackage::updateOrCreate(
[
'tenant_id' => $tenant->id,
'package_id' => $package->id,
],
[
'price' => round($price, 2),
'active' => true,
'purchased_at' => now(),
'expires_at' => $this->resolveExpiry($package, $tenant),
]
);
if ($package->type === 'reseller') {
$tenantPackage = null;
if ($purchase->wasRecentlyCreated) {
$tenantPackage = TenantPackage::create([
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'price' => round($price, 2),
'active' => true,
'purchased_at' => now(),
'expires_at' => null,
'used_events' => 0,
]);
}
} else {
$tenantPackage = TenantPackage::updateOrCreate(
[
'tenant_id' => $tenant->id,
'package_id' => $package->id,
],
[
'price' => round($price, 2),
'active' => true,
'purchased_at' => now(),
'expires_at' => $this->resolveExpiry($package, $tenant),
]
);
}
if ($package->type !== 'reseller') {
$tenant->forceFill([
@@ -188,11 +204,7 @@ class CheckoutAssignmentService
protected function resolveExpiry(Package $package, Tenant $tenant)
{
if ($package->type === 'reseller') {
$hasActive = TenantPackage::where('tenant_id', $tenant->id)
->where('active', true)
->exists();
return $hasActive ? now()->addYear() : now()->addDays(14);
return null;
}
return now()->addYear();

View File

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

View File

@@ -11,7 +11,7 @@ class PackageLimitEvaluator
{
public function __construct(private readonly TenantUsageService $tenantUsageService) {}
public function assessEventCreation(Tenant $tenant): ?array
public function assessEventCreation(Tenant $tenant, ?string $includedPackageSlug = null): ?array
{
$hasEndcustomerPackage = $tenant->tenantPackages()
->where('active', true)
@@ -22,17 +22,66 @@ class PackageLimitEvaluator
return null;
}
if ($tenant->hasEventAllowance()) {
if ($tenant->hasEventAllowanceFor($includedPackageSlug)) {
return null;
}
$package = $tenant->getActiveResellerPackage();
$package = $tenant->getActiveResellerPackageFor($includedPackageSlug);
if (! $package) {
if ($includedPackageSlug) {
$hasAnyActive = $tenant->tenantPackages()
->where('active', true)
->where(function ($query) {
$query->whereNull('expires_at')->orWhere('expires_at', '>', now());
})
->whereHas('package', fn ($query) => $query->withTrashed()->where('type', 'reseller'))
->exists();
if ($hasAnyActive) {
return [
'code' => 'event_tier_unavailable',
'title' => __('api.packages.event_tier_unavailable.title'),
'message' => __('api.packages.event_tier_unavailable.message'),
'status' => 402,
'meta' => [
'scope' => 'events',
'requested_tier' => $includedPackageSlug,
],
];
}
}
$latestResellerPackage = $tenant->tenantPackages()
->with('package')
->whereHas('package', fn ($query) => $query->withTrashed()->where('type', 'reseller'))
->orderByDesc('purchased_at')
->orderByDesc('id')
->first();
if ($latestResellerPackage && $latestResellerPackage->package) {
$limit = $latestResellerPackage->package->max_events_per_year ?? 0;
return [
'code' => 'event_limit_exceeded',
'title' => __('api.packages.event_limit_exceeded.title'),
'message' => __('api.packages.event_limit_exceeded.message'),
'status' => 402,
'meta' => [
'scope' => 'events',
'used' => (int) $latestResellerPackage->used_events,
'limit' => $limit,
'remaining' => max(0, $limit - $latestResellerPackage->used_events),
'tenant_package_id' => $latestResellerPackage->id,
'package_id' => $latestResellerPackage->package_id,
],
];
}
return [
'code' => 'event_limit_missing',
'title' => 'No package assigned',
'message' => 'Assign a package or addon to create events.',
'title' => __('api.packages.event_limit_missing.title'),
'message' => __('api.packages.event_limit_missing.message'),
'status' => 402,
'meta' => [
'scope' => 'events',
@@ -49,8 +98,8 @@ class PackageLimitEvaluator
return [
'code' => 'event_limit_exceeded',
'title' => 'Event quota reached',
'message' => 'Your current package has no remaining event slots. Please upgrade or renew your subscription.',
'title' => __('api.packages.event_limit_exceeded.title'),
'message' => __('api.packages.event_limit_exceeded.message'),
'status' => 402,
'meta' => [
'scope' => 'events',
@@ -74,8 +123,8 @@ class PackageLimitEvaluator
if (! $event) {
return [
'code' => 'event_not_found',
'title' => 'Event not accessible',
'message' => 'The selected event could not be found or belongs to another tenant.',
'title' => __('api.packages.event_not_found.title'),
'message' => __('api.packages.event_not_found.message'),
'status' => 404,
'meta' => [
'scope' => 'photos',
@@ -87,8 +136,8 @@ class PackageLimitEvaluator
if (! $eventPackage || ! $eventPackage->package) {
return [
'code' => 'event_package_missing',
'title' => 'Event package missing',
'message' => 'No package is attached to this event. Assign a package to enable uploads.',
'title' => __('api.packages.event_package_missing.title'),
'message' => __('api.packages.event_package_missing.message'),
'status' => 409,
'meta' => [
'scope' => 'photos',
@@ -102,8 +151,8 @@ class PackageLimitEvaluator
if ($maxPhotos !== null && $eventPackage->used_photos >= $maxPhotos) {
return [
'code' => 'photo_limit_exceeded',
'title' => 'Photo upload limit reached',
'message' => 'This event has reached its photo allowance. Upgrade the event package to accept more uploads.',
'title' => __('api.packages.photo_limit_exceeded.title'),
'message' => __('api.packages.photo_limit_exceeded.message'),
'status' => 402,
'meta' => [
'scope' => 'photos',
@@ -122,8 +171,8 @@ class PackageLimitEvaluator
if ($eventPackage->used_photos >= $tenantPhotoLimit) {
return [
'code' => 'tenant_photo_limit_exceeded',
'title' => 'Tenant photo limit reached',
'message' => 'This tenant has reached its photo allowance for the event.',
'title' => __('api.packages.tenant_photo_limit_exceeded.title'),
'message' => __('api.packages.tenant_photo_limit_exceeded.message'),
'status' => 402,
'meta' => [
'scope' => 'photos',
@@ -146,8 +195,8 @@ class PackageLimitEvaluator
if ($projectedBytes >= $storageLimitBytes) {
return [
'code' => 'tenant_storage_limit_exceeded',
'title' => 'Tenant storage limit reached',
'message' => 'This tenant has reached its storage allowance.',
'title' => __('api.packages.tenant_storage_limit_exceeded.title'),
'message' => __('api.packages.tenant_storage_limit_exceeded.message'),
'status' => 402,
'meta' => [
'scope' => 'storage',

View File

@@ -4,7 +4,6 @@ namespace App\Services\Packages;
use App\Events\Packages\TenantPackageEventLimitReached;
use App\Events\Packages\TenantPackageEventThresholdReached;
use App\Models\Tenant;
use App\Models\TenantPackage;
use Illuminate\Contracts\Events\Dispatcher;
@@ -63,6 +62,12 @@ class TenantUsageTracker
}
$this->dispatcher->dispatch(new TenantPackageEventLimitReached($tenantPackage, $limit));
if ($tenantPackage->active) {
$tenantPackage->forceFill([
'active' => false,
])->save();
}
}
}
}

View File

@@ -13,7 +13,8 @@ trait PresentsPackages
$packageArray = $package->toArray();
$features = $packageArray['features'] ?? [];
$features = $this->normaliseFeatures($features);
$watermarkPolicy = $package->watermark_allowed === false ? 'none' : 'basic';
$watermarkRemovalAllowed = in_array('no_watermark', $features, true) || in_array('watermark_removal', $features, true);
$watermarkPolicy = $watermarkRemovalAllowed ? 'none' : 'basic';
$locale = app()->getLocale();
$name = $this->resolveTranslation($package->name_translations ?? null, $package->name ?? '', $locale);
@@ -43,6 +44,7 @@ trait PresentsPackages
'name' => $name,
'slug' => $package->slug,
'type' => $package->type,
'included_package_slug' => $package->included_package_slug,
'price' => $package->price,
'paddle_product_id' => $package->paddle_product_id,
'paddle_price_id' => $package->paddle_price_id,

View File

@@ -178,10 +178,13 @@ class ImageHelper
$y = max(0, min($srcH - $targetH, $y + $offsetY));
$opacity = max(0.0, min(1.0, (float) ($config['opacity'] ?? 0.25)));
$mergeOpacity = (int) round($opacity * 100); // imagecopymerge uses 0-100
if ($opacity < 1.0) {
self::applyOpacity($resized, $opacity);
}
imagealphablending($src, true);
imagecopymerge($src, $resized, $x, $y, 0, 0, $targetW, $targetH, $mergeOpacity);
imagecopy($src, $resized, $x, $y, 0, 0, $targetW, $targetH);
imagedestroy($resized);
// Overwrite original (respect mime: always JPEG for compatibility)
@@ -210,4 +213,34 @@ class ImageHelper
return $applied ? $destPath : null;
}
/**
* @param \GdImage|resource $image
*/
private static function applyOpacity($image, float $opacity): void
{
$width = imagesx($image);
$height = imagesy($image);
if ($width <= 0 || $height <= 0) {
return;
}
imagealphablending($image, false);
imagesavealpha($image, true);
for ($x = 0; $x < $width; $x++) {
for ($y = 0; $y < $height; $y++) {
$rgba = imagecolorat($image, $x, $y);
$alpha = ($rgba >> 24) & 0x7F;
$red = ($rgba >> 16) & 0xFF;
$green = ($rgba >> 8) & 0xFF;
$blue = $rgba & 0xFF;
$adjustedAlpha = (int) round(127 - (127 - $alpha) * $opacity);
$color = imagecolorallocatealpha($image, $red, $green, $blue, max(0, min(127, $adjustedAlpha)));
imagesetpixel($image, $x, $y, $color);
}
}
}
}

View File

@@ -0,0 +1,174 @@
<?php
namespace App\Support;
use App\Models\Event;
use App\Models\EventMember;
use App\Models\User;
use Illuminate\Http\Exceptions\HttpResponseException;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
class TenantMemberPermissions
{
/**
* @return array<int, string>
*/
public static function resolveEventPermissions(Request $request, Event $event): array
{
$user = $request->user();
if (! $user instanceof User) {
return [];
}
if (self::isTenantAdmin($user)) {
return ['*'];
}
$member = self::resolveEventMember($user, $event);
if (! $member) {
return [];
}
return self::normalizePermissions($member->permissions);
}
public static function ensureEventPermission(Request $request, Event $event, string $permission): void
{
if (self::allowsPermission(self::resolveEventPermissions($request, $event), $permission)) {
return;
}
throw new HttpResponseException(ApiError::response(
'insufficient_permission',
'Insufficient permission',
'You are not allowed to perform this action.',
Response::HTTP_FORBIDDEN,
['required_permission' => $permission]
));
}
public static function allowsEventPermission(Request $request, Event $event, string $permission): bool
{
return self::allowsPermission(self::resolveEventPermissions($request, $event), $permission);
}
public static function ensureTenantPermission(Request $request, string $permission): void
{
$user = $request->user();
if (! $user instanceof User) {
throw new HttpResponseException(ApiError::response(
'unauthenticated',
'Unauthenticated',
'You must be authenticated to perform this action.',
Response::HTTP_UNAUTHORIZED
));
}
if (self::isTenantAdmin($user)) {
return;
}
$permissions = self::resolveTenantMemberPermissions($user);
if (self::allowsPermission($permissions, $permission)) {
return;
}
throw new HttpResponseException(ApiError::response(
'insufficient_permission',
'Insufficient permission',
'You are not allowed to perform this action.',
Response::HTTP_FORBIDDEN,
['required_permission' => $permission]
));
}
/**
* @return array<int, string>
*/
private static function resolveTenantMemberPermissions(User $user): array
{
if (! $user->tenant_id) {
return [];
}
$memberships = EventMember::query()
->where('tenant_id', $user->tenant_id)
->whereIn('status', ['active', 'invited'])
->where(function ($query) use ($user) {
$query->where('user_id', $user->id)
->orWhere('email', $user->email);
})
->get(['permissions']);
$permissions = [];
foreach ($memberships as $member) {
$permissions = array_merge($permissions, self::normalizePermissions($member->permissions));
}
return array_values(array_unique($permissions));
}
private static function isTenantAdmin(User $user): bool
{
return in_array($user->role, ['tenant_admin', 'admin', 'super_admin', 'superadmin'], true);
}
private static function resolveEventMember(User $user, Event $event): ?EventMember
{
return EventMember::query()
->where('tenant_id', $event->tenant_id)
->where('event_id', $event->id)
->whereIn('status', ['active', 'invited'])
->where(function ($query) use ($user) {
$query->where('user_id', $user->id)
->orWhere('email', $user->email);
})
->first();
}
/**
* @param array<int, string>|string|null $permissions
* @return array<int, string>
*/
private static function normalizePermissions(mixed $permissions): array
{
if (is_array($permissions)) {
return array_values(array_filter(array_map('strval', $permissions)));
}
if (is_string($permissions) && $permissions !== '') {
return array_values(array_filter(array_map('trim', explode(',', $permissions))));
}
return [];
}
/**
* @param array<int, string> $permissions
*/
private static function allowsPermission(array $permissions, string $permission): bool
{
foreach ($permissions as $entry) {
if ($entry === '*' || $entry === $permission) {
return true;
}
if (Str::endsWith($entry, ':*')) {
$prefix = Str::beforeLast($entry, '*');
if (Str::startsWith($permission, $prefix)) {
return true;
}
}
}
return false;
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Support;
use App\Models\Event;
use App\Models\Package;
use Illuminate\Support\Arr;
class WatermarkConfigResolver
@@ -29,7 +30,21 @@ class WatermarkConfigResolver
{
$event->loadMissing('eventPackage.package');
return $event->eventPackage?->package?->watermark_allowed === false ? 'none' : 'basic';
return self::determineRemovalAllowed($event) ? 'none' : 'basic';
}
public static function determineRemovalAllowed(Event $event): bool
{
$event->loadMissing('eventPackage.package', 'eventPackages.package');
$package = $event->eventPackage?->package;
if (! $package && $event->relationLoaded('eventPackages')) {
$package = $event->eventPackages->first()?->package;
}
return self::packageHasFeature($package, 'no_watermark')
|| self::packageHasFeature($package, 'watermark_removal');
}
/**
@@ -39,13 +54,6 @@ class WatermarkConfigResolver
{
$policy = self::determinePolicy($event);
if ($policy === 'none') {
return [
'type' => 'none',
'policy' => $policy,
];
}
$baseSetting = null;
if (class_exists(\App\Models\WatermarkSetting::class) && \Illuminate\Support\Facades\Schema::hasTable('watermark_settings')) {
@@ -65,8 +73,15 @@ class WatermarkConfigResolver
'offset_y' => $baseSetting?->offset_y ?? config('watermark.base.offset_y', 0),
];
$event->loadMissing('eventPackage.package', 'tenant');
$event->loadMissing('eventPackage.package', 'eventPackages.package', 'tenant');
$package = $event->eventPackage?->package;
if (! $package && $event->relationLoaded('eventPackages')) {
$package = $event->eventPackages->first()?->package;
}
$brandingAllowed = self::determineBrandingAllowed($event);
$watermarkAllowed = $package?->watermark_allowed !== false;
$removalAllowed = self::determineRemovalAllowed($event);
$eventWatermark = Arr::get($event->settings, 'watermark', []);
$tenantWatermark = Arr::get($event->tenant?->settings, 'watermark', []);
$serveOriginals = (bool) Arr::get($event->settings, 'watermark_serve_originals', false);
@@ -75,7 +90,11 @@ class WatermarkConfigResolver
? ($eventWatermark['mode'] ?? $tenantWatermark['mode'] ?? 'base')
: 'base';
if ($mode === 'off' && $policy === 'basic') {
if ($mode === 'custom' && (! $brandingAllowed || ! $watermarkAllowed)) {
$mode = 'base';
}
if ($mode === 'off' && ! $removalAllowed) {
$mode = 'base';
}
@@ -111,4 +130,30 @@ class WatermarkConfigResolver
'serve_originals' => $serveOriginals,
];
}
private static function packageHasFeature(?Package $package, string $feature): bool
{
if (! $package) {
return false;
}
$features = $package->features ?? [];
if (is_string($features)) {
$decoded = json_decode($features, true);
if (json_last_error() === JSON_ERROR_NONE) {
$features = $decoded;
}
}
if (! is_array($features)) {
return false;
}
if (array_is_list($features)) {
return in_array($feature, $features, true);
}
return isset($features[$feature]) && (bool) $features[$feature];
}
}

View File

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

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

918
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -28,8 +28,8 @@ class TenantFactory extends Factory
'settings' => json_encode([
'branding' => [
'logo_url' => null,
'primary_color' => '#3B82F6',
'secondary_color' => '#1F2937',
'primary_color' => '#FF5A5F',
'secondary_color' => '#FFF8F5',
'font_family' => 'Inter, sans-serif',
],
'features' => [

View File

@@ -0,0 +1,34 @@
<?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::table('packages', function (Blueprint $table) {
if (! Schema::hasColumn('packages', 'included_package_slug')) {
$table->string('included_package_slug')->nullable()->after('type');
$table->index(['type', 'included_package_slug']);
}
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('packages', function (Blueprint $table) {
if (Schema::hasColumn('packages', 'included_package_slug')) {
$table->dropIndex(['type', 'included_package_slug']);
$table->dropColumn('included_package_slug');
}
});
}
};

View File

@@ -46,9 +46,9 @@ class DemoEventSeeder extends Seeder
'collection_slugs' => ['wedding-classics-2025'],
'task_slug_prefix' => 'wedding-',
'branding' => [
'primary_color' => '#f43f5e',
'secondary_color' => '#fb7185',
'background_color' => '#fff7f4',
'primary_color' => '#FF5A5F',
'secondary_color' => '#FFF8F5',
'background_color' => '#FFF8F5',
'font_family' => 'Playfair Display, serif',
],
],

View File

@@ -76,8 +76,8 @@ class DemoTenantSeeder extends Seeder
'contact_email' => $user->email,
'branding' => [
'logo_url' => null,
'primary_color' => '#f43f5e',
'secondary_color' => '#1f2937',
'primary_color' => '#FF5A5F',
'secondary_color' => '#FFF8F5',
],
],
]

View File

@@ -2,9 +2,9 @@
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use App\Models\Package;
use App\Enums\PackageType;
use App\Models\Package;
use Illuminate\Database\Seeder;
class PackageSeeder extends Seeder
{
@@ -14,7 +14,7 @@ class PackageSeeder extends Seeder
public function run(): void
{
$packages = [
[
'slug' => 'starter',
'name' => 'Starter',
@@ -28,12 +28,13 @@ class PackageSeeder extends Seeder
'max_guests' => 100,
'gallery_days' => 180,
'max_tasks' => 30,
'watermark_allowed' => true,
'max_events_per_year' => 1,
'watermark_allowed' => false,
'branding_allowed' => false,
'features' => ['basic_uploads', 'limited_sharing', 'custom_tasks'],
'paddle_product_id' => 'pro_01k8jcxx2g1vj9snqbga4283ej',
'paddle_price_id' => 'pri_01k8jcxx8qktxvqzzv0nkjjj27',
'description' => <<<TEXT
'description' => <<<'TEXT'
Ideal für Geburtstage, Gartenpartys oder Polterabende! {{max_guests}} Gäste teilen ihre besten Schnappschüsse, lösen {{max_tasks}} Fotoaufgaben und haben {{gallery_duration}} Zugriff auf die Online-Galerie. {{max_photos}} Bilder sind inklusive genug Platz für jede Menge Lieblingsmomente.
TEXT,
'description_translations' => [
@@ -61,12 +62,12 @@ TEXT,
'max_guests' => 250,
'gallery_days' => 365,
'max_tasks' => 100,
'watermark_allowed' => false,
'watermark_allowed' => true,
'branding_allowed' => true,
'features' => ['basic_uploads', 'unlimited_sharing', 'custom_branding', 'custom_tasks', 'live_slideshow'],
'features' => ['basic_uploads', 'unlimited_sharing', 'custom_branding', 'custom_tasks', 'live_slideshow', 'no_watermark'],
'paddle_product_id' => 'pro_01k8jcxwjv4ne8vf9pvd9bye3j',
'paddle_price_id' => 'pri_01k8jcxws51pze5xc3vj2ea0yc',
'description' => <<<TEXT
'description' => <<<'TEXT'
Das Rundum-Sorglos-Paket für Hochzeiten, Firmenfeiern oder Jubiläen. {{max_photos}} Bilder, {{max_guests}} Gäste und {{max_tasks}} Fotoaufgaben dazu eine Galerie, die {{gallery_duration}} online bleibt. Eigenes Logo oder Wasserzeichen inklusive.
TEXT,
'description_translations' => [
@@ -94,17 +95,17 @@ TEXT,
'max_guests' => null,
'gallery_days' => 730,
'max_tasks' => 200,
'watermark_allowed' => false,
'watermark_allowed' => true,
'branding_allowed' => true,
'features' => ['basic_uploads', 'unlimited_sharing', 'custom_branding', 'custom_tasks', 'live_slideshow', 'advanced_analytics', 'priority_support'],
'paddle_product_id' => 'pro_01k8jcxvwp38gay6jj2akjg76s',
'paddle_price_id' => 'pri_01k8jcxw5sap4r306wcvc0ephy',
'description' => <<<TEXT
Das volle Erlebnis für alle, die keine Kompromisse machen wollen. {{max_photos}} Bilder, unbegrenzt viele Gäste, {{gallery_duration}} Galerie-Zugang und {{max_tasks}} Aufgaben dazu kein Wasserzeichen, Live-Slideshow und Premium-Support.
'description' => <<<'TEXT'
Das volle Erlebnis für alle, die keine Kompromisse machen wollen. {{max_photos}} Bilder, unbegrenzt viele Gäste, {{gallery_duration}} Galerie-Zugang und {{max_tasks}} Aufgaben dazu eigenes Wasserzeichen, Live-Slideshow und Premium-Support.
TEXT,
'description_translations' => [
'de' => 'Das volle Erlebnis für alle, die keine Kompromisse machen wollen. {{max_photos}} Bilder, unbegrenzt viele Gäste, {{gallery_duration}} Galerie-Zugang und {{max_tasks}} Aufgaben dazu kein Wasserzeichen, Live-Slideshow und Premium-Support.',
'en' => 'The full experience for anyone who refuses to compromise. {{max_photos}} photos, unlimited guests, {{gallery_duration}} of gallery access and {{max_tasks}} challenges—no watermark, live slideshow and premium support included.',
'de' => 'Das volle Erlebnis für alle, die keine Kompromisse machen wollen. {{max_photos}} Bilder, unbegrenzt viele Gäste, {{gallery_duration}} Galerie-Zugang und {{max_tasks}} Aufgaben dazu eigenes Wasserzeichen, Live-Slideshow und Premium-Support.',
'en' => 'The full experience for anyone who refuses to compromise. {{max_photos}} photos, unlimited guests, {{gallery_duration}} of gallery access and {{max_tasks}} challenges—custom watermark, live slideshow and premium support included.',
],
'description_table' => [
['title' => 'Fotos', 'value' => '{{max_photos}}'],
@@ -116,111 +117,149 @@ TEXT,
],
[
'slug' => 's-small-reseller',
'name' => 'Reseller S',
'name' => 'Partner Start',
'name_translations' => [
'de' => 'Reseller S',
'en' => 'Reseller S',
'de' => 'Partner Start',
'en' => 'Partner Start',
],
'type' => PackageType::RESELLER,
'included_package_slug' => 'starter',
'price' => 149.00,
'max_photos' => 1000,
'max_photos' => null,
'max_guests' => null,
'gallery_days' => 30,
'gallery_days' => null,
'max_tasks' => null,
'watermark_allowed' => true,
'branding_allowed' => true,
'max_events_per_year' => 5,
'expires_after' => now()->copy()->addYear(),
'expires_after' => null,
'features' => ['reseller_dashboard', 'custom_branding', 'priority_support'],
'paddle_product_id' => 'pro_01k8jcxvax48mhmwsfydw8ha9y',
'paddle_price_id' => 'pri_01k8jcxvhe0bfasg9gg1rw70sy',
'description' => <<<TEXT
Das perfekte Paket für Fotografen oder Planer, die erste Erfahrungen mit Fotospiel sammeln wollen. Enthalten sind {{max_events_per_year}} Events pro Jahr mit Standard-Leistung Branding-Optionen inklusive.
'description' => <<<'TEXT'
Event-Kontingent r Partner / Agenturen: {{max_events_per_year}} Events auf StarterNiveau. Empfohlen innerhalb von 24 Monaten zu nutzen.
TEXT,
'description_translations' => [
'de' => 'Das perfekte Paket für Fotografen oder Planer, die erste Erfahrungen mit Fotospiel sammeln wollen. Enthalten sind {{max_events_per_year}} Events pro Jahr mit Standard-Leistung Branding-Optionen inklusive.',
'en' => 'Perfect for photographers or planners getting started with Fotospiel. Includes {{max_events_per_year}} events per year with the standard feature set—branding options included.',
'de' => 'Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf StarterNiveau. Empfohlen innerhalb von 24 Monaten zu nutzen.',
'en' => 'Event-Kontingent for Partner / Agencies: {{max_events_per_year}} events at Starter level. Recommended to use within 24 months.',
],
'description_table' => [
['title' => 'Events/Jahr', 'value' => '{{max_events_per_year}} Events'],
['title' => 'Galerie', 'value' => '{{gallery_duration}}'],
['title' => 'Branding', 'value' => 'Logo & Farben pro Event'],
['title' => 'Events', 'value' => '{{max_events_per_year}} Events'],
['title' => 'Inklusive Event-Level', 'value' => 'Starter'],
['title' => 'Empfehlung', 'value' => 'Empfohlen innerhalb von 24 Monaten zu nutzen.'],
],
],
[
'slug' => 'm-medium-reseller',
'name' => 'Reseller M',
'name' => 'Partner Standard',
'name_translations' => [
'de' => 'Reseller M',
'en' => 'Reseller M',
'de' => 'Partner Standard',
'en' => 'Partner Standard',
],
'type' => PackageType::RESELLER,
'included_package_slug' => 'standard',
'price' => 349.00,
'max_photos' => 1500,
'max_photos' => null,
'max_guests' => null,
'gallery_days' => 60,
'gallery_days' => null,
'max_tasks' => null,
'watermark_allowed' => true,
'branding_allowed' => true,
'max_events_per_year' => 15,
'expires_after' => now()->copy()->addYear(),
'expires_after' => null,
'features' => ['reseller_dashboard', 'custom_branding', 'priority_support', 'advanced_reporting'],
'paddle_product_id' => 'pro_01k8jcxtrxw7jsew52jnax901q',
'paddle_price_id' => 'pri_01k8jcxv06nsgy8ym8mnfrfm5v',
'description' => <<<TEXT
Wenn du regelmäßig Hochzeiten, Firmenfeste oder private Events betreust, ist dieses Paket ideal. {{max_events_per_year}} Events pro Jahr mit Branding-Optionen, verlängerter Galerie-Laufzeit und Reporting inklusive.
'description' => <<<'TEXT'
Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf StandardNiveau. Empfohlen innerhalb von 24 Monaten zu nutzen.
TEXT,
'description_translations' => [
'de' => 'Wenn du regelmäßig Hochzeiten, Firmenfeste oder private Events betreust, ist dieses Paket ideal. {{max_events_per_year}} Events pro Jahr mit Branding-Optionen, verlängerter Galerie-Laufzeit und Reporting inklusive.',
'en' => 'Designed for professionals who regularly support weddings, corporate events or private parties. {{max_events_per_year}} events per year with branding options, extended gallery runtime and reporting included.',
'de' => 'Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf StandardNiveau. Empfohlen innerhalb von 24 Monaten zu nutzen.',
'en' => 'Event-Kontingent for Partner / Agencies: {{max_events_per_year}} events at Standard level. Recommended to use within 24 months.',
],
'description_table' => [
['title' => 'Events/Jahr', 'value' => '{{max_events_per_year}} Events'],
['title' => 'Galerie', 'value' => '{{gallery_duration}}'],
['title' => 'Reporting', 'value' => 'Erweiterte Auswertungen'],
['title' => 'Events', 'value' => '{{max_events_per_year}} Events'],
['title' => 'Inklusive Event-Level', 'value' => 'Standard'],
['title' => 'Empfehlung', 'value' => 'Empfohlen innerhalb von 24 Monaten zu nutzen.'],
],
],
[
'slug' => 'l-large-reseller',
'name' => 'Reseller L',
'name' => 'Partner Premium',
'name_translations' => [
'de' => 'Reseller L',
'en' => 'Reseller L',
'de' => 'Partner Premium',
'en' => 'Partner Premium',
],
'type' => PackageType::RESELLER,
'price' => 699.00,
'max_photos' => 3000,
'included_package_slug' => 'pro',
'price' => 1999.00,
'max_photos' => null,
'max_guests' => null,
'gallery_days' => 90,
'gallery_days' => null,
'max_tasks' => null,
'watermark_allowed' => false,
'watermark_allowed' => true,
'branding_allowed' => true,
'max_events_per_year' => 40,
'expires_after' => now()->copy()->addYear(),
'max_events_per_year' => 35,
'expires_after' => null,
'features' => ['reseller_dashboard', 'custom_branding', 'priority_support', 'advanced_reporting', 'live_slideshow'],
'paddle_product_id' => 'pro_01k8jcxt7gc6g6ddavmq65txzz',
'paddle_price_id' => 'pri_01k8jcxtfa07gvq43kpvpe0t8z',
'description' => <<<TEXT
Ideal für Agenturen, Fotografen oder Eventdienstleister mit vielen Veranstaltungen im Jahr. {{max_events_per_year}} Events inklusive, White-Label-Branding und alle Premium-Funktionen sorgen für maximale Flexibilität.
'description' => <<<'TEXT'
Event-Kontingent r Partner / Agenturen: {{max_events_per_year}} Events auf PremiumNiveau. Empfohlen innerhalb von 24 Monaten zu nutzen.
TEXT,
'description_translations' => [
'de' => 'Ideal für Agenturen, Fotografen oder Eventdienstleister mit vielen Veranstaltungen im Jahr. {{max_events_per_year}} Events inklusive, White-Label-Branding und alle Premium-Funktionen sorgen für maximale Flexibilität.',
'en' => 'Ideal for agencies, photographers or event providers with a packed calendar. {{max_events_per_year}} events included, white-label branding and all premium features for maximum flexibility.',
'de' => 'Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf PremiumNiveau. Empfohlen innerhalb von 24 Monaten zu nutzen.',
'en' => 'Event-Kontingent for Partner / Agencies: {{max_events_per_year}} events at Premium level. Recommended to use within 24 months.',
],
'description_table' => [
['title' => 'Events/Jahr', 'value' => '{{max_events_per_year}} Events'],
['title' => 'Branding', 'value' => 'White-Label & eigene Domains'],
['title' => 'Extras', 'value' => 'Live-Slideshow & Premium-Features'],
['title' => 'Events', 'value' => '{{max_events_per_year}} Events'],
['title' => 'Inklusive Event-Level', 'value' => 'Premium'],
['title' => 'Empfehlung', 'value' => 'Empfohlen innerhalb von 24 Monaten zu nutzen.'],
],
],
[
'slug' => 'partner-premium-5',
'name' => 'Partner Premium-Kontingent (5 Events)',
'name_translations' => [
'de' => 'Partner Premium-Kontingent (5 Events)',
'en' => 'Partner Premium kontingent (5 events)',
],
'type' => PackageType::RESELLER,
'included_package_slug' => 'pro',
'price' => 549.00,
'max_photos' => null,
'max_guests' => null,
'gallery_days' => null,
'max_tasks' => null,
'watermark_allowed' => true,
'branding_allowed' => true,
'max_events_per_year' => 5,
'expires_after' => null,
'features' => ['reseller_dashboard', 'custom_branding', 'priority_support'],
'paddle_product_id' => 'pro_01kf16ttp0fph79j59x0z1cdqc',
'paddle_price_id' => 'pri_01kf16v0v2z4hse5cxq5wnah4b',
'description' => <<<'TEXT'
Premium Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf PremiumNiveau. Empfohlen innerhalb von 24 Monaten zu nutzen.
TEXT,
'description_translations' => [
'de' => 'Premium Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf PremiumNiveau. Empfohlen innerhalb von 24 Monaten zu nutzen.',
'en' => 'Premium Event-Kontingent for Partner / Agencies: {{max_events_per_year}} events at Premium level. Recommended to use within 24 months.',
],
'description_table' => [
['title' => 'Events', 'value' => '{{max_events_per_year}} Events'],
['title' => 'Inklusive Event-Level', 'value' => 'Premium'],
['title' => 'Empfehlung', 'value' => 'Empfohlen innerhalb von 24 Monaten zu nutzen.'],
],
],
[
'slug' => 'studio-annual',
'name' => 'Studio Jahrespaket',
'name' => 'Partner Jahreskontingent (24 Events)',
'name_translations' => [
'de' => 'Studio Jahrespaket',
'en' => 'Studio Annual',
'de' => 'Partner Jahreskontingent (24 Events)',
'en' => 'Partner annual kontingent (24 events)',
],
'type' => PackageType::RESELLER,
'included_package_slug' => 'standard',
'price' => 1299.00,
'max_photos' => null,
'max_guests' => null,
@@ -230,42 +269,20 @@ TEXT,
'branding_allowed' => false,
'max_events_per_year' => 24,
'expires_after' => null,
'features' => ['basic_uploads', 'unlimited_sharing', 'custom_branding'],
'features' => ['reseller_dashboard', 'custom_branding', 'priority_support', 'advanced_reporting'],
'paddle_product_id' => 'pro_01k8jct3gz9ks5mg6z61q6nrxb',
'paddle_price_id' => 'pri_01k8jcxsa8axwpjnybhjbcrb06',
'description' => null,
'description_translations' => null,
'description_table' => [],
],
[
'slug' => 'enterprise-unlimited',
'name' => 'Enterprise / Unlimited',
'name_translations' => [
'de' => 'Enterprise / Unlimited',
'en' => 'Enterprise / Unlimited',
],
'type' => PackageType::RESELLER,
'price' => 1999.00,
'max_photos' => null,
'max_guests' => null,
'gallery_days' => null,
'max_tasks' => null,
'watermark_allowed' => false,
'branding_allowed' => true,
'max_events_per_year' => null,
'expires_after' => now()->copy()->addYear(),
'features' => ['reseller_dashboard', 'custom_branding', 'priority_support', 'advanced_reporting', 'live_slideshow', 'unlimited_sharing'],
'description' => <<<TEXT
Das Rundum-Paket für Unternehmen und Agenturen, die maximale Flexibilität brauchen. {{max_events_per_year}} Events, volles White-Label-Branding, eigene Subdomain oder App-Branding alles individuell anpassbar, inklusive persönlicher Betreuung.
'description' => <<<'TEXT'
Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf StandardNiveau. Empfohlen innerhalb von 24 Monaten zu nutzen.
TEXT,
'description_translations' => [
'de' => 'Das Rundum-Paket für Unternehmen und Agenturen, die maximale Flexibilität brauchen. {{max_events_per_year}} Events, volles White-Label-Branding, eigene Subdomain oder App-Branding alles individuell anpassbar, inklusive persönlicher Betreuung.',
'en' => 'The all-round package for enterprises and agencies needing maximum flexibility. {{max_events_per_year}} events, full white-label branding, your own subdomain or app branding—fully customisable with dedicated support.',
'de' => 'Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf StandardNiveau. Empfohlen innerhalb von 24 Monaten zu nutzen.',
'en' => 'Event-Kontingent for Partner / Agencies: {{max_events_per_year}} events at Standard level. Recommended to use within 24 months.',
],
'description_table' => [
['title' => 'Events/Jahr', 'value' => '{{max_events_per_year}} Events'],
['title' => 'Branding', 'value' => 'Eigene Subdomain oder App'],
['title' => 'Support', 'value' => 'Persönliche Betreuung'],
['title' => 'Events', 'value' => '{{max_events_per_year}} Events'],
['title' => 'Inklusive Event-Level', 'value' => 'Standard'],
['title' => 'Empfehlung', 'value' => 'Empfohlen innerhalb von 24 Monaten zu nutzen.'],
],
],
];
@@ -279,5 +296,7 @@ TEXT,
])
);
}
Package::where('slug', 'enterprise-unlimited')->delete();
}
}

View File

@@ -62,13 +62,15 @@ class TaskCollectionsSeeder extends Seeder
'Ekstase' => ['name' => ['de' => 'Ekstase', 'en' => 'Ecstasy'], 'icon' => 'lucide-stars', 'color' => '#f59e0b'],
];
$weddingType = ['slug' => 'wedding', 'name' => ['de' => 'Hochzeit', 'en' => 'Wedding'], 'icon' => 'lucide-heart'];
return [
'wedding' => [
'event_type' => ['slug' => 'wedding', 'name' => ['de' => 'Hochzeit', 'en' => 'Wedding'], 'icon' => 'lucide-heart'],
'event_type' => $weddingType,
'collection' => [
'slug' => 'wedding-classics-2025',
'name' => ['de' => 'Hochzeits-Aufgaben', 'en' => 'Wedding Tasks'],
'description' => ['de' => '50 Aufgaben für den schönsten Tag im Leben.', 'en' => '50 tasks for the most beautiful day of your life.'],
'description' => ['de' => '44 klassische Aufgaben für den schönsten Tag im Leben.', 'en' => '44 classic tasks for the most beautiful day of your life.'],
'is_default' => true,
'position' => 10,
],
@@ -87,25 +89,20 @@ class TaskCollectionsSeeder extends Seeder
$this->taskDefinition('wedding-12', ['de' => 'Findet die Großeltern des Brautpaares und macht ein Generationen-Foto.', 'en' => 'Find the grandparents of the couple and take a generation photo.'], $emotions['Liebe'], 120),
$this->taskDefinition('wedding-13', ['de' => 'Stellt eine Szene aus dem Lieblingsfilm des Brautpaares nach.', 'en' => 'Re-enact a scene from the couple\'s favorite movie.'], $emotions['Teamgeist'], 130),
$this->taskDefinition('wedding-14', ['de' => 'Macht ein Kuss-Foto mit eurem eigenen Partner.', 'en' => 'Take a kiss photo with your own partner.'], $emotions['Romantik'], 140),
$this->taskDefinition('wedding-15', ['de' => 'Fotografiert die Schuhe der Braut neben den Schuhen des Bräutigams.', 'en' => 'Photograph the bride\'s shoes next to the groom\'s shoes.'], $emotions['Romantik'], 150),
$this->taskDefinition('wedding-16', ['de' => 'Findet das jüngste und das älteste Familienmitglied auf der Feier für ein gemeinsames Bild.', 'en' => 'Find the youngest and oldest family member at the party for a photo together.'], $emotions['Nostalgie'], 160),
$this->taskDefinition('wedding-17', ['de' => 'Macht ein Foto von den Händen von drei verschiedenen Generationen.', 'en' => 'Take a photo of the hands of three different generations.'], $emotions['Rührung'], 170),
$this->taskDefinition('wedding-18', ['de' => 'Werft dem Brautpaar aus der Ferne eine Kusshand zu.', 'en' => 'Blow a kiss to the couple from a distance.'], $emotions['Liebe'], 180),
$this->taskDefinition('wedding-19', ['de' => 'Macht ein Foto mit jemandem, der heute zum ersten Mal auf einer Hochzeit ist.', 'en' => 'Take a photo with someone who is at a wedding for the first time today.'], $emotions['Überraschung'], 190),
$this->taskDefinition('wedding-20', ['de' => 'Fotografiert das Detail an der Hochzeitsdeko, das euch am besten gefällt.', 'en' => 'Photograph the detail of the wedding decoration that you like the most.'], $emotions['Besinnlichkeit'], 200),
$this->taskDefinition('wedding-20', ['de' => 'Fotografiert das detail an der Hochzeitsdeko, das euch am besten gefällt.', 'en' => 'Photograph the detail of the wedding decoration that you like the most.'], $emotions['Besinnlichkeit'], 200),
$this->taskDefinition('wedding-21', ['de' => 'Macht ein Gruppenfoto mit allen Gästen von eurem Tisch.', 'en' => 'Take a group photo with all the guests at your table.'], $emotions['Teamgeist'], 210),
$this->taskDefinition('wedding-22', ['de' => 'Findet einen Gast mit einer besonders schicken Krawatte oder Fliege.', 'en' => 'Find a guest with a particularly fancy tie or bow tie.'], $emotions['Stolz'], 220),
$this->taskDefinition('wedding-23', ['de' => 'Tanzt mit einem Elternteil des Brautpaares und haltet den Moment fest.', 'en' => 'Dance with a parent of the couple and capture the moment.'], $emotions['Freude'], 230),
$this->taskDefinition('wedding-24', ['de' => 'Macht ein Foto, das die Aufregung kurz vor dem Ja-Wort einfängt.', 'en' => 'Take a photo that captures the excitement just before the vows.'], $emotions['Rührung'], 240),
$this->taskDefinition('wedding-25', ['de' => 'Fotografiert das Gästebuch in Aktion.', 'en' => 'Photograph the guest book in action.'], $emotions['Nostalgie'], 250),
$this->taskDefinition('wedding-26', ['de' => 'Macht ein Foto von eurem Lieblingsmoment des Tages.', 'en' => 'Take a photo of your favorite moment of the day.'], $emotions['Besinnlichkeit'], 260),
$this->taskDefinition('wedding-27', ['de' => 'Findet einen Gegenstand, der die Farbe "Blau" enthält (etwas Altes, Neues, Geliehenes, Blaues).', 'en' => 'Find an object that contains the color "blue" (something old, new, borrowed, blue).'], $emotions['Nostalgie'], 270),
$this->taskDefinition('wedding-28', ['de' => 'Macht ein Foto, auf dem ihr dem Brautpaar für die Zukunft zuprostet.', 'en' => 'Take a photo toasting to the couple\'s future.'], $emotions['Stolz'], 280),
$this->taskDefinition('wedding-29', ['de' => 'Organisiert ein "Gruppen-Herz" mit so vielen Leuten wie möglich.', 'en' => 'Organize a "group heart" with as many people as possible.'], $emotions['Teamgeist'], 290),
$this->taskDefinition('wedding-30', ['de' => 'Fotografiert einen Moment stiller Zweisamkeit des Brautpaares.', 'en' => 'Photograph a moment of quiet togetherness of the couple.'], $emotions['Romantik'], 300),
$this->taskDefinition('wedding-31', ['de' => 'Macht ein Foto mit dem DJ oder einem Mitglied der Band.', 'en' => 'Take a photo with the DJ or a member of the band.'], $emotions['Freude'], 310),
$this->taskDefinition('wedding-32', ['de' => 'Findet die Person mit dem ansteckendsten Lachen.', 'en' => 'Find the person with the most infectious laugh.'], $emotions['Freude'], 320),
$this->taskDefinition('wedding-33', ['de' => 'Fotografiert den Brautstraußwurf.', 'en' => 'Photograph the bouquet toss.'], $emotions['Überraschung'], 330),
$this->taskDefinition('wedding-34', ['de' => 'Macht ein Foto mit demjenigen, der den Brautstrauß gefangen hat.', 'en' => 'Take a photo with the person who caught the bouquet.'], $emotions['Stolz'], 340),
$this->taskDefinition('wedding-35', ['de' => 'Stellt eure beste Tanzpose zur Schau.', 'en' => 'Show off your best dance pose.'], $emotions['Ekstase'], 350),
$this->taskDefinition('wedding-36', ['de' => 'Macht ein Foto, das "Für immer und ewig" symbolisiert.', 'en' => 'Take a photo that symbolizes "forever and ever".'], $emotions['Liebe'], 360),
@@ -120,11 +117,142 @@ class TaskCollectionsSeeder extends Seeder
$this->taskDefinition('wedding-45', ['de' => 'Fotografiert einen geheimen Moment, den nicht jeder mitbekommt.', 'en' => 'Photograph a secret moment that not everyone gets to see.'], $emotions['Besinnlichkeit'], 450),
$this->taskDefinition('wedding-46', ['de' => 'Macht ein Foto von eurem Lieblings-Teil des Hochzeitsmenüs.', 'en' => 'Take a photo of your favorite part of the wedding menu.'], $emotions['Freude'], 460),
$this->taskDefinition('wedding-47', ['de' => 'Findet einen Gast, der ein Kleid in der gleichen Farbe wie die Deko trägt.', 'en' => 'Find a guest wearing a dress the same color as the decorations.'], $emotions['Überraschung'], 470),
$this->taskDefinition('wedding-48', ['de' => 'Macht ein Foto, das die Erleichterung und Freude nach der Trauung zeigt.', 'en' => 'Take a photo showing the relief and joy after the ceremony.'], $emotions['Ekstase'], 480),
$this->taskDefinition('wedding-49', ['de' => 'Fotografiert die Geschenke für das Brautpaar.', 'en' => 'Photograph the gifts for the couple.'], $emotions['Liebe'], 490),
$this->taskDefinition('wedding-50', ['de' => 'Macht ein letztes Foto des Abends, das die Stimmung perfekt zusammenfasst.', 'en' => 'Take a final photo of the evening that perfectly summarizes the mood.'], $emotions['Nostalgie'], 500),
],
],
'wedding_icebreaker' => [
'event_type' => $weddingType,
'collection' => [
'slug' => 'wedding-booster-icebreaker',
'name' => ['de' => 'Booster: Icebreaker', 'en' => 'Booster: Icebreaker'],
'description' => ['de' => '10 Aufgaben, um Gäste miteinander ins Gespräch zu bringen.', 'en' => '10 tasks to get guests talking to each other.'],
'is_default' => false,
'position' => 11,
],
'base_tasks' => [
$this->taskDefinition('w-ice-1', ['de' => 'Finde einen Gast, den du noch nicht kennst, und macht ein gemeinsames Selfie.', 'en' => 'Find a guest you don\'t know yet and take a joint selfie.'], $emotions['Überraschung'], 10),
$this->taskDefinition('w-ice-2', ['de' => 'Finde jemanden mit der gleichen Augenfarbe wie du.', 'en' => 'Find someone with the same eye color as you.'], $emotions['Teamgeist'], 20),
$this->taskDefinition('w-ice-3', ['de' => 'Macht ein Foto mit jemandem, der aus einer anderen Stadt kommt.', 'en' => 'Take a photo with someone who comes from a different city.'], $emotions['Teamgeist'], 30),
$this->taskDefinition('w-ice-4', ['de' => 'Findet jemanden, der auch gerne das gleiche Hobby macht wie das Brautpaar.', 'en' => 'Find someone who also enjoys the same hobby as the couple.'], $emotions['Freude'], 40),
$this->taskDefinition('w-ice-5', ['de' => 'Macht ein Gruppen-Selfie mit mindestens 3 Personen, die sich vorher nicht kannten.', 'en' => 'Take a group selfie with at least 3 people who didn\'t know each other before.'], $emotions['Teamgeist'], 50),
$this->taskDefinition('w-ice-6', ['de' => 'Finde jemanden, der das gleiche Sternzeichen hat wie du.', 'en' => 'Find someone who has the same zodiac sign as you.'], $emotions['Überraschung'], 60),
$this->taskDefinition('w-ice-7', ['de' => 'Macht ein Foto von zwei Personen, die gerade tief im Gespräch sind.', 'en' => 'Take a photo of two people deep in conversation.'], $emotions['Besinnlichkeit'], 70),
$this->taskDefinition('w-ice-8', ['de' => 'Finde den Gast, der die weiteste Anreise hatte, und macht ein Foto.', 'en' => 'Find the guest who travelled the furthest and take a photo.'], $emotions['Stolz'], 80),
$this->taskDefinition('w-ice-9', ['de' => 'Macht ein Foto mit jemandem, der ein tolles Kompliment für dein Outfit hat.', 'en' => 'Take a photo with someone who has a great compliment for your outfit.'], $emotions['Freude'], 90),
$this->taskDefinition('w-ice-10', ['de' => 'Findet jemanden, der schon mal auf derselben Schule/Uni war wie das Brautpaar.', 'en' => 'Find someone who was at the same school/university as the couple.'], $emotions['Nostalgie'], 100),
],
],
'wedding_party' => [
'event_type' => $weddingType,
'collection' => [
'slug' => 'wedding-booster-party',
'name' => ['de' => 'Booster: Party & Dance', 'en' => 'Booster: Party & Dance'],
'description' => ['de' => '10 Aufgaben für eine unvergessliche Party auf der Tanzfläche.', 'en' => '10 tasks for an unforgettable party on the dance floor.'],
'is_default' => false,
'position' => 12,
],
'base_tasks' => [
$this->taskDefinition('w-party-1', ['de' => 'Macht ein Foto mitten im Sprung auf der Tanzfläche.', 'en' => 'Take a photo mid-jump on the dance floor.'], $emotions['Ekstase'], 10),
$this->taskDefinition('w-party-2', ['de' => 'Fotografiere die tanzenden Füße einer Person.', 'en' => 'Photograph a person\'s dancing feet.'], $emotions['Freude'], 20),
$this->taskDefinition('w-party-3', ['de' => 'Macht ein Selfie mit dem DJ.', 'en' => 'Take a selfie with the DJ.'], $emotions['Teamgeist'], 30),
$this->taskDefinition('w-party-4', ['de' => 'Fotografiere den "Luftgitarren-Profi" des Abends.', 'en' => 'Photograph the "air guitar pro" of the evening.'], $emotions['Ekstase'], 40),
$this->taskDefinition('w-party-5', ['de' => 'Macht ein Gruppenfoto mit euren Lieblings-Drinks.', 'en' => 'Take a group photo with your favorite drinks.'], $emotions['Freude'], 50),
$this->taskDefinition('w-party-6', ['de' => 'Halte den Moment fest, wenn alle mitsingen.', 'en' => 'Capture the moment when everyone is singing along.'], $emotions['Ekstase'], 60),
$this->taskDefinition('w-party-7', ['de' => 'Fotografiere die Person mit dem coolsten Tanzmove.', 'en' => 'Photograph the person with the coolest dance move.'], $emotions['Stolz'], 70),
$this->taskDefinition('w-party-8', ['de' => 'Macht ein Foto mit einem Requisit von der Tanzfläche (falls vorhanden).', 'en' => 'Take a photo with a prop from the dance floor (if available).'], $emotions['Überraschung'], 80),
$this->taskDefinition('w-party-9', ['de' => 'Fotografiere das Brautpaar beim Tanzen aus der Froschperspektive.', 'en' => 'Photograph the couple dancing from a frog\'s eye view.'], $emotions['Romantik'], 90),
$this->taskDefinition('w-party-10', ['de' => 'Macht ein Foto von der erschöpften, aber glücklichen "Tanz-Pause" an der Bar.', 'en' => 'Take a photo of the exhausted but happy "dance break" at the bar.'], $emotions['Freude'], 100),
],
],
'wedding_funny' => [
'event_type' => $weddingType,
'collection' => [
'slug' => 'wedding-booster-funny',
'name' => ['de' => 'Booster: Funny & Crazy', 'en' => 'Booster: Funny & Crazy'],
'description' => ['de' => '10 Aufgaben für maximalen Spaß und witzige Posen.', 'en' => '10 tasks for maximum fun and funny poses.'],
'is_default' => false,
'position' => 13,
],
'base_tasks' => [
$this->taskDefinition('w-fun-1', ['de' => 'Tauscht euer Sakko oder Accessoire mit dem Nachbarn und macht ein Foto.', 'en' => 'Swap your jacket or accessory with your neighbor and take a photo.'], $emotions['Überraschung'], 10),
$this->taskDefinition('w-fun-2', ['de' => 'Macht ein Foto, auf dem ihr so tut, als wärt ihr das Brautpaar.', 'en' => 'Take a photo pretending to be the bride and groom.'], $emotions['Freude'], 20),
$this->taskDefinition('w-fun-3', ['de' => 'Wer kann die lustigste Grimasse schneiden? Abdrücken!', 'en' => 'Who can make the funniest face? Snap it!'], $emotions['Ekstase'], 30),
$this->taskDefinition('w-fun-4', ['de' => 'Macht ein "Photobomb"-Foto (schleicht euch in das Bild von jemand anderem).', 'en' => 'Take a "photobomb" photo (sneak into someone else\'s picture).'], $emotions['Überraschung'], 40),
$this->taskDefinition('w-fun-5', ['de' => 'Stellt ein berühmtes Gemälde oder Filmplakat nach.', 'en' => 'Re-enact a famous painting or movie poster.'], $emotions['Teamgeist'], 50),
$this->taskDefinition('w-fun-6', ['de' => 'Macht ein Foto von jemandem, der gerade herzhaft lacht.', 'en' => 'Take a photo of someone laughing heartily.'], $emotions['Freude'], 60),
$this->taskDefinition('w-fun-7', ['de' => 'Werft euch in eine Helden-Pose (wie Avengers).', 'en' => 'Strike a hero pose (like Avengers).'], $emotions['Stolz'], 70),
$this->taskDefinition('w-fun-8', ['de' => 'Macht ein Foto von einer Person, die gerade ein Nickerchen macht (oder so tut).', 'en' => 'Take a photo of a person taking a nap (or pretending to).'], $emotions['Besinnlichkeit'], 80),
$this->taskDefinition('w-fun-9', ['de' => 'Benutzt Besteck oder Deko als falsche Schnurrbärte oder Brillen.', 'en' => 'Use cutlery or decorations as fake mustaches or glasses.'], $emotions['Freude'], 90),
$this->taskDefinition('w-fun-10', ['de' => 'Macht ein Foto von der "Schlacht am Buffet".', 'en' => 'Take a photo of the "battle at the buffet".'], $emotions['Ekstase'], 100),
],
],
'wedding_rustic' => [
'event_type' => $weddingType,
'collection' => [
'slug' => 'wedding-booster-rustic',
'name' => ['de' => 'Booster: Landhochzeit', 'en' => 'Booster: Rustic & Outdoor'],
'description' => ['de' => '10 Aufgaben für naturnahe Hochzeiten im Freien oder in der Scheune.', 'en' => '10 tasks for nature-oriented outdoor or barn weddings.'],
'is_default' => false,
'position' => 14,
],
'base_tasks' => [
$this->taskDefinition('w-rust-1', ['de' => 'Fotografiere das schönste Detail aus Holz oder Jute.', 'en' => 'Photograph the most beautiful detail made of wood or jute.'], $emotions['Besinnlichkeit'], 10),
$this->taskDefinition('w-rust-2', ['de' => 'Finde jemanden mit Blumen im Haar oder am Revers.', 'en' => 'Find someone with flowers in their hair or on their lapel.'], $emotions['Romantik'], 20),
$this->taskDefinition('w-rust-3', ['de' => 'Mach ein Foto im Freien mit viel Himmel im Hintergrund.', 'en' => 'Take a photo outdoors with lots of sky in the background.'], $emotions['Freude'], 30),
$this->taskDefinition('w-rust-4', ['de' => 'Fotografiere eine Lichterkette oder Laterne in der Dämmerung.', 'en' => 'Photograph a string of lights or lantern at dusk.'], $emotions['Romantik'], 40),
$this->taskDefinition('w-rust-5', ['de' => 'Finde ein Tier (Hund, Vogel, Schmetterling) auf dem Gelände.', 'en' => 'Find an animal (dog, bird, butterfly) on the grounds.'], $emotions['Überraschung'], 50),
$this->taskDefinition('w-rust-6', ['de' => 'Macht ein Foto auf einer Gartenbank oder einer Heuballe.', 'en' => 'Take a photo on a garden bench or a bale of hay.'], $emotions['Nostalgie'], 60),
$this->taskDefinition('w-rust-7', ['de' => 'Fotografiere das Brautpaar vor einer natürlichen Kulisse.', 'en' => 'Photograph the couple in front of a natural backdrop.'], $emotions['Liebe'], 70),
$this->taskDefinition('w-rust-8', ['de' => 'Finde einen Stein in Herzform oder ein Blatt.', 'en' => 'Find a heart-shaped stone or a leaf.'], $emotions['Überraschung'], 80),
$this->taskDefinition('w-rust-9', ['de' => 'Macht ein Foto von euren Schuhen im Gras.', 'en' => 'Take a photo of your shoes in the grass.'], $emotions['Freude'], 90),
$this->taskDefinition('w-rust-10', ['de' => 'Halte den Sonnenuntergang (oder das goldene Licht) fest.', 'en' => 'Capture the sunset (or the golden light).'], $emotions['Besinnlichkeit'], 100),
],
],
'wedding_traditions' => [
'event_type' => $weddingType,
'collection' => [
'slug' => 'wedding-booster-traditions',
'name' => ['de' => 'Booster: Tradition & Kultur', 'en' => 'Booster: Traditions & Culture'],
'description' => ['de' => '10 Aufgaben für kulturstarke Hochzeiten mit Bräuchen und viel Energie.', 'en' => '10 tasks for culture-rich weddings with customs and lots of energy.'],
'is_default' => false,
'position' => 15,
],
'base_tasks' => [
$this->taskDefinition('w-trad-1', ['de' => 'Macht ein Foto vom wildesten Kreistanz (Halay, Polonaise, Sirtaki).', 'en' => 'Take a photo of the wildest circle dance (Halay, Polonaise, Sirtaki).'], $emotions['Ekstase'], 10),
$this->taskDefinition('w-trad-2', ['de' => 'Fotografiere den Moment, wenn es "laut" und emotional wird (Trommeln, Gesang).', 'en' => 'Photograph the moment when it gets "loud" and emotional (drums, singing).'], $emotions['Rührung'], 20),
$this->taskDefinition('w-trad-3', ['de' => 'Halte eine traditionelle Zeremonie oder einen Brauch fest.', 'en' => 'Capture a traditional ceremony or custom.'], $emotions['Nostalgie'], 30),
$this->taskDefinition('w-trad-4', ['de' => 'Fotografiere das traditionellste Kleidungsstück oder Schmuckstück im Raum.', 'en' => 'Photograph the most traditional piece of clothing or jewelry in the room.'], $emotions['Stolz'], 40),
$this->taskDefinition('w-trad-5', ['de' => 'Macht ein Foto vom Anschneiden der Torte (oder einer anderen Speisen-Tradition).', 'en' => 'Take a photo of the cutting of the cake (or another food tradition).'], $emotions['Freude'], 50),
$this->taskDefinition('w-trad-6', ['de' => 'Finde jemanden, der eine rührende Geschichte über eine alte Tradition erzählen kann.', 'en' => 'Find someone who can tell a touching story about an old tradition.'], $emotions['Rührung'], 60),
$this->taskDefinition('w-trad-7', ['de' => 'Macht ein Foto von der "Geld-Übergabe" oder einem Geschenkritual.', 'en' => 'Take a photo of the "money handover" or a gift ritual.'], $emotions['Überraschung'], 70),
$this->taskDefinition('w-trad-8', ['de' => 'Fotografiere die größte Gruppe von Verwandten auf einem Bild.', 'en' => 'Photograph the largest group of relatives in one picture.'], $emotions['Teamgeist'], 80),
$this->taskDefinition('w-trad-9', ['de' => 'Macht ein Foto von dem Moment, wenn das Brautpaar hochgehoben wird.', 'en' => 'Take a photo of the moment when the couple is lifted up.'], $emotions['Ekstase'], 90),
$this->taskDefinition('w-trad-10', ['de' => 'Halte den Segen oder einen spirituellen Moment fest.', 'en' => 'Capture the blessing or a spiritual moment.'], $emotions['Besinnlichkeit'], 100),
],
],
'wedding_nerdy' => [
'event_type' => $weddingType,
'collection' => [
'slug' => 'wedding-booster-nerdy',
'name' => ['de' => 'Booster: Nerd-Hochzeit', 'en' => 'Booster: Nerdy Wedding'],
'description' => ['de' => '10 Aufgaben für Gamer, Filmfans und Technik-Enthusiasten.', 'en' => '10 tasks for gamers, movie fans and tech enthusiasts.'],
'is_default' => false,
'position' => 16,
],
'base_tasks' => [
$this->taskDefinition('w-nerd-1', ['de' => 'Stellt eine epische Szene aus einem Film oder Spiel nach (Besteck-Lichtschwert!).', 'en' => 'Re-enact an epic scene from a movie or game (cutlery lightsaber!).'], $emotions['Teamgeist'], 10),
$this->taskDefinition('w-nerd-2', ['de' => 'Finde das versteckte "Easter Egg" in der Deko.', 'en' => 'Find the hidden "Easter Egg" in the decoration.'], $emotions['Überraschung'], 20),
$this->taskDefinition('w-nerd-3', ['de' => 'Macht ein "Power-Up"-Foto (Essen oder Trinken macht dich stärker).', 'en' => 'Take a "power-up" photo (eating or drinking makes you stronger).'], $emotions['Ekstase'], 30),
$this->taskDefinition('w-nerd-4', ['de' => 'Fotografiere ein Detail, das eine Anspielung auf ein Fandom ist.', 'en' => 'Photograph a detail that is an allusion to a fandom.'], $emotions['Freude'], 40),
$this->taskDefinition('w-nerd-5', ['de' => 'Macht ein Selfie mit einem "NPC" (jemandem, der gerade nur rumsteht).', 'en' => 'Take a selfie with an "NPC" (someone who is just standing around).'], $emotions['Überraschung'], 50),
$this->taskDefinition('w-nerd-6', ['de' => 'Stellt eine "Victory Royale" Pose nach.', 'en' => 'Re-enact a "Victory Royale" pose.'], $emotions['Stolz'], 60),
$this->taskDefinition('w-nerd-7', ['de' => 'Finde jemanden, der ein nerdiges Tattoo oder Accessoire trägt.', 'en' => 'Find someone wearing a nerdy tattoo or accessory.'], $emotions['Nostalgie'], 70),
$this->taskDefinition('w-nerd-8', ['de' => 'Macht ein Foto, das aussieht wie ein Ladebildschirm.', 'en' => 'Take a photo that looks like a loading screen.'], $emotions['Besinnlichkeit'], 80),
$this->taskDefinition('w-nerd-9', ['de' => 'Findet den "Endgegner" des Buffets (das größte Stück Fleisch oder Torte).', 'en' => 'Find the "end boss" of the buffet (the biggest piece of meat or cake).'], $emotions['Ekstase'], 90),
$this->taskDefinition('w-nerd-10', ['de' => 'Macht ein Gruppenfoto als "Gilde" oder "Squad".', 'en' => 'Take a group photo as a "guild" or "squad".'], $emotions['Teamgeist'], 100),
],
],
'birthday' => [
'event_type' => ['slug' => 'birthday', 'name' => ['de' => 'Geburtstag', 'en' => 'Birthday'], 'icon' => 'lucide-cake'],
'collection' => [
@@ -528,4 +656,4 @@ class TaskCollectionsSeeder extends Seeder
return $emotion;
}
}
}

View File

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

View File

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

View File

@@ -15,11 +15,21 @@ server {
try_files $uri $uri/ /index.php?$query_string;
}
location ^~ /api/ {
try_files $uri /index.php?$query_string;
}
location ~ \.php$ {
include fastcgi_params;
fastcgi_pass app:9000;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info;
fastcgi_param HTTP_X_FORWARDED_PROTO $http_x_forwarded_proto;
fastcgi_param HTTP_X_FORWARDED_HOST $http_x_forwarded_host;
fastcgi_param HTTP_X_FORWARDED_FOR $proxy_add_x_forwarded_for;
fastcgi_param HTTP_HOST $host;
fastcgi_param HTTP_X_FORWARDED_PORT $server_port;
fastcgi_param HTTPS $http_x_forwarded_proto;
fastcgi_buffer_size 32k;
fastcgi_buffers 8 16k;
}

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

View File

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

View File

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

7612
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -22,24 +22,24 @@
"@eslint/js": "^9.19.0",
"@laravel/vite-plugin-wayfinder": "^0.1.7",
"@playwright/test": "^1.57.0",
"@tamagui/cli": "^1.142.0",
"@tamagui/cli": "^1.144.2",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@testing-library/react": "^16.3.1",
"@testing-library/user-event": "^14.6.1",
"@types/fabric": "^5.3.10",
"@types/node": "^22.19.2",
"baseline-browser-mapping": "^2.9.6",
"@types/fabric": "^5.3.11",
"@types/node": "^22.19.7",
"baseline-browser-mapping": "^2.9.15",
"dotenv": "^16.6.1",
"eslint": "^9.39.1",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"i18next-scanner": "^4.6.0",
"jsdom": "^25.0.1",
"playwright": "^1.55.1",
"prettier": "^3.7.4",
"shadcn": "^3.5.2",
"typescript-eslint": "^8.49.0",
"prettier": "^3.8.0",
"shadcn": "^3.7.0",
"typescript-eslint": "^8.53.0",
"vite-plugin-pwa": "^1.2.0",
"vitest": "^2.1.9"
},
@@ -47,7 +47,7 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/utilities": "^3.2.2",
"@headlessui/react": "^2.2.9",
"@inertiajs/react": "^2.2.21",
"@inertiajs/react": "^2.3.10",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
@@ -66,24 +66,24 @@
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@react-spring/web": "^10.0.3",
"@sentry/react": "^10.32.0",
"@sentry/react": "^10.34.0",
"@sentry/tracing": "^7.120.4",
"@sentry/vite-plugin": "^4.6.1",
"@stripe/stripe-js": "^8.5.3",
"@tailwindcss/vite": "^4.1.17",
"@tamagui/button": "~1.139.2",
"@tamagui/config": "~1.139.2",
"@tamagui/font": "~1.139.3",
"@tamagui/group": "~1.139.2",
"@tamagui/list-item": "~1.139.2",
"@tamagui/radio-group": "~1.139.2",
"@tamagui/stacks": "~1.139.2",
"@tamagui/switch": "~1.139.2",
"@tamagui/text": "~1.139.2",
"@tamagui/themes": "~1.139.2",
"@tamagui/vite-plugin": "~1.139.2",
"@tanstack/react-query": "^5.90.12",
"@types/react": "^19.2.7",
"@sentry/vite-plugin": "^4.6.2",
"@stripe/stripe-js": "^8.6.1",
"@tailwindcss/vite": "^4.1.18",
"@tamagui/button": "~1.144.2",
"@tamagui/config": "~1.144.2",
"@tamagui/font": "~1.144.2",
"@tamagui/group": "~1.144.2",
"@tamagui/list-item": "~1.144.2",
"@tamagui/radio-group": "~1.144.2",
"@tamagui/stacks": "~1.144.2",
"@tamagui/switch": "~1.144.2",
"@tamagui/text": "~1.144.2",
"@tamagui/themes": "~1.144.2",
"@tamagui/vite-plugin": "~1.144.2",
"@tanstack/react-query": "^5.90.19",
"@types/react": "^19.2.8",
"@types/react-dom": "^19.2.3",
"@use-gesture/react": "^10.3.1",
"@vitejs/plugin-react": "^4.7.0",
@@ -94,29 +94,29 @@
"embla-carousel": "^8.6.0",
"embla-carousel-autoplay": "^8.6.0",
"embla-carousel-react": "^8.6.0",
"fabric": "^6.9.0",
"framer-motion": "^12.23.26",
"fabric": "^6.9.1",
"framer-motion": "^12.26.2",
"globals": "^15.15.0",
"html5-qrcode": "^2.3.8",
"i18next": "^25.7.2",
"i18next": "^25.7.4",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"laravel-vite-plugin": "^2.0.1",
"lucide-react": "^0.475.0",
"pdf-lib": "^1.17.1",
"react": "^19.2.1",
"react": "^19.2.3",
"react-colorful": "^5.6.1",
"react-dom": "^19.2.1",
"react-dom": "^19.2.3",
"react-hot-toast": "^2.6.0",
"react-i18next": "^16.4.1",
"react-router-dom": "^7.10.1",
"react-i18next": "^16.5.3",
"react-router-dom": "^7.12.0",
"swiper": "^12.0.3",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.0.0",
"tailwindcss-animate": "^1.0.7",
"tamagui": "^1.139.3",
"tamagui": "^1.144.2",
"typescript": "^5.9.3",
"vite": "^7.2.7"
"vite": "^7.3.1"
},
"optionalDependencies": {
"@rollup/rollup-linux-x64-gnu": "4.9.5",

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,490 +1,106 @@
/* Auto-generated by fonts:sync-google */
@font-face {
font-family: 'Roboto';
font-family: 'Manifest Font';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/roboto/Roboto-400-normal.ttf') format('truetype');
src: url('/fonts/google/manifest-font/regular.woff2') format('woff2');
}
@font-face {
font-family: 'Roboto';
font-family: 'Plus Jakarta Sans';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/plus-jakarta-sans/PlusJakartaSans-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Plus Jakarta Sans';
font-style: italic;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/plus-jakarta-sans/PlusJakartaSans-400-italic.ttf') format('truetype');
}
@font-face {
font-family: 'Plus Jakarta Sans';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('/fonts/google/plus-jakarta-sans/PlusJakartaSans-500-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Plus Jakarta Sans';
font-style: italic;
font-weight: 500;
font-display: swap;
src: url('/fonts/google/plus-jakarta-sans/PlusJakartaSans-500-italic.ttf') format('truetype');
}
@font-face {
font-family: 'Plus Jakarta Sans';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('/fonts/google/plus-jakarta-sans/PlusJakartaSans-600-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Plus Jakarta Sans';
font-style: italic;
font-weight: 600;
font-display: swap;
src: url('/fonts/google/plus-jakarta-sans/PlusJakartaSans-600-italic.ttf') format('truetype');
}
@font-face {
font-family: 'Plus Jakarta Sans';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/roboto/Roboto-700-normal.ttf') format('truetype');
src: url('/fonts/google/plus-jakarta-sans/PlusJakartaSans-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Open Sans';
font-family: 'Plus Jakarta Sans';
font-style: italic;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/plus-jakarta-sans/PlusJakartaSans-700-italic.ttf') format('truetype');
}
@font-face {
font-family: 'Space Grotesk';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/open-sans/OpenSans-400-normal.ttf') format('truetype');
src: url('/fonts/google/space-grotesk/SpaceGrotesk-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Open Sans';
font-family: 'Space Grotesk';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('/fonts/google/space-grotesk/SpaceGrotesk-500-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Space Grotesk';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('/fonts/google/space-grotesk/SpaceGrotesk-600-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Space Grotesk';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/open-sans/OpenSans-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Noto Sans JP';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/noto-sans-jp/NotoSansJp-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Noto Sans JP';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/noto-sans-jp/NotoSansJp-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Lato';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/lato/Lato-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Lato';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/lato/Lato-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Montserrat';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/montserrat/Montserrat-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Montserrat';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/montserrat/Montserrat-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/inter/Inter-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/inter/Inter-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/poppins/Poppins-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/poppins/Poppins-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Material Icons';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/material-icons/MaterialIcons-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Roboto Condensed';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/roboto-condensed/RobotoCondensed-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Roboto Condensed';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/roboto-condensed/RobotoCondensed-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Roboto Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/roboto-mono/RobotoMono-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Roboto Mono';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/roboto-mono/RobotoMono-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Arimo';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/arimo/Arimo-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Arimo';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/arimo/Arimo-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Oswald';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/oswald/Oswald-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Oswald';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/oswald/Oswald-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Noto Sans';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/noto-sans/NotoSans-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Noto Sans';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/noto-sans/NotoSans-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Raleway';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/raleway/Raleway-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Raleway';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/raleway/Raleway-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Nunito Sans';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/nunito-sans/NunitoSans-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Nunito Sans';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/nunito-sans/NunitoSans-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Nunito';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/nunito/Nunito-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Nunito';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/nunito/Nunito-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Playfair Display';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/playfair-display/PlayfairDisplay-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Playfair Display';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/playfair-display/PlayfairDisplay-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Ubuntu';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/ubuntu/Ubuntu-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Ubuntu';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/ubuntu/Ubuntu-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Rubik';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/rubik/Rubik-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Rubik';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/rubik/Rubik-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Noto Sans KR';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/noto-sans-kr/NotoSansKr-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Noto Sans KR';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/noto-sans-kr/NotoSansKr-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Roboto Slab';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/roboto-slab/RobotoSlab-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Roboto Slab';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/roboto-slab/RobotoSlab-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'DM Sans';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/dm-sans/DmSans-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'DM Sans';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/dm-sans/DmSans-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Kanit';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/kanit/Kanit-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Kanit';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/kanit/Kanit-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Merriweather';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/merriweather/Merriweather-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Merriweather';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/merriweather/Merriweather-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Work Sans';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/work-sans/WorkSans-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Work Sans';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/work-sans/WorkSans-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'PT Sans';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/pt-sans/PtSans-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'PT Sans';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/pt-sans/PtSans-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Material Symbols Outlined';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/material-symbols-outlined/MaterialSymbolsOutlined-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Material Symbols Outlined';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/material-symbols-outlined/MaterialSymbolsOutlined-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Lora';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/lora/Lora-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Lora';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/lora/Lora-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Quicksand';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/quicksand/Quicksand-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Quicksand';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/quicksand/Quicksand-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Mulish';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/mulish/Mulish-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Mulish';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/mulish/Mulish-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Noto Sans TC';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/noto-sans-tc/NotoSansTc-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Noto Sans TC';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/noto-sans-tc/NotoSansTc-700-normal.ttf') format('truetype');
src: url('/fonts/google/space-grotesk/SpaceGrotesk-700-normal.ttf') format('truetype');
}
@font-face {
@@ -520,337 +136,9 @@
}
@font-face {
font-family: 'Figtree';
font-family: 'Archivo Black';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/figtree/Figtree-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Figtree';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/figtree/Figtree-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Inconsolata';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/inconsolata/Inconsolata-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Inconsolata';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/inconsolata/Inconsolata-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/ibm-plex-sans/IbmPlexSans-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/ibm-plex-sans/IbmPlexSans-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Fira Sans';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/fira-sans/FiraSans-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Fira Sans';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/fira-sans/FiraSans-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Barlow';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/barlow/Barlow-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Barlow';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/barlow/Barlow-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Outfit';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/outfit/Outfit-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Outfit';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/outfit/Outfit-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Source Sans 3';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/source-sans-3/SourceSans3-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Source Sans 3';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/source-sans-3/SourceSans3-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Bebas Neue';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/bebas-neue/BebasNeue-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Titillium Web';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/titillium-web/TitilliumWeb-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Titillium Web';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/titillium-web/TitilliumWeb-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Karla';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/karla/Karla-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Karla';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/karla/Karla-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Material Icons Outlined';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/material-icons-outlined/MaterialIconsOutlined-400-normal.otf') format('opentype');
}
@font-face {
font-family: 'PT Serif';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/pt-serif/PtSerif-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'PT Serif';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/pt-serif/PtSerif-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Noto Serif';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/noto-serif/NotoSerif-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Noto Serif';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/noto-serif/NotoSerif-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Jost';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/jost/Jost-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Jost';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/jost/Jost-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Prompt';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/prompt/Prompt-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Prompt';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/prompt/Prompt-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Heebo';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/heebo/Heebo-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Heebo';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/heebo/Heebo-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Saira';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/saira/Saira-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Saira';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/saira/Saira-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Archivo';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/archivo/Archivo-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Archivo';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/archivo/Archivo-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Fraunces';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/fraunces/Fraunces-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Fraunces';
font-style: italic;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/fraunces/Fraunces-400-italic.ttf') format('truetype');
}
@font-face {
font-family: 'Fraunces';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('/fonts/google/fraunces/Fraunces-500-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Fraunces';
font-style: italic;
font-weight: 500;
font-display: swap;
src: url('/fonts/google/fraunces/Fraunces-500-italic.ttf') format('truetype');
}
@font-face {
font-family: 'Fraunces';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('/fonts/google/fraunces/Fraunces-600-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Fraunces';
font-style: italic;
font-weight: 600;
font-display: swap;
src: url('/fonts/google/fraunces/Fraunces-600-italic.ttf') format('truetype');
}
@font-face {
font-family: 'Fraunces';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/fraunces/Fraunces-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Fraunces';
font-style: italic;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/fraunces/Fraunces-700-italic.ttf') format('truetype');
src: url('/fonts/google/archivo-black/ArchivoBlack-400-normal.ttf') format('truetype');
}

View File

@@ -1 +1,149 @@
{"fonts":[{"family":"Manifest Font","category":"sans-serif","variants":[{"variant":"regular","weight":400,"style":"normal","url":"\/fonts\/google\/manifest-font\/regular.woff2"}]}]}
{
"generated_at": "2026-01-15T21:06:13+01:00",
"source": "google-webfonts",
"count": 5,
"fonts": [
{
"family": "Manifest Font",
"category": "sans-serif",
"variants": [
{
"variant": "regular",
"weight": 400,
"style": "normal",
"url": "/fonts/google/manifest-font/regular.woff2"
}
]
},
{
"family": "Plus Jakarta Sans",
"slug": "plus-jakarta-sans",
"category": "sans-serif",
"variants": [
{
"variant": "regular",
"weight": 400,
"style": "normal",
"url": "/fonts/google/plus-jakarta-sans/PlusJakartaSans-400-normal.ttf"
},
{
"variant": "italic",
"weight": 400,
"style": "italic",
"url": "/fonts/google/plus-jakarta-sans/PlusJakartaSans-400-italic.ttf"
},
{
"variant": 500,
"weight": 500,
"style": "normal",
"url": "/fonts/google/plus-jakarta-sans/PlusJakartaSans-500-normal.ttf"
},
{
"variant": "500italic",
"weight": 500,
"style": "italic",
"url": "/fonts/google/plus-jakarta-sans/PlusJakartaSans-500-italic.ttf"
},
{
"variant": 600,
"weight": 600,
"style": "normal",
"url": "/fonts/google/plus-jakarta-sans/PlusJakartaSans-600-normal.ttf"
},
{
"variant": "600italic",
"weight": 600,
"style": "italic",
"url": "/fonts/google/plus-jakarta-sans/PlusJakartaSans-600-italic.ttf"
},
{
"variant": 700,
"weight": 700,
"style": "normal",
"url": "/fonts/google/plus-jakarta-sans/PlusJakartaSans-700-normal.ttf"
},
{
"variant": "700italic",
"weight": 700,
"style": "italic",
"url": "/fonts/google/plus-jakarta-sans/PlusJakartaSans-700-italic.ttf"
}
]
},
{
"family": "Space Grotesk",
"slug": "space-grotesk",
"category": "sans-serif",
"variants": [
{
"variant": "regular",
"weight": 400,
"style": "normal",
"url": "/fonts/google/space-grotesk/SpaceGrotesk-400-normal.ttf"
},
{
"variant": 500,
"weight": 500,
"style": "normal",
"url": "/fonts/google/space-grotesk/SpaceGrotesk-500-normal.ttf"
},
{
"variant": 600,
"weight": 600,
"style": "normal",
"url": "/fonts/google/space-grotesk/SpaceGrotesk-600-normal.ttf"
},
{
"variant": 700,
"weight": 700,
"style": "normal",
"url": "/fonts/google/space-grotesk/SpaceGrotesk-700-normal.ttf"
}
]
},
{
"family": "Manrope",
"slug": "manrope",
"category": "sans-serif",
"variants": [
{
"variant": "regular",
"weight": 400,
"style": "normal",
"url": "/fonts/google/manrope/Manrope-400-normal.ttf"
},
{
"variant": 500,
"weight": 500,
"style": "normal",
"url": "/fonts/google/manrope/Manrope-500-normal.ttf"
},
{
"variant": 600,
"weight": 600,
"style": "normal",
"url": "/fonts/google/manrope/Manrope-600-normal.ttf"
},
{
"variant": 700,
"weight": 700,
"style": "normal",
"url": "/fonts/google/manrope/Manrope-700-normal.ttf"
}
]
},
{
"family": "Archivo Black",
"slug": "archivo-black",
"category": "sans-serif",
"variants": [
{
"variant": "regular",
"weight": 400,
"style": "normal",
"url": "/fonts/google/archivo-black/ArchivoBlack-400-normal.ttf"
}
]
}
]
}

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