Compare commits

...

127 Commits

Author SHA1 Message Date
Codex Agent
2e78f3ab8d Update marketing packages and checkout copy
Some checks are pending
linter / quality (push) Waiting to run
tests / ci (push) Waiting to run
tests / ui (push) Waiting to run
2026-02-01 13:04:11 +01:00
Codex Agent
386d0004ed Modernize guest PWA header and homepage
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-31 23:15:44 +01:00
Codex Agent
e233cddcc8 Publish Livewire assets in Docker build
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-31 22:10:11 +01:00
Codex Agent
e39ddd2143 Adjust branding defaults and tenant presets
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-30 18:15:52 +01:00
Codex Agent
b1f9f7cee0 Fix TypeScript typecheck errors
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-30 15:56:06 +01:00
Codex Agent
916b204688 Harden tenant admin auth and photo moderation
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-30 14:53:51 +01:00
Codex Agent
d45cb6a087 Stop tracking beads runtime file
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-30 14:09:55 +01:00
Codex Agent
f574ffaf38 Remove legacy registration page assets
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-30 13:54:35 +01:00
Codex Agent
b866179521 Remove missing doc from docs sidebar
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-30 13:26:30 +01:00
Codex Agent
3ba784154b Share CSRF headers across guest uploads
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-30 13:10:19 +01:00
Codex Agent
96aaea23e4 Fix guest upload queue endpoint
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-30 13:06:26 +01:00
Codex Agent
4b1785fb85 Update beads tracker
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-30 13:01:29 +01:00
Codex Agent
8aba034344 Respect cache-control in guest API cache 2026-01-30 13:00:19 +01:00
Codex Agent
19425c0f62 Document dynamic security review checklists
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-30 12:27:15 +01:00
Codex Agent
1443ff0d3a Add marketing hreflang tests and docs
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-30 11:52:44 +01:00
Codex Agent
e48ec3c564 Add storage checksum env defaults 2026-01-30 11:52:20 +01:00
Codex Agent
eeffe4c6f1 Add checksum validation for archived media
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-30 11:29:40 +01:00
Codex Agent
9a8305d986 Add Uptime Kuma monitoring template
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-30 11:12:15 +01:00
Codex Agent
6ca0b50403 Make queue health widget full width
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-29 11:25:21 +01:00
Codex Agent
ce7da1ff66 Read Dokploy environments for composes
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-29 11:17:41 +01:00
Codex Agent
87f348462b Load Dokploy project details for compose data
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-29 11:00:53 +01:00
Codex Agent
dba0cd5882 Handle Dokploy project composes in widget
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-29 10:45:21 +01:00
Codex Agent
78af7838bf Use Dokploy projects in dashboard widget
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-29 10:40:10 +01:00
Codex Agent
b8bb7926c0 Expand support API contract coverage
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-29 07:42:53 +01:00
Codex Agent
6e4656946c Expand support API integration tests and add load script
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-28 21:49:16 +01:00
Codex Agent
c94fbe4ab8 Require current password on profile password change
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-28 21:34:27 +01:00
Codex Agent
9ccf079a3a Register support API token widget
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-28 21:30:49 +01:00
Codex Agent
e0e9723b11 Add support API token management to profile
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-28 21:24:37 +01:00
Codex Agent
0d2759b0d4 Fix support API audit logging
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-28 21:02:25 +01:00
Codex Agent
f0e8cee850 Expand support API validation for writable resources
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-28 20:46:12 +01:00
Codex Agent
981df2ee45 Add support API validation rules
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-28 19:42:28 +01:00
Codex Agent
6bc1d86009 Tighten support API resource mutations
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-28 18:34:12 +01:00
Codex Agent
53a6500e6a Add support API scaffold
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-28 13:52:47 +01:00
Codex Agent
75c4dbd1f0 Add spacing between tabs 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-25 15:53:00 +01:00
Codex Agent
5d48b804a5 Move packages tabs further up
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-25 15:50:41 +01:00
Codex Agent
80dca9fe67 Adjust packages tabs label and spacing
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-25 15:47:52 +01:00
Codex Agent
78bd3c9267 Allow superadmin to bypass onboarding billing
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-25 00:05:34 +01:00
Codex Agent
c4ac38e41a Relax style-src-elem to allow inline
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-24 23:41:53 +01:00
Codex Agent
84e253b61c Allow inline style tags and remove Bunny font
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-24 23:34:10 +01:00
Codex Agent
8414305ea3 Fix CSP style-src-elem allowlist
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-24 23:16:23 +01:00
Codex Agent
694ce218c9 Adjust packages tabs spacing
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-24 22:59:12 +01:00
Codex Agent
ec98086e23 Refine packages hero and translations
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-24 22:55:14 +01:00
Codex Agent
d87d22fa22 Redesign marketing packages layout
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-24 22:30:03 +01:00
Codex Agent
a21321bb3c Allow inline style elements for event-admin CSP
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-24 21:16:31 +01:00
Codex Agent
7a91e40bb3 Allow inline style elements for event-admin CSP
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-24 21:02:33 +01:00
Codex Agent
71604c6e41 Fix CSP nonce timing for admin styles
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-24 20:54:23 +01:00
Codex Agent
2b4d9e9411 Add CSP nonce for Tamagui styles
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-24 20:38:36 +01:00
Codex Agent
35d8c94c11 Update Dokploy compose for prod/staging
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-24 11:12:53 +01:00
Codex Agent
ce43cac145 Fix foldable background layout
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-24 10:41:24 +01:00
Codex Agent
b11f010938 refactor(checkout): wrap auth step buttons in shadcn tabs
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-24 09:50:06 +01:00
Codex Agent
e3b356e810 Enable foldable background presets
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-24 09:02:52 +01:00
Codex Agent
6bd75b0788 Add more invite background presets
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 22:54:54 +01:00
Codex Agent
14bb375674 Add from-disk rebuild for font manifest
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 21:46:09 +01:00
Codex Agent
a33bf0e3a4 Scope social login callbacks per flow
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 20:38:22 +01:00
Codex Agent
1241f5092e Remove Google helper badge in checkout auth
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 20:24:43 +01:00
Codex Agent
73728f6baf Add Facebook social login
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 20:19:15 +01:00
Codex Agent
db90b9af2e Fix pagination totals for zero counts
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 17:33:18 +01:00
Codex Agent
7dd8bc4c91 Fix tenant admin Google OAuth redirect
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 17:25:12 +01:00
Codex Agent
ee6fb7a5bb Fix event naming and checklist labels
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 17:13:10 +01:00
Codex Agent
1c4c93c547 Simplify guest language selector
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 16:27:48 +01:00
Codex Agent
bdb1789a10 Add guest analytics consent nudge
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 16:20:14 +01:00
Codex Agent
4bf0d5052c Add honeypot protection to contact forms
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 15:38:34 +01:00
Codex Agent
d629b745c4 Add Google login to checkout login form
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 14:17:12 +01:00
Codex Agent
72dd1409e8 Add Google login to mobile admin PWA
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 13:59:14 +01:00
Codex Agent
2729c3c713 Add spacing around KPI separator
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 13:40:46 +01:00
Codex Agent
4135deb110 Update dashboard live show KPI label (DE)
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 13:34:00 +01:00
Codex Agent
fda97b3c05 Update dashboard KPIs for live show and auto-approval
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 13:31:50 +01:00
Codex Agent
55608c311d Add dashboard action colors and admin help translations
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 13:14:33 +01:00
Codex Agent
ead80025fc Update admin theme palette and heading font
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 12:36:57 +01:00
Codex Agent
d000d9b456 Record bd issue activity
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 12:25:09 +01:00
Codex Agent
ebfcc090d6 Fix admin PWA status badge contrast 2026-01-23 12:24:09 +01:00
Codex Agent
49c4f9ad7d Tweak German admin copy
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 11:10:32 +01:00
Codex Agent
0089a14204 Replace KPI/tenant wording in admin UI and help
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 10:55:24 +01:00
Codex Agent
0eb3b85f06 Add tenant PWA help articles and links
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 10:29:20 +01:00
Codex Agent
db0fdc58a1 Ensure help lists render as lists
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 10:13:39 +01:00
Codex Agent
0db0ddf3c4 Add related help titles and fix umlauts
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 10:05:29 +01:00
Codex Agent
df5e8204fa Add admin FAQ help article
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 09:53:10 +01:00
Codex Agent
6f7bf818dd Add control room help article and move ops docs
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 09:47:58 +01:00
Codex Agent
b3ea522e31 Remove ops-only help articles
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 09:42:10 +01:00
Codex Agent
b267ae2c15 Refresh admin help articles for event PWA
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 09:35:26 +01:00
Codex Agent
4706b21d22 Acknowledge bd 0.49 upgrade
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 09:26:26 +01:00
Codex Agent
96e65ffc0b Acknowledge bd version
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 09:22:47 +01:00
Codex Agent
348834250a 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-23 09:21:00 +01:00
Codex Agent
31a5148263 Update help system issue status
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 09:20:07 +01:00
Codex Agent
35f28fd48d Add contextual help links to admin pages 2026-01-23 09:18:46 +01:00
Codex Agent
53a90fec33 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-23 08:56:44 +01:00
Codex Agent
e1a2850768 Add admin help center entry points 2026-01-23 08:55:37 +01:00
Codex Agent
03ee16bb87 Read admin theme colors from CSS vars
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 22:31:18 +01:00
Codex Agent
1313135020 Fix admin theme dark fallbacks
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 22:14:18 +01:00
Codex Agent
85f2c42fc5 Improve admin mobile dark mode contrast
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 22:02:45 +01:00
Codex Agent
6318aec3cb Replace checklist badge with check icon
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 21:26:24 +01:00
Codex Agent
056d864f80 Compact tasks toggle and title
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 21:15:55 +01:00
Codex Agent
ef88342bd0 Polish tasks hero and dialog
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 21:10:21 +01:00
Codex Agent
d76b26b7ad Style collection import CTA
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 21:03:41 +01:00
Codex Agent
c1dfbaa51e Tighten tasks tab controls
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 20:59:25 +01:00
Codex Agent
32644eb41e Restructure event tasks layout
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 20:50:38 +01:00
Codex Agent
db5fea9f2a Refactor event tasks tabs
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 20:33:02 +01:00
Codex Agent
fba9714ede Switch tasks quick nav to tabs
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 19:48:22 +01:00
Codex Agent
cebc1d1ec5 Simplify hero toggles
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 17:34:42 +01:00
Codex Agent
5aa79b587d Add hero quick settings toggles
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 17:30:51 +01:00
Codex Agent
2e089f7f77 Unify setup status block
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 17:17:10 +01:00
Codex Agent
fd52f8e13d Tighten KPI card layout
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 17:05:42 +01:00
Codex Agent
8ac38cf264 Adjust KPI strip layout
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 17:01:54 +01:00
Codex Agent
66193a6461 Compact dashboard overview
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 16:51:05 +01:00
Codex Agent
64c9d7357a Embed quick actions header
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 16:31:46 +01:00
Codex Agent
8aa2efdd9a Refine dashboard overview layout
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 16:24:48 +01:00
Codex Agent
4f3503e3f4 Refactor mobile dashboard layout
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 16:13:22 +01:00
Codex Agent
4235eda49a Fix guest PWA dark mode contrast
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 15:47:26 +01:00
Codex Agent
ad0e8b7923 Allow longer blog post excerpts
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 15:10:50 +01:00
Codex Agent
446eb15c6b Fix blog post image upload storage
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 15:03:05 +01:00
Codex Agent
02a24877f7 chore: shift blog post published_at dates 3 weeks into the future
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 14:47:08 +01:00
Codex Agent
f016004b2b Update gallery retention copy
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 14:26:20 +01:00
Codex Agent
a0248d976b Refine photobooth timeline label and video note
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 14:23:53 +01:00
Codex Agent
99a880854a Adjust photobooth timeline step
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 14:16:52 +01:00
Codex Agent
a3747138a4 Expand photobooth info on how-it-works
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 14:09:20 +01:00
Codex Agent
287cc8a532 Refine photobooth wording and add FAQ
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 14:00:21 +01:00
Codex Agent
191f39cf5b Add photobooth connect marketing copy
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 13:54:29 +01:00
Codex Agent
543b3015ca UI: Change PWA header icon backgrounds to primary color
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 13:37:33 +01:00
Codex Agent
9d3c866562 Fix: Add missing 'text' variable to EventControlRoomPage theme destructuring
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 13:35:09 +01:00
Codex Agent
911880f1a0 Refactor: Update Tenant PWA headers and tabs to use Playfair Display and Tamagui components
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 13:29:56 +01:00
Codex Agent
b9d91c8f40 Improve marketing language switcher
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 09:07:46 +01:00
Codex Agent
23193a3452 Adjust package CTA split and label
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 08:48:33 +01:00
Codex Agent
da6f95aead Add order CTA links on packages overview
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-21 22:07:46 +01:00
Codex Agent
2f9a700e00 Fix guest demo UX and enforce guest limits
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-21 21:35:40 +01:00
Codex Agent
50cc4e76df Add marketing motion reveals to blog and occasions
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-21 15:22:39 +01:00
Codex Agent
941931934f Update 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-21 12:55:55 +01:00
Codex Agent
9b245e9c51 Update marketing packages testimonials and demo 2026-01-21 12:48:34 +01:00
334 changed files with 446423 additions and 431393 deletions

View File

@@ -17,6 +17,7 @@
{"id":"fotospiel-app-38f","title":"Paddle catalog sync: surface last sync error/log context in admin","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:59:14.865414785+01:00","created_by":"soeren","updated_at":"2026-01-02T21:16:09.109922491+01:00","closed_at":"2026-01-02T21:16:09.109922491+01:00","close_reason":"Completed"}
{"id":"fotospiel-app-3ut","title":"SEC-API-03 Synthetic monitoring + alert config","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:52:46.793875724+01:00","created_by":"soeren","updated_at":"2026-01-01T15:52:46.793875724+01:00"}
{"id":"fotospiel-app-3xa","title":"Security review: event admin code audit (policies, PKCE, file handling)","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:05:20.115675149+01:00","created_by":"soeren","updated_at":"2026-01-01T16:05:20.115675149+01:00"}
{"id":"fotospiel-app-43mp","title":"Help-System für Event Admin PWA planen","notes":"Context help links wired into priority admin pages.","status":"in_progress","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-23T08:21:47.812129626+01:00","created_by":"Codex Agent","updated_at":"2026-01-23T09:19:45.828239299+01:00"}
{"id":"fotospiel-app-4ar","title":"SEC-BILL-03 Failed capture notifications + ledger hook","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:54:33.266516715+01:00","created_by":"soeren","updated_at":"2026-01-01T15:54:33.266516715+01:00"}
{"id":"fotospiel-app-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)"}
@@ -37,6 +38,7 @@
{"id":"fotospiel-app-5ie","title":"Help docs: Live Show how-to + recommended hardware (DE/EN)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-05T11:12:05.973844187+01:00","created_by":"soeren","updated_at":"2026-01-05T19:42:44.39939087+01:00","closed_at":"2026-01-05T19:42:44.39939087+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-5ie","depends_on_id":"fotospiel-app-vro","type":"blocks","created_at":"2026-01-05T11:13:54.925412888+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-5ie","depends_on_id":"fotospiel-app-539","type":"blocks","created_at":"2026-01-05T11:14:03.257649076+01:00","created_by":"soeren"}]}
{"id":"fotospiel-app-5iy","title":"Security review: confirm env/header defaults","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:03:20.808188183+01:00","created_by":"soeren","updated_at":"2026-01-01T16:03:26.388002115+01:00","closed_at":"2026-01-01T16:03:26.388002115+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-5s3","title":"Localized SEO: canonical/hreflang tags + localized navigation","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:02:03.909947355+01:00","created_by":"soeren","updated_at":"2026-01-01T16:02:09.550647107+01:00","closed_at":"2026-01-01T16:02:09.550647107+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-5veo","title":"Investigate vite build timeout","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-21T12:49:14.166622473+01:00","created_by":"Codex Agent","updated_at":"2026-01-21T12:49:14.166622473+01:00"}
{"id":"fotospiel-app-5zl","title":"Ensure checkout step 3 requires login for Paddle checkout","description":"Problem: Paddle checkout on step 3 fails when user is not logged in. Step 3 must enforce authentication before initializing Paddle checkout.\\n\\nSuggestions:\\n- Protect step 3 route/controller with auth middleware and redirect to login with intended return URL.\\n- Gate step 3 UI/CTA on auth state; show inline login prompt and disable Paddle until authenticated.\\n- Require auth in backend endpoint that creates Paddle transaction/session; return 401 and send user to login.\\n- Optionally preflight at end of step 2 to prompt login before advancing.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-04T12:31:43.215017311+01:00","created_by":"soeren","updated_at":"2026-01-04T12:42:45.088723058+01:00","closed_at":"2026-01-04T12:42:45.088723058+01:00","close_reason":"Closed"}
{"id":"fotospiel-app-64l","title":"SEC-FE-01 CSP nonce/hashing rollout","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:54:47.607047443+01:00","created_by":"soeren","updated_at":"2026-01-01T15:55:56.477104351+01:00","closed_at":"2026-01-01T15:55:56.477104351+01:00","close_reason":"Completed in codebase (verified) - duplicate of fotospiel-app-zli"}
{"id":"fotospiel-app-6dp","title":"Coupon ops enhancements (redemption service, preview endpoint, widget, export)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:09:09.275919717+01:00","created_by":"soeren","updated_at":"2026-01-01T16:09:14.882264149+01:00","closed_at":"2026-01-01T16:09:14.882264149+01:00","close_reason":"Completed in codebase (verified)"}

View File

@@ -1 +0,0 @@
fotospiel-app-spq8

View File

@@ -1,4 +1,5 @@
{
"database": "beads.db",
"jsonl_export": "issues.jsonl"
}
"jsonl_export": "issues.jsonl",
"last_bd_version": "0.49.0"
}

View File

@@ -97,6 +97,11 @@ GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=${APP_URL}/checkout/auth/google/callback
# Facebook OAuth (Checkout comfort login)
FACEBOOK_CLIENT_ID=
FACEBOOK_CLIENT_SECRET=
FACEBOOK_REDIRECT_URI=${APP_URL}/checkout/auth/facebook/callback
VITE_APP_NAME="${APP_NAME}"
VITE_ENABLE_TENANT_SWITCHER=false
REVENUECAT_WEBHOOK_SECRET=
@@ -187,5 +192,9 @@ STORAGE_QUEUE_PENDING_EVENT_MINUTES=8
STORAGE_QUEUE_FAILED_EVENT_THRESHOLD=2
STORAGE_QUEUE_FAILED_EVENT_MINUTES=30
STORAGE_QUEUE_GUEST_ALERT_TTL=30
STORAGE_CHECKSUM_VALIDATION=true
STORAGE_CHECKSUM_ALERT_WINDOW_MINUTES=60
STORAGE_CHECKSUM_WARNING=1
STORAGE_CHECKSUM_CRITICAL=5

View File

@@ -148,867 +148,6 @@ var tokens = {
size
};
// node_modules/@tamagui/create-theme/dist/esm/isMinusZero.mjs
function isMinusZero(value) {
return 1 / value === Number.NEGATIVE_INFINITY;
}
__name(isMinusZero, "isMinusZero");
// node_modules/@tamagui/create-theme/dist/esm/themeInfo.mjs
var THEME_INFO = /* @__PURE__ */ new Map();
var getThemeInfo = /* @__PURE__ */ __name((theme, name) => THEME_INFO.get(name || JSON.stringify(theme)), "getThemeInfo");
var setThemeInfo = /* @__PURE__ */ __name((theme, info) => {
const next = {
...info,
cache: /* @__PURE__ */ new Map()
};
THEME_INFO.set(info.name || JSON.stringify(theme), next), THEME_INFO.set(JSON.stringify(info.definition), next);
}, "setThemeInfo");
// node_modules/@tamagui/create-theme/dist/esm/createTheme.mjs
var identityCache = /* @__PURE__ */ new Map();
function createTheme(palette, definition, options, name, skipCache = false) {
const cacheKey = skipCache ? "" : JSON.stringify([name, palette, definition, options]);
if (!skipCache && identityCache.has(cacheKey)) return identityCache.get(cacheKey);
const theme = {
...Object.fromEntries(Object.entries(definition).map(([key, offset]) => [key, getValue(palette, offset)])),
...options?.nonInheritedValues
};
return setThemeInfo(theme, {
palette,
definition,
options,
name
}), cacheKey && identityCache.set(cacheKey, theme), theme;
}
__name(createTheme, "createTheme");
var getValue = /* @__PURE__ */ __name((palette, value) => {
if (!palette) throw new Error("No palette!");
if (typeof value == "string") return value;
const max = palette.length - 1, next = (value === 0 ? !isMinusZero(value) : value >= 0) ? value : max + value, index = Math.min(Math.max(0, next), max);
return palette[index];
}, "getValue");
// node_modules/@tamagui/create-theme/dist/esm/helpers.mjs
function objectEntries(obj) {
return Object.entries(obj);
}
__name(objectEntries, "objectEntries");
function objectFromEntries(arr) {
return Object.fromEntries(arr);
}
__name(objectFromEntries, "objectFromEntries");
// node_modules/@tamagui/create-theme/dist/esm/masks.mjs
var createMask = /* @__PURE__ */ __name((createMask2) => typeof createMask2 == "function" ? {
name: createMask2.name || "unnamed",
mask: createMask2
} : createMask2, "createMask");
var skipMask = {
name: "skip-mask",
mask: /* @__PURE__ */ __name((template, opts) => {
const {
skip
} = opts;
return Object.fromEntries(Object.entries(template).filter(([k]) => !skip || !(k in skip)).map(([k, v]) => [k, applyOverrides(k, v, opts)]));
}, "mask")
};
function applyOverrides(key, value, opts) {
let override, strategy = opts.overrideStrategy;
const overrideSwap = opts.overrideSwap?.[key];
if (typeof overrideSwap < "u") override = overrideSwap, strategy = "swap";
else {
const overrideShift = opts.overrideShift?.[key];
if (typeof overrideShift < "u") override = overrideShift, strategy = "shift";
else {
const overrideDefault = opts.override?.[key];
typeof overrideDefault < "u" && (override = overrideDefault, strategy = opts.overrideStrategy);
}
}
return typeof override > "u" || typeof override == "string" ? value : strategy === "swap" ? override : value;
}
__name(applyOverrides, "applyOverrides");
var createIdentityMask = /* @__PURE__ */ __name(() => ({
name: "identity-mask",
mask: /* @__PURE__ */ __name((template, opts) => skipMask.mask(template, opts), "mask")
}), "createIdentityMask");
var createInverseMask = /* @__PURE__ */ __name(() => ({
name: "inverse-mask",
mask: /* @__PURE__ */ __name((template, opts) => {
const inversed = objectFromEntries(objectEntries(template).map(([k, v]) => [k, -v]));
return skipMask.mask(inversed, opts);
}, "mask")
}), "createInverseMask");
var createShiftMask = /* @__PURE__ */ __name(({
inverse
} = {}, defaultOptions) => ({
name: "shift-mask",
mask: /* @__PURE__ */ __name((template, opts) => {
const {
override,
overrideStrategy = "shift",
max: maxIn,
palette,
min = 0,
strength = 1
} = {
...defaultOptions,
...opts
}, values = Object.entries(template), max = maxIn ?? (palette ? Object.values(palette).length - 1 : Number.POSITIVE_INFINITY), out = {};
for (const [key, value] of values) {
if (typeof value == "string") continue;
if (typeof override?.[key] == "number") {
const overrideVal = override[key];
out[key] = overrideStrategy === "shift" ? value + overrideVal : overrideVal;
continue;
}
if (typeof override?.[key] == "string") {
out[key] = override[key];
continue;
}
const isPositive = value === 0 ? !isMinusZero(value) : value >= 0, direction = isPositive ? 1 : -1, invert = inverse ? -1 : 1, next = value + strength * direction * invert, clamped = isPositive ? Math.max(min, Math.min(max, next)) : Math.min(-min, Math.max(-max, next));
out[key] = clamped;
}
return skipMask.mask(out, opts);
}, "mask")
}), "createShiftMask");
var createWeakenMask = /* @__PURE__ */ __name((defaultOptions) => ({
name: "soften-mask",
mask: createShiftMask({}, defaultOptions).mask
}), "createWeakenMask");
var createSoftenMask = createWeakenMask;
var createStrengthenMask = /* @__PURE__ */ __name((defaultOptions) => ({
name: "strengthen-mask",
mask: createShiftMask({
inverse: true
}, defaultOptions).mask
}), "createStrengthenMask");
// node_modules/@tamagui/create-theme/dist/esm/applyMask.mjs
function applyMaskStateless(info, mask, options = {}, parentName) {
const skip = {
...options.skip
};
if (info.options?.nonInheritedValues) for (const key in info.options.nonInheritedValues) skip[key] = 1;
const maskOptions = {
parentName,
palette: info.palette,
...options,
skip
}, template = mask.mask(info.definition, maskOptions), theme = createTheme(info.palette, template);
return {
...info,
cache: /* @__PURE__ */ new Map(),
definition: template,
theme
};
}
__name(applyMaskStateless, "applyMaskStateless");
// node_modules/@tamagui/create-theme/dist/esm/combineMasks.mjs
var combineMasks = /* @__PURE__ */ __name((...masks2) => ({
name: "combine-mask",
mask: /* @__PURE__ */ __name((template, opts) => {
let current = getThemeInfo(template, opts.parentName), theme;
for (const mask2 of masks2) {
if (!current) throw new Error(`Nothing returned from mask: ${current}, for template: ${template} and mask: ${mask2.toString()}, given opts ${JSON.stringify(opts, null, 2)}`);
const next = applyMaskStateless(current, mask2, opts);
current = next, theme = next.theme;
}
return theme;
}, "mask")
}), "combineMasks");
// node_modules/color2k/dist/index.exports.import.es.mjs
function guard(low, high, value) {
return Math.min(Math.max(low, value), high);
}
__name(guard, "guard");
var ColorError = class extends Error {
static {
__name(this, "ColorError");
}
constructor(color2) {
super(`Failed to parse color: "${color2}"`);
}
};
var ColorError$1 = ColorError;
function parseToRgba(color2) {
if (typeof color2 !== "string") throw new ColorError$1(color2);
if (color2.trim().toLowerCase() === "transparent") return [0, 0, 0, 0];
let normalizedColor = color2.trim();
normalizedColor = namedColorRegex.test(color2) ? nameToHex(color2) : color2;
const reducedHexMatch = reducedHexRegex.exec(normalizedColor);
if (reducedHexMatch) {
const arr = Array.from(reducedHexMatch).slice(1);
return [...arr.slice(0, 3).map((x) => parseInt(r(x, 2), 16)), parseInt(r(arr[3] || "f", 2), 16) / 255];
}
const hexMatch = hexRegex.exec(normalizedColor);
if (hexMatch) {
const arr = Array.from(hexMatch).slice(1);
return [...arr.slice(0, 3).map((x) => parseInt(x, 16)), parseInt(arr[3] || "ff", 16) / 255];
}
const rgbaMatch = rgbaRegex.exec(normalizedColor);
if (rgbaMatch) {
const arr = Array.from(rgbaMatch).slice(1);
return [...arr.slice(0, 3).map((x) => parseInt(x, 10)), parseFloat(arr[3] || "1")];
}
const hslaMatch = hslaRegex.exec(normalizedColor);
if (hslaMatch) {
const [h, s, l, a] = Array.from(hslaMatch).slice(1).map(parseFloat);
if (guard(0, 100, s) !== s) throw new ColorError$1(color2);
if (guard(0, 100, l) !== l) throw new ColorError$1(color2);
return [...hslToRgb(h, s, l), Number.isNaN(a) ? 1 : a];
}
throw new ColorError$1(color2);
}
__name(parseToRgba, "parseToRgba");
function hash(str) {
let hash2 = 5381;
let i = str.length;
while (i) {
hash2 = hash2 * 33 ^ str.charCodeAt(--i);
}
return (hash2 >>> 0) % 2341;
}
__name(hash, "hash");
var colorToInt = /* @__PURE__ */ __name((x) => parseInt(x.replace(/_/g, ""), 36), "colorToInt");
var compressedColorMap = "1q29ehhb 1n09sgk7 1kl1ekf_ _yl4zsno 16z9eiv3 1p29lhp8 _bd9zg04 17u0____ _iw9zhe5 _to73___ _r45e31e _7l6g016 _jh8ouiv _zn3qba8 1jy4zshs 11u87k0u 1ro9yvyo 1aj3xael 1gz9zjz0 _3w8l4xo 1bf1ekf_ _ke3v___ _4rrkb__ 13j776yz _646mbhl _nrjr4__ _le6mbhl 1n37ehkb _m75f91n _qj3bzfz 1939yygw 11i5z6x8 _1k5f8xs 1509441m 15t5lwgf _ae2th1n _tg1ugcv 1lp1ugcv 16e14up_ _h55rw7n _ny9yavn _7a11xb_ 1ih442g9 _pv442g9 1mv16xof 14e6y7tu 1oo9zkds 17d1cisi _4v9y70f _y98m8kc 1019pq0v 12o9zda8 _348j4f4 1et50i2o _8epa8__ _ts6senj 1o350i2o 1mi9eiuo 1259yrp0 1ln80gnw _632xcoy 1cn9zldc _f29edu4 1n490c8q _9f9ziet 1b94vk74 _m49zkct 1kz6s73a 1eu9dtog _q58s1rz 1dy9sjiq __u89jo3 _aj5nkwg _ld89jo3 13h9z6wx _qa9z2ii _l119xgq _bs5arju 1hj4nwk9 1qt4nwk9 1ge6wau6 14j9zlcw 11p1edc_ _ms1zcxe _439shk6 _jt9y70f _754zsow 1la40eju _oq5p___ _x279qkz 1fa5r3rv _yd2d9ip _424tcku _8y1di2_ _zi2uabw _yy7rn9h 12yz980_ __39ljp6 1b59zg0x _n39zfzp 1fy9zest _b33k___ _hp9wq92 1il50hz4 _io472ub _lj9z3eo 19z9ykg0 _8t8iu3a 12b9bl4a 1ak5yw0o _896v4ku _tb8k8lv _s59zi6t _c09ze0p 1lg80oqn 1id9z8wb _238nba5 1kq6wgdi _154zssg _tn3zk49 _da9y6tc 1sg7cv4f _r12jvtt 1gq5fmkz 1cs9rvci _lp9jn1c _xw1tdnb 13f9zje6 16f6973h _vo7ir40 _bt5arjf _rc45e4t _hr4e100 10v4e100 _hc9zke2 _w91egv_ _sj2r1kk 13c87yx8 _vqpds__ _ni8ggk8 _tj9yqfb 1ia2j4r4 _7x9b10u 1fc9ld4j 1eq9zldr _5j9lhpx _ez9zl6o _md61fzm".split(" ").reduce((acc, next) => {
const key = colorToInt(next.substring(0, 3));
const hex = colorToInt(next.substring(3)).toString(16);
let prefix = "";
for (let i = 0; i < 6 - hex.length; i++) {
prefix += "0";
}
acc[key] = `${prefix}${hex}`;
return acc;
}, {});
function nameToHex(color2) {
const normalizedColorName = color2.toLowerCase().trim();
const result = compressedColorMap[hash(normalizedColorName)];
if (!result) throw new ColorError$1(color2);
return `#${result}`;
}
__name(nameToHex, "nameToHex");
var r = /* @__PURE__ */ __name((str, amount) => Array.from(Array(amount)).map(() => str).join(""), "r");
var reducedHexRegex = new RegExp(`^#${r("([a-f0-9])", 3)}([a-f0-9])?$`, "i");
var hexRegex = new RegExp(`^#${r("([a-f0-9]{2})", 3)}([a-f0-9]{2})?$`, "i");
var rgbaRegex = new RegExp(`^rgba?\\(\\s*(\\d+)\\s*${r(",\\s*(\\d+)\\s*", 2)}(?:,\\s*([\\d.]+))?\\s*\\)$`, "i");
var hslaRegex = /^hsla?\(\s*([\d.]+)\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%(?:\s*,\s*([\d.]+))?\s*\)$/i;
var namedColorRegex = /^[a-z]+$/i;
var roundColor = /* @__PURE__ */ __name((color2) => {
return Math.round(color2 * 255);
}, "roundColor");
var hslToRgb = /* @__PURE__ */ __name((hue, saturation, lightness) => {
let l = lightness / 100;
if (saturation === 0) {
return [l, l, l].map(roundColor);
}
const huePrime = (hue % 360 + 360) % 360 / 60;
const chroma = (1 - Math.abs(2 * l - 1)) * (saturation / 100);
const secondComponent = chroma * (1 - Math.abs(huePrime % 2 - 1));
let red3 = 0;
let green3 = 0;
let blue3 = 0;
if (huePrime >= 0 && huePrime < 1) {
red3 = chroma;
green3 = secondComponent;
} else if (huePrime >= 1 && huePrime < 2) {
red3 = secondComponent;
green3 = chroma;
} else if (huePrime >= 2 && huePrime < 3) {
green3 = chroma;
blue3 = secondComponent;
} else if (huePrime >= 3 && huePrime < 4) {
green3 = secondComponent;
blue3 = chroma;
} else if (huePrime >= 4 && huePrime < 5) {
red3 = secondComponent;
blue3 = chroma;
} else if (huePrime >= 5 && huePrime < 6) {
red3 = chroma;
blue3 = secondComponent;
}
const lightnessModification = l - chroma / 2;
const finalRed = red3 + lightnessModification;
const finalGreen = green3 + lightnessModification;
const finalBlue = blue3 + lightnessModification;
return [finalRed, finalGreen, finalBlue].map(roundColor);
}, "hslToRgb");
function parseToHsla(color2) {
const [red3, green3, blue3, alpha] = parseToRgba(color2).map((value, index) => (
// 3rd index is alpha channel which is already normalized
index === 3 ? value : value / 255
));
const max = Math.max(red3, green3, blue3);
const min = Math.min(red3, green3, blue3);
const lightness = (max + min) / 2;
if (max === min) return [0, 0, lightness, alpha];
const delta = max - min;
const saturation = lightness > 0.5 ? delta / (2 - max - min) : delta / (max + min);
const hue = 60 * (red3 === max ? (green3 - blue3) / delta + (green3 < blue3 ? 6 : 0) : green3 === max ? (blue3 - red3) / delta + 2 : (red3 - green3) / delta + 4);
return [hue, saturation, lightness, alpha];
}
__name(parseToHsla, "parseToHsla");
function hsla(hue, saturation, lightness, alpha) {
return `hsla(${(hue % 360).toFixed()}, ${guard(0, 100, saturation * 100).toFixed()}%, ${guard(0, 100, lightness * 100).toFixed()}%, ${parseFloat(guard(0, 1, alpha).toFixed(3))})`;
}
__name(hsla, "hsla");
// node_modules/@tamagui/theme-builder/dist/esm/helpers.mjs
var objectKeys = /* @__PURE__ */ __name((obj) => Object.keys(obj), "objectKeys");
function objectFromEntries2(arr) {
return Object.fromEntries(arr);
}
__name(objectFromEntries2, "objectFromEntries");
// node_modules/@tamagui/theme-builder/dist/esm/defaultTemplates.mjs
var getTemplates = /* @__PURE__ */ __name(() => {
const lightTemplates = getBaseTemplates("light"), darkTemplates = getBaseTemplates("dark");
return {
...objectFromEntries2(objectKeys(lightTemplates).map((name) => [`light_${name}`, lightTemplates[name]])),
...objectFromEntries2(objectKeys(darkTemplates).map((name) => [`dark_${name}`, darkTemplates[name]]))
};
}, "getTemplates");
var getBaseTemplates = /* @__PURE__ */ __name((scheme) => {
const isLight = scheme === "light", bgIndex = 6, lighten = isLight ? -1 : 1, darken = -lighten, borderColor = bgIndex + 3, baseColors = {
color: -bgIndex,
colorHover: -bgIndex - 1,
colorPress: -bgIndex,
colorFocus: -bgIndex - 1,
placeholderColor: -bgIndex - 3,
outlineColor: -2
}, base = {
accentBackground: 0,
accentColor: -0,
background0: 1,
background02: 2,
background04: 3,
background06: 4,
background08: 5,
color1: bgIndex,
color2: bgIndex + 1,
color3: bgIndex + 2,
color4: bgIndex + 3,
color5: bgIndex + 4,
color6: bgIndex + 5,
color7: bgIndex + 6,
color8: bgIndex + 7,
color9: bgIndex + 8,
color10: bgIndex + 9,
color11: bgIndex + 10,
color12: bgIndex + 11,
color0: -1,
color02: -2,
color04: -3,
color06: -4,
color08: -5,
// the background, color, etc keys here work like generics - they make it so you
// can publish components for others to use without mandating a specific color scale
// the @tamagui/button Button component looks for `$background`, so you set the
// dark_red_Button theme to have a stronger background than the dark_red theme.
background: bgIndex,
backgroundHover: bgIndex + lighten,
// always lighten on hover no matter the scheme
backgroundPress: bgIndex + darken,
// always darken on press no matter the theme
backgroundFocus: bgIndex + darken,
borderColor,
borderColorHover: borderColor + lighten,
borderColorPress: borderColor + darken,
borderColorFocus: borderColor,
...baseColors,
colorTransparent: -1
}, surface1 = {
...baseColors,
background: base.background + 1,
backgroundHover: base.backgroundHover + 1,
backgroundPress: base.backgroundPress + 1,
backgroundFocus: base.backgroundFocus + 1,
borderColor: base.borderColor + 1,
borderColorHover: base.borderColorHover + 1,
borderColorFocus: base.borderColorFocus + 1,
borderColorPress: base.borderColorPress + 1
}, surface2 = {
...baseColors,
background: base.background + 2,
backgroundHover: base.backgroundHover + 2,
backgroundPress: base.backgroundPress + 2,
backgroundFocus: base.backgroundFocus + 2,
borderColor: base.borderColor + 2,
borderColorHover: base.borderColorHover + 2,
borderColorFocus: base.borderColorFocus + 2,
borderColorPress: base.borderColorPress + 2
}, surface3 = {
...baseColors,
background: base.background + 3,
backgroundHover: base.backgroundHover + 3,
backgroundPress: base.backgroundPress + 3,
backgroundFocus: base.backgroundFocus + 3,
borderColor: base.borderColor + 3,
borderColorHover: base.borderColorHover + 3,
borderColorFocus: base.borderColorFocus + 3,
borderColorPress: base.borderColorPress + 3
}, alt1 = {
color: base.color - 1,
colorHover: base.colorHover - 1,
colorPress: base.colorPress - 1,
colorFocus: base.colorFocus - 1
}, alt2 = {
color: base.color - 2,
colorHover: base.colorHover - 2,
colorPress: base.colorPress - 2,
colorFocus: base.colorFocus - 2
}, inverse = Object.fromEntries(Object.entries(base).map(([key, index]) => [key, -index]));
return {
base,
surface1,
surface2,
surface3,
alt1,
alt2,
inverse
};
}, "getBaseTemplates");
var defaultTemplates = getTemplates();
// node_modules/@tamagui/theme-builder/dist/esm/getThemeSuitePalettes.mjs
var paletteSize = 12;
var generateColorPalette = /* @__PURE__ */ __name(({
palette: buildPalette,
scheme
}) => {
if (!buildPalette) return [];
const {
anchors
} = buildPalette;
let palette = [];
const add = /* @__PURE__ */ __name((h, s, l, a) => {
palette.push(hsla(h, s, l, a ?? 1));
}, "add"), numAnchors = Object.keys(anchors).length;
for (const [anchorIndex, anchor] of anchors.entries()) {
const [h, s, l, a] = [anchor.hue[scheme], anchor.sat[scheme], anchor.lum[scheme], anchor.alpha?.[scheme] ?? 1];
if (anchorIndex !== 0) {
const lastAnchor = anchors[anchorIndex - 1], steps = anchor.index - lastAnchor.index, lastHue = lastAnchor.hue[scheme], lastSat = lastAnchor.sat[scheme], lastLum = lastAnchor.lum[scheme], stepHue = (lastHue - h) / steps, stepSat = (lastSat - s) / steps, stepLum = (lastLum - l) / steps;
for (let step = lastAnchor.index + 1; step < anchor.index; step++) {
const str = anchor.index - step;
add(h + stepHue * str, s + stepSat * str, l + stepLum * str);
}
}
if (add(h, s, l, a), anchorIndex === numAnchors - 1 && palette.length < paletteSize) for (let step = anchor.index + 1; step < paletteSize; step++) add(h, s, l);
}
const background = palette[0], foreground = palette[palette.length - 1], transparentValues = [background, foreground].map((color2) => {
const [h, s, l] = parseToHsla(color2);
return [hsla(h, s, l, 0), hsla(h, s, l, 0.2), hsla(h, s, l, 0.4), hsla(h, s, l, 0.6), hsla(h, s, l, 0.8)];
}), reverseForeground = [...transparentValues[1]].reverse();
return palette = [...transparentValues[0], ...palette, ...reverseForeground], palette;
}, "generateColorPalette");
function getThemeSuitePalettes(palette) {
return {
light: generateColorPalette({
palette,
scheme: "light"
}),
dark: generateColorPalette({
palette,
scheme: "dark"
})
};
}
__name(getThemeSuitePalettes, "getThemeSuitePalettes");
// node_modules/@tamagui/theme-builder/dist/esm/createThemes.mjs
var defaultPalettes = createPalettes(getThemesPalettes({
base: {
palette: ["#fff", "#000"]
},
accent: {
palette: ["#ff0000", "#ff9999"]
}
}));
function getSchemePalette(colors3) {
return {
light: colors3,
dark: [...colors3].reverse()
};
}
__name(getSchemePalette, "getSchemePalette");
function getAnchors(palette) {
const numItems = palette.light.length;
return palette.light.map((lcolor, index) => {
const dcolor = palette.dark[index], [lhue, lsat, llum, lalpha] = parseToHsla(lcolor), [dhue, dsat, dlum, dalpha] = parseToHsla(dcolor);
return {
index: spreadIndex(11, numItems, index),
hue: {
light: lhue,
dark: dhue
},
sat: {
light: lsat,
dark: dsat
},
lum: {
light: llum,
dark: dlum
},
alpha: {
light: lalpha,
dark: dalpha
}
};
});
}
__name(getAnchors, "getAnchors");
function spreadIndex(maxIndex, numItems, index) {
return Math.round(index / (numItems - 1) * maxIndex);
}
__name(spreadIndex, "spreadIndex");
function coerceSimplePaletteToSchemePalette(def) {
return Array.isArray(def) ? getSchemePalette(def) : def;
}
__name(coerceSimplePaletteToSchemePalette, "coerceSimplePaletteToSchemePalette");
function getThemesPalettes(props) {
const base = coerceSimplePaletteToSchemePalette(props.base.palette), accent = props.accent ? coerceSimplePaletteToSchemePalette(props.accent.palette) : null, baseAnchors = getAnchors(base);
function getSubThemesPalettes(defs) {
return Object.fromEntries(Object.entries(defs).map(([key, value]) => [key, {
name: key,
anchors: value.palette ? getAnchors(coerceSimplePaletteToSchemePalette(value.palette)) : baseAnchors
}]));
}
__name(getSubThemesPalettes, "getSubThemesPalettes");
return {
base: {
name: "base",
anchors: baseAnchors
},
...accent && {
accent: {
name: "accent",
anchors: getAnchors(accent)
}
},
...props.childrenThemes && getSubThemesPalettes(props.childrenThemes),
...props.grandChildrenThemes && getSubThemesPalettes(props.grandChildrenThemes)
};
}
__name(getThemesPalettes, "getThemesPalettes");
function createPalettes(palettes) {
const accentPalettes = palettes.accent ? getThemeSuitePalettes(palettes.accent) : null, basePalettes = getThemeSuitePalettes(palettes.base);
return Object.fromEntries(Object.entries(palettes).flatMap(([name, palette]) => {
const palettes2 = getThemeSuitePalettes(palette), oppositePalettes = name.startsWith("accent") ? basePalettes : accentPalettes || basePalettes;
if (!oppositePalettes) return [];
const oppositeLight = oppositePalettes.light, oppositeDark = oppositePalettes.dark, bgOffset = 7;
return [[name === "base" ? "light" : `light_${name}`, [oppositeLight[bgOffset], ...palettes2.light, oppositeLight[oppositeLight.length - bgOffset - 1]]], [name === "base" ? "dark" : `dark_${name}`, [oppositeDark[oppositeDark.length - bgOffset - 1], ...palettes2.dark, oppositeDark[bgOffset]]]];
}));
}
__name(createPalettes, "createPalettes");
// node_modules/@tamagui/theme-builder/dist/esm/defaultTemplatesStronger.mjs
var getTemplates2 = /* @__PURE__ */ __name(() => {
const lightTemplates = getBaseTemplates2("light"), darkTemplates = getBaseTemplates2("dark");
return {
...objectFromEntries2(objectKeys(lightTemplates).map((name) => [`light_${name}`, lightTemplates[name]])),
...objectFromEntries2(objectKeys(darkTemplates).map((name) => [`dark_${name}`, darkTemplates[name]]))
};
}, "getTemplates");
var getBaseTemplates2 = /* @__PURE__ */ __name((scheme) => {
const isLight = scheme === "light", bgIndex = 6, lighten = isLight ? -1 : 1, darken = -lighten, borderColor = bgIndex + 3, baseColors = {
color: -bgIndex,
colorHover: -bgIndex - 1,
colorPress: -bgIndex,
colorFocus: -bgIndex - 1,
placeholderColor: -bgIndex - 3,
outlineColor: -2
}, base = {
accentBackground: 0,
accentColor: -0,
background0: 1,
background02: 2,
background04: 3,
background06: 4,
background08: 5,
color1: bgIndex,
color2: bgIndex + 1,
color3: bgIndex + 2,
color4: bgIndex + 3,
color5: bgIndex + 4,
color6: bgIndex + 5,
color7: bgIndex + 6,
color8: bgIndex + 7,
color9: bgIndex + 8,
color10: bgIndex + 9,
color11: bgIndex + 10,
color12: bgIndex + 11,
color0: -1,
color02: -2,
color04: -3,
color06: -4,
color08: -5,
// the background, color, etc keys here work like generics - they make it so you
// can publish components for others to use without mandating a specific color scale
// the @tamagui/button Button component looks for `$background`, so you set the
// dark_red_Button theme to have a stronger background than the dark_red theme.
background: bgIndex,
backgroundHover: bgIndex + lighten,
// always lighten on hover no matter the scheme
backgroundPress: bgIndex + darken,
// always darken on press no matter the theme
backgroundFocus: bgIndex + darken,
borderColor,
borderColorHover: borderColor + lighten,
borderColorPress: borderColor + darken,
borderColorFocus: borderColor,
...baseColors,
colorTransparent: -1
}, surface1 = {
...baseColors,
background: base.background + 2,
backgroundHover: base.backgroundHover + 2,
backgroundPress: base.backgroundPress + 2,
backgroundFocus: base.backgroundFocus + 2,
borderColor: base.borderColor + 2,
borderColorHover: base.borderColorHover + 2,
borderColorFocus: base.borderColorFocus + 2,
borderColorPress: base.borderColorPress + 2
}, surface2 = {
...baseColors,
background: base.background + 3,
backgroundHover: base.backgroundHover + 3,
backgroundPress: base.backgroundPress + 3,
backgroundFocus: base.backgroundFocus + 3,
borderColor: base.borderColor + 3,
borderColorHover: base.borderColorHover + 3,
borderColorFocus: base.borderColorFocus + 3,
borderColorPress: base.borderColorPress + 3
}, surface3 = {
...baseColors,
background: base.background + 4,
backgroundHover: base.backgroundHover + 4,
backgroundPress: base.backgroundPress + 4,
backgroundFocus: base.backgroundFocus + 4,
borderColor: base.borderColor + 4,
borderColorHover: base.borderColorHover + 4,
borderColorFocus: base.borderColorFocus + 4,
borderColorPress: base.borderColorPress + 4
}, alt1 = {
color: base.color - 1,
colorHover: base.colorHover - 1,
colorPress: base.colorPress - 1,
colorFocus: base.colorFocus - 1
}, alt2 = {
color: base.color - 2,
colorHover: base.colorHover - 2,
colorPress: base.colorPress - 2,
colorFocus: base.colorFocus - 2
}, inverse = Object.fromEntries(Object.entries(base).map(([key, index]) => [key, -index]));
return {
base,
surface1,
surface2,
surface3,
alt1,
alt2,
inverse
};
}, "getBaseTemplates");
var defaultTemplatesStronger = getTemplates2();
// node_modules/@tamagui/theme-builder/dist/esm/defaultTemplatesStrongest.mjs
var getTemplates3 = /* @__PURE__ */ __name(() => {
const lightTemplates = getBaseTemplates3("light"), darkTemplates = getBaseTemplates3("dark");
return {
...objectFromEntries2(objectKeys(lightTemplates).map((name) => [`light_${name}`, lightTemplates[name]])),
...objectFromEntries2(objectKeys(darkTemplates).map((name) => [`dark_${name}`, darkTemplates[name]]))
};
}, "getTemplates");
var getBaseTemplates3 = /* @__PURE__ */ __name((scheme) => {
const isLight = scheme === "light", bgIndex = 6, lighten = isLight ? -1 : 1, darken = -lighten, borderColor = bgIndex + 3, baseColors = {
color: -bgIndex,
colorHover: -bgIndex - 1,
colorPress: -bgIndex,
colorFocus: -bgIndex - 1,
placeholderColor: -bgIndex - 3,
outlineColor: -2
}, base = {
accentBackground: 0,
accentColor: -0,
background0: 1,
background02: 2,
background04: 3,
background06: 4,
background08: 5,
color1: bgIndex,
color2: bgIndex + 1,
color3: bgIndex + 2,
color4: bgIndex + 3,
color5: bgIndex + 4,
color6: bgIndex + 5,
color7: bgIndex + 6,
color8: bgIndex + 7,
color9: bgIndex + 8,
color10: bgIndex + 9,
color11: bgIndex + 10,
color12: bgIndex + 11,
color0: -1,
color02: -2,
color04: -3,
color06: -4,
color08: -5,
// the background, color, etc keys here work like generics - they make it so you
// can publish components for others to use without mandating a specific color scale
// the @tamagui/button Button component looks for `$background`, so you set the
// dark_red_Button theme to have a stronger background than the dark_red theme.
background: bgIndex,
backgroundHover: bgIndex + lighten,
// always lighten on hover no matter the scheme
backgroundPress: bgIndex + darken,
// always darken on press no matter the theme
backgroundFocus: bgIndex + darken,
borderColor,
borderColorHover: borderColor + lighten,
borderColorPress: borderColor + darken,
borderColorFocus: borderColor,
...baseColors,
colorTransparent: -1
}, surface1 = {
...baseColors,
background: base.background + 3,
backgroundHover: base.backgroundHover + 3,
backgroundPress: base.backgroundPress + 3,
backgroundFocus: base.backgroundFocus + 3,
borderColor: base.borderColor + 3,
borderColorHover: base.borderColorHover + 3,
borderColorFocus: base.borderColorFocus + 3,
borderColorPress: base.borderColorPress + 3
}, surface2 = {
...baseColors,
background: base.background + 4,
backgroundHover: base.backgroundHover + 4,
backgroundPress: base.backgroundPress + 4,
backgroundFocus: base.backgroundFocus + 4,
borderColor: base.borderColor + 4,
borderColorHover: base.borderColorHover + 4,
borderColorFocus: base.borderColorFocus + 4,
borderColorPress: base.borderColorPress + 4
}, surface3 = {
...baseColors,
background: base.background + 5,
backgroundHover: base.backgroundHover + 5,
backgroundPress: base.backgroundPress + 5,
backgroundFocus: base.backgroundFocus + 5,
borderColor: base.borderColor + 5,
borderColorHover: base.borderColorHover + 5,
borderColorFocus: base.borderColorFocus + 5,
borderColorPress: base.borderColorPress + 5
}, alt1 = {
color: base.color - 1,
colorHover: base.colorHover - 1,
colorPress: base.colorPress - 1,
colorFocus: base.colorFocus - 1
}, alt2 = {
color: base.color - 2,
colorHover: base.colorHover - 2,
colorPress: base.colorPress - 2,
colorFocus: base.colorFocus - 2
}, inverse = Object.fromEntries(Object.entries(base).map(([key, index]) => [key, -index]));
return {
base,
surface1,
surface2,
surface3,
alt1,
alt2,
inverse
};
}, "getBaseTemplates");
var defaultTemplatesStrongest = getTemplates3();
// node_modules/@tamagui/theme-builder/dist/esm/masks.mjs
var masks = {
identity: createIdentityMask(),
soften: createSoftenMask(),
soften2: createSoftenMask({
strength: 2
}),
soften3: createSoftenMask({
strength: 3
}),
strengthen: createStrengthenMask(),
inverse: createInverseMask(),
inverseSoften: combineMasks(createInverseMask(), createSoftenMask({
strength: 2
})),
inverseSoften2: combineMasks(createInverseMask(), createSoftenMask({
strength: 3
})),
inverseSoften3: combineMasks(createInverseMask(), createSoftenMask({
strength: 4
})),
inverseStrengthen2: combineMasks(createInverseMask(), createStrengthenMask({
strength: 2
})),
strengthenButSoftenBorder: createMask((template, options) => {
const stronger = createStrengthenMask().mask(template, options), softer = createSoftenMask().mask(template, options);
return {
...stronger,
borderColor: softer.borderColor,
borderColorHover: softer.borderColorHover,
borderColorPress: softer.borderColorPress,
borderColorFocus: softer.borderColorFocus
};
}),
soften2Border1: createMask((template, options) => {
const softer2 = createSoftenMask({
strength: 2
}).mask(template, options), softer1 = createSoftenMask({
strength: 1
}).mask(template, options);
return {
...softer2,
borderColor: softer1.borderColor,
borderColorHover: softer1.borderColorHover,
borderColorPress: softer1.borderColorPress,
borderColorFocus: softer1.borderColorFocus
};
}),
soften3FlatBorder: createMask((template, options) => {
const borderMask = createSoftenMask({
strength: 2
}).mask(template, options);
return {
...createSoftenMask({
strength: 3
}).mask(template, options),
borderColor: borderMask.borderColor,
borderColorHover: borderMask.borderColorHover,
borderColorPress: borderMask.borderColorPress,
borderColorFocus: borderMask.borderColorFocus
};
}),
softenBorder: createMask((template, options) => {
const plain = skipMask.mask(template, options), softer = createSoftenMask().mask(template, options);
return {
...plain,
borderColor: softer.borderColor,
borderColorHover: softer.borderColorHover,
borderColorPress: softer.borderColorPress,
borderColorFocus: softer.borderColorFocus
};
}),
softenBorder2: createMask((template, options) => {
const plain = skipMask.mask(template, options), softer = createSoftenMask({
strength: 2
}).mask(template, options);
return {
...plain,
borderColor: softer.borderColor,
borderColorHover: softer.borderColorHover,
borderColorPress: softer.borderColorPress,
borderColorFocus: softer.borderColorFocus
};
})
};
// node_modules/@tamagui/themes/dist/esm/generated-v4.mjs
function t(a) {
let res = {};
@@ -4160,8 +3299,8 @@ var tokens3 = {
...tokens2,
color: {
...tokens2.color,
primary: "#4F46E5",
// Indigo 600
primary: "#FF5A5F",
// Brand Rose
accent: "#F43F5E",
// Rose 500
accentSoft: "#E0E7FF",
@@ -4194,8 +3333,8 @@ var themes3 = {
...themes2.light,
primary: tokens3.color.primary,
accent: tokens3.color.accent,
background: "#F1F5F9",
// Slate 100
background: "#FFF8F5",
// Brand Cream
backgroundHover: "#E2E8F0",
backgroundPress: "#CBD5E1",
backgroundStrong: tokens3.color.surface,
@@ -4223,7 +3362,7 @@ var themes3 = {
...themes2.dark,
primary: tokens3.color.primary,
accent: tokens3.color.accent,
background: "#0B132B",
background: "#171219",
backgroundHover: "#101A36",
backgroundPress: "#132142",
backgroundStrong: "#101A36",
@@ -4263,12 +3402,12 @@ var fonts2 = {
},
heading: {
...defaultConfig.fonts.heading,
family: "Archivo Black",
family: "Fraunces",
weight: sharedWeights
},
display: {
...defaultConfig.fonts.heading,
family: "Archivo Black",
family: "Fraunces",
weight: sharedWeights
}
};

File diff suppressed because it is too large Load Diff

View File

@@ -118,319 +118,8 @@ var isWindowDefined = typeof window < "u";
var isClient = isWeb && isWindowDefined;
var isChrome = typeof navigator < "u" && /Chrome/.test(navigator.userAgent || "");
var isWebTouchable = isClient && ("ontouchstart" in window || navigator.maxTouchPoints > 0);
var isAndroid = false;
var isIos = process.env.TEST_NATIVE_PLATFORM === "ios";
// node_modules/@tamagui/helpers/dist/esm/validStyleProps.mjs
var textColors = {
color: true,
textDecorationColor: true,
textShadowColor: true
};
var tokenCategories = {
radius: {
borderRadius: true,
borderTopLeftRadius: true,
borderTopRightRadius: true,
borderBottomLeftRadius: true,
borderBottomRightRadius: true,
// logical
borderStartStartRadius: true,
borderStartEndRadius: true,
borderEndStartRadius: true,
borderEndEndRadius: true
},
size: {
width: true,
height: true,
minWidth: true,
minHeight: true,
maxWidth: true,
maxHeight: true,
blockSize: true,
minBlockSize: true,
maxBlockSize: true,
inlineSize: true,
minInlineSize: true,
maxInlineSize: true
},
zIndex: {
zIndex: true
},
color: {
backgroundColor: true,
borderColor: true,
borderBlockStartColor: true,
borderBlockEndColor: true,
borderBlockColor: true,
borderBottomColor: true,
borderInlineColor: true,
borderInlineStartColor: true,
borderInlineEndColor: true,
borderTopColor: true,
borderLeftColor: true,
borderRightColor: true,
borderEndColor: true,
borderStartColor: true,
shadowColor: true,
...textColors,
outlineColor: true,
caretColor: true
}
};
var stylePropsUnitless = {
WebkitLineClamp: true,
animationIterationCount: true,
aspectRatio: true,
borderImageOutset: true,
borderImageSlice: true,
borderImageWidth: true,
columnCount: true,
flex: true,
flexGrow: true,
flexOrder: true,
flexPositive: true,
flexShrink: true,
flexNegative: true,
fontWeight: true,
gridRow: true,
gridRowEnd: true,
gridRowGap: true,
gridRowStart: true,
gridColumn: true,
gridColumnEnd: true,
gridColumnGap: true,
gridColumnStart: true,
gridTemplateColumns: true,
gridTemplateAreas: true,
lineClamp: true,
opacity: true,
order: true,
orphans: true,
tabSize: true,
widows: true,
zIndex: true,
zoom: true,
scale: true,
scaleX: true,
scaleY: true,
scaleZ: true,
shadowOpacity: true
};
var stylePropsTransform = {
x: true,
y: true,
scale: true,
perspective: true,
scaleX: true,
scaleY: true,
skewX: true,
skewY: true,
matrix: true,
rotate: true,
rotateY: true,
rotateX: true,
rotateZ: true
};
var stylePropsView = {
backfaceVisibility: true,
borderBottomEndRadius: true,
borderBottomStartRadius: true,
borderBottomWidth: true,
borderLeftWidth: true,
borderRightWidth: true,
borderBlockWidth: true,
borderBlockEndWidth: true,
borderBlockStartWidth: true,
borderInlineWidth: true,
borderInlineEndWidth: true,
borderInlineStartWidth: true,
borderStyle: true,
borderBlockStyle: true,
borderBlockEndStyle: true,
borderBlockStartStyle: true,
borderInlineStyle: true,
borderInlineEndStyle: true,
borderInlineStartStyle: true,
borderTopEndRadius: true,
borderTopStartRadius: true,
borderTopWidth: true,
borderWidth: true,
transform: true,
transformOrigin: true,
alignContent: true,
alignItems: true,
alignSelf: true,
borderEndWidth: true,
borderStartWidth: true,
bottom: true,
display: true,
end: true,
flexBasis: true,
flexDirection: true,
flexWrap: true,
gap: true,
columnGap: true,
rowGap: true,
justifyContent: true,
left: true,
margin: true,
marginBlock: true,
marginBlockEnd: true,
marginBlockStart: true,
marginInline: true,
marginInlineStart: true,
marginInlineEnd: true,
marginBottom: true,
marginEnd: true,
marginHorizontal: true,
marginLeft: true,
marginRight: true,
marginStart: true,
marginTop: true,
marginVertical: true,
overflow: true,
padding: true,
paddingBottom: true,
paddingInline: true,
paddingBlock: true,
paddingBlockStart: true,
paddingInlineEnd: true,
paddingInlineStart: true,
paddingEnd: true,
paddingHorizontal: true,
paddingLeft: true,
paddingRight: true,
paddingStart: true,
paddingTop: true,
paddingVertical: true,
position: true,
right: true,
start: true,
top: true,
inset: true,
insetBlock: true,
insetBlockEnd: true,
insetBlockStart: true,
insetInline: true,
insetInlineEnd: true,
insetInlineStart: true,
direction: true,
shadowOffset: true,
shadowRadius: true,
...tokenCategories.color,
...tokenCategories.radius,
...tokenCategories.size,
...tokenCategories.radius,
...stylePropsTransform,
...stylePropsUnitless,
boxShadow: true,
filter: true,
// RN 0.77+ style props (set REACT_NATIVE_PRE_77=1 for older RN)
...!process.env.REACT_NATIVE_PRE_77 && {
boxSizing: true,
mixBlendMode: true,
outlineColor: true,
outlineSpread: true,
outlineStyle: true,
outlineWidth: true
},
// RN doesn't support specific border styles per-edge
transition: true,
textWrap: true,
backdropFilter: true,
WebkitBackdropFilter: true,
background: true,
backgroundAttachment: true,
backgroundBlendMode: true,
backgroundClip: true,
backgroundColor: true,
backgroundImage: true,
backgroundOrigin: true,
backgroundPosition: true,
backgroundRepeat: true,
backgroundSize: true,
borderBottomStyle: true,
borderImage: true,
borderLeftStyle: true,
borderRightStyle: true,
borderTopStyle: true,
caretColor: true,
clipPath: true,
contain: true,
containerType: true,
content: true,
cursor: true,
float: true,
mask: true,
maskBorder: true,
maskBorderMode: true,
maskBorderOutset: true,
maskBorderRepeat: true,
maskBorderSlice: true,
maskBorderSource: true,
maskBorderWidth: true,
maskClip: true,
maskComposite: true,
maskImage: true,
maskMode: true,
maskOrigin: true,
maskPosition: true,
maskRepeat: true,
maskSize: true,
maskType: true,
objectFit: true,
objectPosition: true,
outlineOffset: true,
overflowBlock: true,
overflowInline: true,
overflowX: true,
overflowY: true,
pointerEvents: true,
scrollbarWidth: true,
textEmphasis: true,
touchAction: true,
transformStyle: true,
userSelect: true,
willChange: true,
...isAndroid ? {
elevationAndroid: true
} : {}
};
var stylePropsFont = {
fontFamily: true,
fontSize: true,
fontStyle: true,
fontWeight: true,
fontVariant: true,
letterSpacing: true,
lineHeight: true,
textTransform: true
};
var stylePropsTextOnly = {
...stylePropsFont,
textAlign: true,
textDecorationLine: true,
textDecorationStyle: true,
...textColors,
textShadowOffset: true,
textShadowRadius: true,
userSelect: true,
selectable: true,
verticalAlign: true,
whiteSpace: true,
wordWrap: true,
textOverflow: true,
textDecorationDistance: true,
cursor: true,
WebkitLineClamp: true,
WebkitBoxOrient: true
};
var stylePropsText = {
...stylePropsView,
...stylePropsTextOnly
};
// node_modules/@tamagui/helpers/dist/esm/withStaticProperties.mjs
var import_react2 = __toESM(require("react"), 1);
var Decorated = Symbol();
@@ -755,7 +444,10 @@ var SizableText2 = (0, import_web4.styled)(import_web4.Text, {
}
});
SizableText2.staticConfig.variants.fontFamily = {
"...": /* @__PURE__ */ __name((_val, extras) => {
"...": /* @__PURE__ */ __name((val, extras) => {
if (val === "inherit") return {
fontFamily: "inherit"
};
const sizeProp = extras.props.size, fontSizeProp = extras.props.fontSize, size = sizeProp === "$true" && fontSizeProp ? fontSizeProp : extras.props.size || "$true";
return getFontSized(size, extras);
}, "...")

View File

@@ -112,7 +112,10 @@ var SizableText2 = (0, import_web2.styled)(import_web2.Text, {
}
});
SizableText2.staticConfig.variants.fontFamily = {
"...": /* @__PURE__ */ __name((_val, extras) => {
"...": /* @__PURE__ */ __name((val, extras) => {
if (val === "inherit") return {
fontFamily: "inherit"
};
const sizeProp = extras.props.size, fontSizeProp = extras.props.fontSize, size = sizeProp === "$true" && fontSizeProp ? fontSizeProp : extras.props.size || "$true";
return getFontSized(size, extras);
}, "...")

View File

@@ -38,6 +38,9 @@ This repository hosts a multi-tenant event photo platform (Laravel 12, PHP 8.3,
- resources/js/admin/ — Tenant Admin PWA source (React 19, Capacitor/TWA ready).
- resources/js/pages/ — Inertia pages (React).
- docs/archive/README.md — historical PRP context.
- Marketing frontend language files:
- Source translations: `resources/lang/{de,en}/marketing.php` and `resources/lang/{de,en}/marketing.json`.
- Runtime i18next JSON served to the frontend: `public/lang/{de,en}/marketing.json` (must stay in sync with the source files).
## Standard Workflows
- Coding tasks (Codegen Agent):

View File

@@ -100,6 +100,8 @@ COPY . .
COPY --from=vendor /var/www/html/vendor ./vendor
COPY --from=node_builder /var/www/html/public/build ./public/build
RUN php artisan vendor:publish --tag=livewire:assets --force --no-interaction
RUN php artisan config:clear \
&& php artisan config:cache \
&& php artisan route:clear \

View File

@@ -46,6 +46,12 @@ class MonitorStorageCommand extends Command
$assetStats = $this->buildAssetStatistics();
$thresholds = $this->capacityThresholds();
$checksumConfig = $this->checksumAlertConfig();
$checksumWindowMinutes = $checksumConfig['window_minutes'];
$checksumThresholds = $checksumConfig['thresholds'];
$checksumMismatches = $checksumConfig['enabled'] && $checksumWindowMinutes > 0
? $this->checksumMismatchCounts($checksumWindowMinutes)
: [];
$alerts = [];
$snapshotTargets = [];
@@ -78,6 +84,7 @@ class MonitorStorageCommand extends Command
];
}
$targetChecksumMismatches = $checksumMismatches[$target->id] ?? 0;
$snapshotTargets[] = [
'id' => $target->id,
'key' => $target->key,
@@ -85,13 +92,35 @@ class MonitorStorageCommand extends Command
'is_hot' => (bool) $target->is_hot,
'capacity' => $capacity,
'assets' => $assets,
'checksum_mismatches' => [
'count' => $targetChecksumMismatches,
'window_minutes' => $checksumWindowMinutes,
],
];
}
if ($checksumConfig['enabled'] && $checksumWindowMinutes > 0) {
$totalMismatches = array_sum($checksumMismatches);
$checksumSeverity = $this->determineChecksumSeverity($totalMismatches, $checksumThresholds);
if ($checksumSeverity !== 'ok') {
$alerts[] = [
'type' => 'checksum_mismatch',
'severity' => $checksumSeverity,
'count' => $totalMismatches,
'window_minutes' => $checksumWindowMinutes,
];
}
}
$snapshot = [
'generated_at' => now()->toIso8601String(),
'targets' => $snapshotTargets,
'alerts' => $alerts,
'checksum' => [
'window_minutes' => $checksumWindowMinutes,
'mismatch_total' => array_sum($checksumMismatches),
],
];
$ttlMinutes = max(1, (int) config('storage-monitor.monitor.cache_minutes', 15));
@@ -191,4 +220,62 @@ class MonitorStorageCommand extends Command
return 'ok';
}
private function checksumAlertConfig(): array
{
$enabled = (bool) config('storage-monitor.checksum_validation.enabled', true);
$windowMinutes = max(0, (int) config('storage-monitor.checksum_validation.alert_window_minutes', 60));
$warning = (int) config('storage-monitor.checksum_validation.thresholds.warning', 1);
$critical = (int) config('storage-monitor.checksum_validation.thresholds.critical', 5);
if ($warning > $critical && $critical > 0) {
[$warning, $critical] = [$critical, $warning];
}
return [
'enabled' => $enabled,
'window_minutes' => $windowMinutes,
'thresholds' => [
'warning' => $warning,
'critical' => $critical,
],
];
}
private function checksumMismatchCounts(int $windowMinutes): array
{
$query = EventMediaAsset::query()
->selectRaw('media_storage_target_id, COUNT(*) as total_count')
->where('status', 'failed')
->where('meta->checksum_status', 'mismatch');
if ($windowMinutes > 0) {
$query->where('updated_at', '>=', now()->subMinutes($windowMinutes));
}
return $query->groupBy('media_storage_target_id')
->get()
->mapWithKeys(fn ($row) => [(int) $row->media_storage_target_id => (int) $row->total_count])
->all();
}
private function determineChecksumSeverity(int $count, array $thresholds): string
{
$warning = (int) ($thresholds['warning'] ?? 1);
$critical = (int) ($thresholds['critical'] ?? 5);
if ($count <= 0) {
return 'ok';
}
if ($critical > 0 && $count >= $critical) {
return 'critical';
}
if ($warning > 0 && $count >= $warning) {
return 'warning';
}
return 'ok';
}
}

View File

@@ -12,7 +12,7 @@ use Illuminate\Support\Str;
class SyncGoogleFonts extends Command
{
protected $signature = 'fonts:sync-google {--count=50 : Number of popular fonts to fetch} {--weights=400,700 : Comma separated numeric font weights to download} {--italic : Also download italic variants where available} {--force : Re-download files even if they exist} {--path= : Optional custom output directory (defaults to public/fonts/google)} {--family= : Download specific family name(s), comma separated (case-insensitive)} {--category= : Filter by category, comma separated (e.g. sans-serif,serif)} {--prune : Remove local font families not included in this sync} {--dry-run : Show what would be downloaded without writing files}';
protected $signature = 'fonts:sync-google {--count=50 : Number of popular fonts to fetch} {--weights=400,700 : Comma separated numeric font weights to download} {--italic : Also download italic variants where available} {--force : Re-download files even if they exist} {--path= : Optional custom output directory (defaults to public/fonts/google)} {--family= : Download specific family name(s), comma separated (case-insensitive)} {--category= : Filter by category, comma separated (e.g. sans-serif,serif)} {--prune : Remove local font families not included in this sync} {--dry-run : Show what would be downloaded without writing files} {--from-disk : Rebuild manifest + CSS from existing font files without downloading}';
protected $description = 'Download the most popular Google Fonts to the local public/fonts/google directory and generate a manifest + CSS file.';
@@ -20,6 +20,17 @@ class SyncGoogleFonts extends Command
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');
$fromDisk = (bool) $this->option('from-disk');
$pathOption = $this->option('path');
$basePath = $pathOption
? (Str::startsWith($pathOption, DIRECTORY_SEPARATOR) ? $pathOption : base_path($pathOption))
: public_path('fonts/google');
if ($fromDisk) {
return $this->syncFromDisk($basePath, $dryRun);
}
$apiKey = config('services.google_fonts.key');
if (! $apiKey) {
@@ -32,16 +43,10 @@ class SyncGoogleFonts extends Command
$weights = $this->prepareWeights($this->option('weights'));
$includeItalic = (bool) $this->option('italic');
$force = (bool) $this->option('force');
$dryRun = (bool) $this->option('dry-run');
$families = $this->normalizeFamilyOption($this->option('family'));
$categories = $this->prepareCategories($this->option('category'));
$prune = (bool) $this->option('prune');
$pathOption = $this->option('path');
$basePath = $pathOption
? (Str::startsWith($pathOption, DIRECTORY_SEPARATOR) ? $pathOption : base_path($pathOption))
: public_path('fonts/google');
if (count($families)) {
$label = count($families) > 1 ? 'families' : 'family';
$this->info(sprintf('Fetching Google Font %s "%s" (weights: %s, italic: %s)...', $label, implode(', ', $families), implode(', ', $weights), $includeItalic ? 'yes' : 'no'));
@@ -206,6 +211,204 @@ class SyncGoogleFonts extends Command
return self::SUCCESS;
}
private function syncFromDisk(string $basePath, bool $dryRun): int
{
if (! File::isDirectory($basePath)) {
$this->error(sprintf('Font directory not found: %s', $basePath));
return self::FAILURE;
}
if ($this->option('prune')) {
$this->warn('Ignoring --prune when rebuilding from disk.');
}
$fonts = $this->buildManifestFromDisk($basePath);
if (! count($fonts)) {
$this->warn('No fonts found on disk.');
}
if ($dryRun) {
$this->info(sprintf('Dry run complete: %d font families would be written to %s', count($fonts), $basePath));
return self::SUCCESS;
}
$this->writeManifest($basePath, $fonts);
$this->writeCss($basePath, $fonts);
Cache::forget('fonts:manifest');
$this->info(sprintf('Rebuilt manifest for %d font families from %s', count($fonts), $basePath));
return self::SUCCESS;
}
/**
* @return array<int, array<string, mixed>>
*/
private function buildManifestFromDisk(string $basePath): array
{
$directories = File::directories($basePath);
$fonts = [];
foreach ($directories as $dir) {
$slug = basename($dir);
$files = collect(File::files($dir))
->filter(function (\SplFileInfo $file) {
$extension = strtolower($file->getExtension());
return in_array($extension, ['woff2', 'woff', 'otf', 'ttf'], true);
})
->values();
if (! $files->count()) {
continue;
}
$variantsByKey = [];
foreach ($files as $file) {
$filename = $file->getFilename();
$extension = strtolower($file->getExtension());
$style = $this->extractStyleFromFilename($filename);
$weight = $this->extractWeightFromFilename($filename);
$variantKey = $this->buildVariantKey($weight, $style);
$priority = $this->extensionPriority($extension);
$relativePath = sprintf('/fonts/google/%s/%s', $slug, $filename);
$existing = $variantsByKey[$variantKey] ?? null;
if ($existing && ($existing['priority'] ?? 0) >= $priority) {
continue;
}
$variantsByKey[$variantKey] = [
'variant' => $variantKey,
'weight' => $weight,
'style' => $style,
'url' => $relativePath,
'priority' => $priority,
];
}
if (! count($variantsByKey)) {
continue;
}
$variants = array_values(array_map(function (array $variant) {
unset($variant['priority']);
return $variant;
}, $variantsByKey));
usort($variants, function (array $left, array $right) {
$weightCompare = ($left['weight'] ?? 400) <=> ($right['weight'] ?? 400);
if ($weightCompare !== 0) {
return $weightCompare;
}
return strcmp((string) ($left['style'] ?? 'normal'), (string) ($right['style'] ?? 'normal'));
});
$fonts[] = [
'family' => $this->familyFromSlug($slug),
'slug' => $slug,
'category' => null,
'variants' => $variants,
];
}
usort($fonts, fn (array $left, array $right) => strcmp((string) $left['family'], (string) $right['family']));
return $fonts;
}
private function familyFromSlug(string $slug): string
{
$parts = array_filter(explode('-', $slug), fn ($part) => $part !== '');
$words = array_map(function (string $part) {
if (is_numeric($part)) {
return $part;
}
if (strlen($part) <= 3) {
return strtoupper($part);
}
return ucfirst(strtolower($part));
}, $parts);
return trim(implode(' ', $words));
}
private function extractStyleFromFilename(string $filename): string
{
$lower = strtolower($filename);
return str_contains($lower, 'italic') || str_contains($lower, 'oblique') ? 'italic' : 'normal';
}
private function extractWeightFromFilename(string $filename): int
{
if (preg_match('/(?:^|[^0-9])(100|200|300|400|500|600|700|800|900)(?:[^0-9]|$)/', $filename, $matches)) {
return (int) $matches[1];
}
$lower = strtolower($filename);
$weightMap = [
'thin' => 100,
'extralight' => 200,
'ultralight' => 200,
'light' => 300,
'regular' => 400,
'book' => 400,
'medium' => 500,
'semibold' => 600,
'demibold' => 600,
'bold' => 700,
'extrabold' => 800,
'ultrabold' => 800,
'black' => 900,
'heavy' => 900,
];
foreach ($weightMap as $label => $weight) {
if (str_contains($lower, $label)) {
return $weight;
}
}
return 400;
}
private function buildVariantKey(int $weight, string $style): string
{
if ($weight === 400 && $style === 'normal') {
return 'regular';
}
if ($weight === 400 && $style === 'italic') {
return 'italic';
}
if ($style === 'italic') {
return $weight.'italic';
}
return (string) $weight;
}
private function extensionPriority(string $extension): int
{
return match ($extension) {
'woff2' => 4,
'woff' => 3,
'otf' => 2,
'ttf' => 1,
default => 0,
};
}
/**
* @return array<int, string>
*/

View File

@@ -79,9 +79,10 @@ class PostResource extends Resource
->label('Inhalt')
->required()
->columnSpanFull(),
TextInput::make('excerpt.de')
Textarea::make('excerpt.de')
->label('Auszug')
->maxLength(255),
->maxLength(65535)
->columnSpanFull(),
TextInput::make('meta_title.de')
->label('Meta-Titel')
->maxLength(255),
@@ -99,9 +100,10 @@ class PostResource extends Resource
MarkdownEditor::make('content.en')
->label('Inhalt')
->columnSpanFull(),
TextInput::make('excerpt.en')
Textarea::make('excerpt.en')
->label('Auszug')
->maxLength(255),
->maxLength(65535)
->columnSpanFull(),
TextInput::make('meta_title.en')
->label('Meta-Titel')
->maxLength(255),
@@ -121,9 +123,10 @@ class PostResource extends Resource
->unique(BlogPost::class, 'slug', ignoreRecord: true)
->maxLength(255)
->columnSpanFull(),
FileUpload::make('featured_image')
FileUpload::make('banner')
->label('Featured Image')
->image()
->disk('public')
->directory('blog')
->visibility('public'),
Select::make('blog_category_id')

View File

@@ -109,8 +109,9 @@ class EventResource extends Resource
->columns([
Tables\Columns\TextColumn::make('id')->sortable(),
Tables\Columns\TextColumn::make('tenant.name')->label(__('admin.events.table.tenant'))->searchable(),
Tables\Columns\TextColumn::make('name.de')
Tables\Columns\TextColumn::make('name')
->label(__('admin.events.fields.name'))
->formatStateUsing(fn (mixed $state): string => static::formatEventName($state))
->limit(30),
Tables\Columns\TextColumn::make('slug')->searchable(),
Tables\Columns\TextColumn::make('date')->date(),
@@ -278,6 +279,30 @@ class EventResource extends Resource
];
}
/**
* @param array<string, mixed>|string|null $name
*/
private static function formatEventName(mixed $name): string
{
if (is_array($name)) {
$candidates = [
$name['de'] ?? null,
$name['en'] ?? null,
reset($name) ?: null,
];
foreach ($candidates as $candidate) {
if (is_string($candidate) && $candidate !== '') {
return $candidate;
}
}
return '';
}
return is_string($name) ? $name : '';
}
public static function getPages(): array
{
return [

View File

@@ -8,6 +8,7 @@ use App\Filament\Resources\TenantResource\RelationManagers\PackagePurchasesRelat
use App\Filament\Resources\TenantResource\RelationManagers\TenantPackagesRelationManager;
use App\Filament\Resources\TenantResource\Schemas\TenantInfolist;
use App\Jobs\AnonymizeAccount;
use App\Models\Package;
use App\Models\Tenant;
use App\Notifications\InactiveTenantDeletionWarning;
use App\Services\Audit\SuperAdminAuditLogger;
@@ -205,11 +206,13 @@ class TenantResource extends Resource
Forms\Components\Textarea::make('reason')->label('Grund')->rows(3),
])
->action(function (Tenant $record, array $data) {
$package = Package::query()->find($data['package_id']);
\App\Models\TenantPackage::create([
'tenant_id' => $record->id,
'package_id' => $data['package_id'],
'expires_at' => $data['expires_at'],
'active' => true,
'price' => $package?->price ?? 0,
'reason' => $data['reason'] ?? null,
]);
\App\Models\PackagePurchase::create([

View File

@@ -3,39 +3,78 @@
namespace App\Filament\SuperAdmin\Pages\Auth;
use Filament\Auth\Pages\EditProfile as BaseEditProfile;
use Filament\Facades\Filament;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Components\Component;
use Filament\Schemas\Components\Livewire;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema;
use Illuminate\Support\Facades\Log;
class EditProfile extends BaseEditProfile
{
public function mount(): void
protected function getPasswordConfirmationFormComponent(): Component
{
Log::info('EditProfile class loaded for superadmin');
parent::mount();
return TextInput::make('passwordConfirmation')
->label(__('filament-panels::auth/pages/edit-profile.form.password_confirmation.label'))
->validationAttribute(__('filament-panels::auth/pages/edit-profile.form.password_confirmation.validation_attribute'))
->password()
->autocomplete('new-password')
->revealable(filament()->arePasswordsRevealable())
->required()
->visible(fn (Get $get): bool => filled($get('password')))
->dehydrated(false);
}
protected function getCurrentPasswordFormComponent(): Component
{
return TextInput::make('currentPassword')
->label(__('filament-panels::auth/pages/edit-profile.form.current_password.label'))
->validationAttribute(__('filament-panels::auth/pages/edit-profile.form.current_password.validation_attribute'))
->belowContent(__('filament-panels::auth/pages/edit-profile.form.current_password.below_content'))
->password()
->autocomplete('current-password')
->currentPassword(guard: Filament::getAuthGuard())
->revealable(filament()->arePasswordsRevealable())
->required()
->visible(fn (Get $get): bool => filled($get('password')))
->dehydrated(false);
}
public function form(Schema $schema): Schema
{
return $schema
->schema([
$this->getNameFormComponent(),
$this->getEmailFormComponent(),
TextInput::make('username')
->required()
->unique(ignoreRecord: true)
->maxLength(255),
Select::make('preferred_locale')
->options([
'de' => 'Deutsch',
'en' => 'English',
Section::make('Profile')
->schema([
$this->getNameFormComponent(),
$this->getEmailFormComponent(),
TextInput::make('username')
->required()
->unique(ignoreRecord: true)
->maxLength(255),
Select::make('preferred_locale')
->options([
'de' => 'Deutsch',
'en' => 'English',
])
->default('de')
->required(),
])
->default('de')
->required(),
$this->getPasswordFormComponent(),
$this->getPasswordConfirmationFormComponent(),
$this->getCurrentPasswordFormComponent(),
->columns(2),
Section::make('Security')
->schema([
$this->getPasswordFormComponent(),
$this->getPasswordConfirmationFormComponent(),
$this->getCurrentPasswordFormComponent(),
])
->columns(1),
Section::make('Support API Tokens')
->description('Manage bearer tokens for external support tooling.')
->schema([
Livewire::make('support-api-token-manager'),
]),
]);
}
}

View File

@@ -0,0 +1,280 @@
<?php
namespace App\Filament\SuperAdmin\Widgets;
use App\Models\User;
use App\Services\Audit\SuperAdminAuditLogger;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Tables;
use Filament\Tables\Table;
use Filament\Widgets\TableWidget;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
use Laravel\Sanctum\NewAccessToken;
use Laravel\Sanctum\PersonalAccessToken;
class SupportApiTokenManager extends TableWidget
{
protected static bool $isDiscovered = false;
protected int|string|array $columnSpan = 'full';
public function table(Table $table): Table
{
return $table
->heading('Support API Tokens')
->query(fn (): Builder => $this->getTokenQuery())
->defaultSort('created_at', 'desc')
->columns([
Tables\Columns\TextColumn::make('name')
->label('Name')
->sortable()
->searchable(),
Tables\Columns\TextColumn::make('abilities')
->label('Abilities')
->formatStateUsing(fn ($state): string => $this->formatAbilities($state))
->wrap(),
Tables\Columns\TextColumn::make('last_used_at')
->label('Last used')
->since()
->placeholder('—'),
Tables\Columns\TextColumn::make('expires_at')
->label('Expires')
->dateTime('Y-m-d H:i')
->placeholder('—'),
Tables\Columns\TextColumn::make('created_at')
->label('Created')
->since(),
])
->headerActions([
Action::make('create_support_token')
->label('Create token')
->icon('heroicon-o-key')
->form([
TextInput::make('name')
->label('Token name')
->default($this->defaultTokenName())
->required()
->maxLength(255)
->helperText('Existing tokens with the same name will be revoked.'),
CheckboxList::make('abilities')
->label('Abilities')
->options($this->abilityOptions())
->columns(2)
->required()
->default($this->defaultAbilities()),
DateTimePicker::make('expires_at')
->label('Expires at')
->displayFormat('Y-m-d H:i')
->seconds(false),
])
->action(function (array $data): void {
$user = $this->getUser();
if (! $user) {
return;
}
$name = $this->normalizeTokenName($data['name'] ?? null);
$abilities = $this->normalizeAbilities($data['abilities'] ?? []);
$expiresAt = $this->normalizeExpiresAt($data['expires_at'] ?? null);
$user->tokens()->where('name', $name)->delete();
$token = $user->createToken($name, $abilities, $expiresAt);
$this->recordTokenCreated($token, $abilities, $user);
Notification::make()
->success()
->title('Token created')
->body('Copy this token now. It will not be shown again: '.$token->plainTextToken)
->persistent()
->send();
}),
])
->actions([
Action::make('revoke')
->label('Revoke')
->icon('heroicon-o-trash')
->color('danger')
->requiresConfirmation()
->visible(fn (PersonalAccessToken $record): bool => $this->ownsToken($record))
->action(function (PersonalAccessToken $record): void {
if (! $this->ownsToken($record)) {
return;
}
app(SuperAdminAuditLogger::class)->record(
'support-api-token.revoked',
$record,
['fields' => ['name', 'abilities', 'expires_at']],
actor: $this->getUser(),
source: static::class
);
$record->delete();
Notification::make()
->success()
->title('Token revoked')
->send();
}),
])
->emptyStateHeading('No support API tokens')
->emptyStateDescription('Create a token for external support tooling.');
}
private function getTokenQuery(): Builder
{
$user = $this->getUser();
if (! $user) {
return PersonalAccessToken::query()->whereRaw('1 = 0');
}
return PersonalAccessToken::query()
->where('tokenable_id', $user->getKey())
->where('tokenable_type', $user->getMorphClass());
}
private function getUser(): ?User
{
$user = Filament::auth()->user();
return $user instanceof User ? $user : null;
}
private function formatAbilities(mixed $state): string
{
if (is_array($state)) {
return implode(', ', $state);
}
if (is_string($state)) {
return $state;
}
return '';
}
/**
* @return array<int, string>
*/
private function defaultAbilities(): array
{
$abilities = config('support-api.token.default_abilities', []);
if (! is_array($abilities)) {
return ['support-admin'];
}
$abilities = array_values(array_filter($abilities, fn ($ability) => is_string($ability) && $ability !== ''));
if (! in_array('support-admin', $abilities, true)) {
$abilities[] = 'support-admin';
}
return array_values(array_unique($abilities));
}
/**
* @return array<string, string>
*/
private function abilityOptions(): array
{
$options = [];
foreach ($this->defaultAbilities() as $ability) {
$options[$ability] = $ability;
}
return $options;
}
/**
* @param array<int, string> $abilities
* @return array<int, string>
*/
private function normalizeAbilities(array $abilities): array
{
$allowed = $this->defaultAbilities();
$filtered = array_values(array_intersect($abilities, $allowed));
if (! in_array('support-admin', $filtered, true)) {
$filtered[] = 'support-admin';
}
sort($filtered);
return $filtered;
}
private function defaultTokenName(): string
{
$name = config('support-api.token.name');
if (is_string($name) && $name !== '') {
return $name;
}
return 'support-api';
}
private function normalizeTokenName(?string $name): string
{
$name = $name ? trim($name) : '';
return $name !== '' ? $name : $this->defaultTokenName();
}
private function normalizeExpiresAt(mixed $expiresAt): ?Carbon
{
if ($expiresAt instanceof Carbon) {
return $expiresAt;
}
if ($expiresAt instanceof \DateTimeInterface) {
return Carbon::instance($expiresAt);
}
if (is_string($expiresAt) && $expiresAt !== '') {
return Carbon::parse($expiresAt);
}
return null;
}
private function recordTokenCreated(NewAccessToken $token, array $abilities, User $user): void
{
$actionLog = app(SuperAdminAuditLogger::class);
$actionLog->record(
'support-api-token.created',
$token->accessToken,
[
'fields' => ['name', 'abilities', 'expires_at'],
'abilities' => $abilities,
],
actor: $user,
source: static::class
);
}
private function ownsToken(PersonalAccessToken $token): bool
{
$user = $this->getUser();
if (! $user) {
return false;
}
return (int) $token->tokenable_id === (int) $user->getKey()
&& $token->tokenable_type === $user->getMorphClass();
}
}

View File

@@ -14,11 +14,88 @@ class DokployPlatformHealth extends Widget
protected function getViewData(): array
{
$projects = $this->loadProjects();
return [
'composes' => $this->loadComposes(),
'projects' => $projects,
'composes' => empty($projects) ? $this->loadComposes() : [],
];
}
protected function loadProjects(): array
{
$client = app(DokployClient::class);
$projectMap = config('dokploy.projects', []);
$results = [];
if (empty($projectMap)) {
return [];
}
foreach ($projectMap as $label => $projectId) {
$project = [];
$projectIdString = (string) $projectId;
try {
$project = $client->project($projectIdString);
} catch (\Throwable $exception) {
$project = [];
}
if (empty($project)) {
$project = $client->findProject($projectIdString) ?? [];
$resolvedProjectId = Arr::get($project, 'projectId');
if ($resolvedProjectId) {
try {
$project = $client->project((string) $resolvedProjectId);
} catch (\Throwable $exception) {
$project = $project;
}
}
}
if (! $project) {
$results[] = [
'label' => ucfirst((string) $label),
'project_id' => $projectIdString,
'name' => $projectIdString,
'status' => 'unreachable',
'error' => "Project {$projectIdString} not found.",
'applications' => [],
'services' => [],
'composes' => [],
'updated_at' => null,
];
continue;
}
$environments = $this->extractEnvironments($project);
$applications = $this->formatEnvironmentApplications($environments, $client);
$composes = $this->formatEnvironmentComposes($environments, $client);
$services = $this->formatEnvironmentServices($environments);
$results[] = [
'label' => ucfirst((string) $label),
'project_id' => Arr::get($project, 'projectId', $projectIdString),
'name' => Arr::get($project, 'name') ?? Arr::get($project, 'projectName') ?? $projectIdString,
'description' => Arr::get($project, 'description'),
'status' => $this->deriveProjectStatus($applications, $services, $composes),
'applications' => $applications,
'composes' => $composes,
'services' => $services,
'updated_at' => Arr::get($project, 'updatedAt') ?? Arr::get($project, 'createdAt'),
'applications_count' => count($applications),
'composes_count' => count($composes),
'services_count' => count($services),
];
}
return $results;
}
protected function loadComposes(): array
{
$client = app(DokployClient::class);
@@ -62,7 +139,7 @@ class DokployPlatformHealth extends Widget
'label' => 'Dokploy',
'compose_id' => '-',
'status' => 'unconfigured',
'error' => 'Set DOKPLOY_COMPOSE_IDS in .env to enable monitoring.',
'error' => 'Set DOKPLOY_PROJECT_IDS or DOKPLOY_COMPOSE_IDS in .env to enable monitoring.',
],
];
}
@@ -70,6 +147,252 @@ class DokployPlatformHealth extends Widget
return $results;
}
protected function extractEnvironments(array $project): array
{
$environments = Arr::get($project, 'environments', []);
if (is_array($environments) && ! empty($environments)) {
return $environments;
}
return [[
'name' => Arr::get($project, 'name'),
'applications' => Arr::get($project, 'applications', []),
'compose' => Arr::get($project, 'compose', []),
'mysql' => Arr::get($project, 'mysql', []),
'postgres' => Arr::get($project, 'postgres', []),
'mariadb' => Arr::get($project, 'mariadb', []),
'mongo' => Arr::get($project, 'mongo', []),
'redis' => Arr::get($project, 'redis', []),
]];
}
protected function formatEnvironmentApplications(array $environments, DokployClient $client): array
{
return collect($environments)
->flatMap(function (array $environment) use ($client) {
$applications = Arr::get($environment, 'applications', []);
$environmentName = Arr::get($environment, 'name');
return $this->formatApplications(is_array($applications) ? $applications : [], $client, $environmentName);
})
->values()
->all();
}
protected function formatEnvironmentComposes(array $environments, DokployClient $client): array
{
return collect($environments)
->flatMap(function (array $environment) use ($client) {
$composes = Arr::get($environment, 'compose', []);
$environmentName = Arr::get($environment, 'name');
return collect(is_array($composes) ? $composes : [])
->map(function (array $compose) use ($client, $environmentName) {
$composeId = Arr::get($compose, 'composeId') ?? Arr::get($compose, 'id');
$statusPayload = [];
$deployments = [];
if ($composeId) {
try {
$statusPayload = $client->composeStatus($composeId);
$deployments = $client->composeDeployments($composeId, 1);
} catch (\Throwable $exception) {
$statusPayload = [];
$deployments = [];
}
}
$composeDetails = Arr::get($statusPayload, 'compose', []);
return [
'id' => $composeId,
'name' => Arr::get($compose, 'name')
?? Arr::get($compose, 'appName')
?? Arr::get($composeDetails, 'name')
?? Arr::get($composeDetails, 'appName')
?? $composeId,
'status' => Arr::get($compose, 'composeStatus')
?? Arr::get($compose, 'status')
?? Arr::get($composeDetails, 'composeStatus')
?? Arr::get($composeDetails, 'status')
?? 'unknown',
'environment' => $environmentName,
'last_deploy' => Arr::get($deployments, '0.createdAt')
?? Arr::get($deployments, '0.created_at')
?? Arr::get($compose, 'updatedAt')
?? Arr::get($composeDetails, 'updatedAt'),
'services' => $this->formatServices(Arr::get($statusPayload, 'services', [])),
];
})
->filter(fn (array $compose) => filled($compose['name']))
->values()
->all();
})
->values()
->all();
}
protected function formatEnvironmentServices(array $environments): array
{
return collect($environments)
->flatMap(function (array $environment) {
$environmentName = Arr::get($environment, 'name');
return collect([
...$this->normalizeServiceList((array) Arr::get($environment, 'compose', []), 'compose', 'composeId', 'composeStatus', $environmentName),
...$this->normalizeServiceList((array) Arr::get($environment, 'mysql', []), 'mysql', 'mysqlId', 'applicationStatus', $environmentName),
...$this->normalizeServiceList((array) Arr::get($environment, 'postgres', []), 'postgres', 'postgresId', 'applicationStatus', $environmentName),
...$this->normalizeServiceList((array) Arr::get($environment, 'mariadb', []), 'mariadb', 'mariadbId', 'applicationStatus', $environmentName),
...$this->normalizeServiceList((array) Arr::get($environment, 'mongo', []), 'mongo', 'mongoId', 'applicationStatus', $environmentName),
...$this->normalizeServiceList((array) Arr::get($environment, 'redis', []), 'redis', 'redisId', 'applicationStatus', $environmentName),
]);
})
->filter(fn (array $service) => filled($service['name']))
->values()
->all();
}
protected function formatApplications(array $applications, DokployClient $client, ?string $environment = null): array
{
return collect($applications)
->map(function (array $application) use ($client, $environment) {
$applicationId = $this->extractApplicationId($application);
$statusPayload = [];
if ($applicationId) {
try {
$statusPayload = $client->applicationStatus($applicationId);
} catch (\Throwable $exception) {
$statusPayload = [];
}
}
$applicationDetails = Arr::get($statusPayload, 'application', []);
$monitoring = Arr::get($statusPayload, 'monitoring', []);
$status = Arr::get($application, 'applicationStatus')
?? Arr::get($application, 'status')
?? Arr::get($applicationDetails, 'applicationStatus')
?? Arr::get($applicationDetails, 'status')
?? 'unknown';
return [
'id' => $applicationId ?? Arr::get($application, 'id'),
'name' => Arr::get($application, 'name')
?? Arr::get($application, 'appName')
?? Arr::get($applicationDetails, 'name')
?? Arr::get($applicationDetails, 'appName')
?? $applicationId,
'status' => $status,
'repository' => Arr::get($application, 'repository')
?? Arr::get($applicationDetails, 'repository')
?? Arr::get($application, 'repo')
?? Arr::get($applicationDetails, 'repo'),
'branch' => Arr::get($application, 'branch')
?? Arr::get($applicationDetails, 'branch')
?? Arr::get($application, 'gitBranch')
?? Arr::get($applicationDetails, 'gitBranch'),
'url' => Arr::get($application, 'url')
?? Arr::get($applicationDetails, 'url')
?? Arr::get($application, 'domain')
?? Arr::get($applicationDetails, 'domain'),
'server' => Arr::get($application, 'serverName')
?? Arr::get($applicationDetails, 'serverName')
?? Arr::get($application, 'server'),
'environment' => $environment,
'last_deploy' => Arr::get($application, 'lastDeploymentAt')
?? Arr::get($applicationDetails, 'lastDeploymentAt')
?? Arr::get($application, 'updatedAt')
?? Arr::get($applicationDetails, 'updatedAt')
?? Arr::get($application, 'createdAt'),
'monitoring' => $this->formatMonitoring($monitoring),
];
})
->filter(fn (array $application) => filled($application['name']))
->values()
->all();
}
protected function extractApplicationId(array $application): ?string
{
return Arr::get($application, 'applicationId')
?? Arr::get($application, 'appId')
?? Arr::get($application, 'id');
}
protected function normalizeServiceList(array $services, string $type, string $idKey, string $statusKey, ?string $environment = null): array
{
return collect($services)
->map(function (array $service) use ($type, $idKey, $statusKey, $environment) {
return [
'type' => $type,
'id' => Arr::get($service, $idKey) ?? Arr::get($service, 'id'),
'name' => Arr::get($service, 'name') ?? Arr::get($service, 'appName') ?? Arr::get($service, 'serviceName'),
'status' => Arr::get($service, $statusKey) ?? Arr::get($service, 'status') ?? Arr::get($service, 'composeStatus', 'unknown'),
'version' => Arr::get($service, 'dockerImage') ?? Arr::get($service, 'image'),
'external_port' => Arr::get($service, 'externalPort'),
'environment' => $environment,
];
})
->values()
->all();
}
protected function formatMonitoring(array $monitoring): array
{
$metrics = [];
$allowed = [
'cpuPercent' => 'CPU',
'cpu' => 'CPU',
'memoryPercent' => 'Memory',
'memory' => 'Memory',
'uptime' => 'Uptime',
];
foreach ($allowed as $key => $label) {
$value = Arr::get($monitoring, $key);
if (filled($value) && ! is_array($value)) {
$metrics[] = [
'label' => $label,
'value' => $value,
];
}
}
return $metrics;
}
protected function deriveProjectStatus(array $applications, array $services, array $composes): string
{
$statuses = collect($applications)
->pluck('status')
->merge(collect($services)->pluck('status'))
->merge(collect($composes)->pluck('status'))
->filter()
->map(fn ($status) => strtolower((string) $status))
->values();
if ($statuses->contains(fn ($status) => in_array($status, ['error', 'failed', 'unreachable', 'unhealthy'], true))) {
return 'error';
}
if ($statuses->contains(fn ($status) => in_array($status, ['deploying', 'pending', 'starting'], true))) {
return 'deploying';
}
if ($statuses->contains(fn ($status) => in_array($status, ['stopped', 'inactive', 'paused'], true))) {
return 'warning';
}
if ($statuses->contains(fn ($status) => in_array($status, ['done', 'running', 'healthy'], true))) {
return 'done';
}
return 'unknown';
}
protected function formatServices(array $services): array
{
return collect($services)

View File

@@ -12,6 +12,8 @@ class QueueHealthWidget extends Widget
protected ?string $pollingInterval = '60s';
protected int|string|array $columnSpan = 'full';
protected function getViewData(): array
{
$snapshot = Cache::get('storage:queue-health:last');

View File

@@ -185,6 +185,57 @@ class EventPublicController extends BaseController
);
}
$deviceId = (string) $request->header('X-Device-Id', $request->input('device_id', ''));
$deviceId = $deviceId !== '' ? $deviceId : null;
if ($event->id ?? null) {
$eventModel = Event::with(['tenant', 'eventPackage.package', 'eventPackages.package'])->find($event->id);
if ($eventModel && $eventModel->tenant) {
$eventPackage = $this->packageLimitEvaluator->resolveEventPackageForPhotoUpload(
$eventModel->tenant,
$eventModel->id,
$eventModel
);
$maxGuests = $eventPackage?->effectiveGuestLimit();
if ($eventPackage && $maxGuests !== null) {
$grace = (int) config('package-limits.guest_grace', 10);
$hardLimit = $maxGuests + max(0, $grace);
$usedGuests = (int) $eventPackage->used_guests;
$isReturningGuest = $this->joinTokenService->hasSeenGuest($eventModel->id, $deviceId, $request->ip());
if ($usedGuests >= $hardLimit && ! $isReturningGuest) {
$this->recordTokenEvent(
$joinToken,
$request,
'guest_limit_exceeded',
[
'event_id' => $eventModel->id,
'used' => $usedGuests,
'limit' => $maxGuests,
'hard_limit' => $hardLimit,
],
$token,
Response::HTTP_PAYMENT_REQUIRED
);
return ApiError::response(
'guest_limit_exceeded',
__('api.packages.guest_limit_exceeded.title'),
__('api.packages.guest_limit_exceeded.message'),
Response::HTTP_PAYMENT_REQUIRED,
[
'event_id' => $eventModel->id,
'used' => $usedGuests,
'limit' => $maxGuests,
'hard_limit' => $hardLimit,
]
);
}
}
}
}
RateLimiter::clear($rateLimiterKey);
if (isset($event->status)) {
@@ -1042,12 +1093,8 @@ class EventPublicController extends BaseController
$brandingAllowed = $this->determineBrandingAllowed($event);
$eventBranding = $brandingAllowed ? Arr::get($event->settings, 'branding', []) : [];
$tenantBranding = $brandingAllowed ? Arr::get($event->tenant?->settings, 'branding', []) : [];
$useDefault = (bool) Arr::get($eventBranding, 'use_default_branding', Arr::get($eventBranding, 'use_default', false));
$sources = $brandingAllowed
? ($useDefault ? [$tenantBranding] : [$eventBranding, $tenantBranding])
: [[]];
$sources = $brandingAllowed ? [$eventBranding] : [[]];
$primary = $this->normalizeHexColor(
$this->firstStringFromSources($sources, ['palette.primary', 'primary_color']),
@@ -1906,7 +1953,9 @@ class EventPublicController extends BaseController
$policy = $this->guestPolicy();
if ($joinToken) {
$this->joinTokenService->incrementUsage($joinToken);
$deviceId = (string) $request->header('X-Device-Id', $request->input('device_id', ''));
$deviceId = $deviceId !== '' ? $deviceId : null;
$this->joinTokenService->incrementUsage($joinToken, $deviceId, $request->ip());
}
$demoReadOnly = (bool) Arr::get($joinToken?->metadata ?? [], 'demo_read_only', false);

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Http\Controllers\Api\Support;
use App\Http\Controllers\Controller;
use App\Http\Requests\Support\SupportGuestPolicyRequest;
use App\Models\GuestPolicySetting;
use App\Services\Audit\SuperAdminAuditLogger;
use App\Support\SupportApiAuthorizer;
use Illuminate\Http\JsonResponse;
class SupportGuestPolicyController extends Controller
{
public function show(): JsonResponse
{
if ($response = SupportApiAuthorizer::authorizeAbilities(request(), ['support:settings'], 'settings')) {
return $response;
}
$settings = GuestPolicySetting::current();
return response()->json([
'data' => $settings,
]);
}
public function update(SupportGuestPolicyRequest $request): JsonResponse
{
if ($response = SupportApiAuthorizer::authorizeAbilities($request, ['support:settings'], 'settings')) {
return $response;
}
$settings = GuestPolicySetting::query()->firstOrNew(['id' => 1]);
$settings->fill($request->validated());
$settings->save();
$changed = $settings->getChanges();
if ($changed !== []) {
app(SuperAdminAuditLogger::class)->record(
'guest_policy.updated',
$settings,
SuperAdminAuditLogger::fieldsMetadata(array_keys($changed)),
source: static::class
);
}
return response()->json([
'data' => $settings->refresh(),
]);
}
}

View File

@@ -0,0 +1,401 @@
<?php
namespace App\Http\Controllers\Api\Support;
use App\Enums\DataExportScope;
use App\Http\Controllers\Controller;
use App\Http\Requests\Support\Resources\SupportResourceFormRequest;
use App\Http\Requests\Support\SupportResourceRequest;
use App\Jobs\GenerateDataExport;
use App\Models\DataExport;
use App\Services\Audit\SuperAdminAuditLogger;
use App\Support\ApiError;
use App\Support\SupportApiAuthorizer;
use App\Support\SupportApiRegistry;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Validator;
class SupportResourceController extends Controller
{
public function index(Request $request, string $resource): JsonResponse
{
if ($response = SupportApiAuthorizer::authorizeResource($request, $resource, 'read')) {
return $response;
}
$config = SupportApiRegistry::get($resource);
if (! $config) {
return $this->resourceNotFoundResponse($resource);
}
$modelClass = $config['model'];
/** @var Builder $query */
$query = $modelClass::query();
$relations = SupportApiRegistry::withRelations($resource);
if ($relations !== []) {
$query->with($relations);
}
$this->applySearch($request, $query, $resource);
$this->applySorting($request, $query, $resource);
$perPage = $this->resolvePerPage($request);
$paginator = $query->paginate($perPage);
return response()->json([
'data' => $paginator->items(),
'meta' => [
'page' => $paginator->currentPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
'last_page' => $paginator->lastPage(),
],
]);
}
public function show(Request $request, string $resource, string $record): JsonResponse
{
if ($response = SupportApiAuthorizer::authorizeResource($request, $resource, 'read')) {
return $response;
}
$model = $this->resolveRecord($resource, $record);
if (! $model) {
return $this->resourceNotFoundResponse($resource, $record);
}
return response()->json([
'data' => $model,
]);
}
public function store(SupportResourceRequest $request, string $resource): JsonResponse
{
if ($response = SupportApiAuthorizer::authorizeAnyAbility($request, SupportApiRegistry::abilitiesFor($resource, 'write'), 'write')) {
return $response;
}
if (! SupportApiRegistry::allowsMutation($resource, 'create')) {
return $this->mutationNotAllowedResponse($resource, 'create');
}
$config = SupportApiRegistry::get($resource);
if (! $config) {
return $this->resourceNotFoundResponse($resource);
}
$modelClass = $config['model'];
/** @var Model $model */
$model = new $modelClass;
$payload = $this->validatedPayload($request, $resource, 'create', $model);
if ($payload instanceof JsonResponse) {
return $payload;
}
if ($payload === []) {
return $this->emptyPayloadResponse($resource);
}
if ($resource === 'data-exports') {
$payload = $this->normalizeDataExportPayload($request, $payload);
}
$record = $modelClass::query()->create($payload);
app(SuperAdminAuditLogger::class)->record(
SupportApiRegistry::auditAction($resource, 'created'),
$record,
SuperAdminAuditLogger::fieldsMetadata($payload),
actor: $request->user(),
source: static::class
);
if ($resource === 'data-exports') {
GenerateDataExport::dispatch($record->id);
}
return response()->json([
'data' => $record,
], 201);
}
public function update(SupportResourceRequest $request, string $resource, string $record): JsonResponse
{
if ($response = SupportApiAuthorizer::authorizeAnyAbility($request, SupportApiRegistry::abilitiesFor($resource, 'write'), 'write')) {
return $response;
}
if (! SupportApiRegistry::allowsMutation($resource, 'update')) {
return $this->mutationNotAllowedResponse($resource, 'update');
}
$model = $this->resolveRecord($resource, $record);
if (! $model) {
return $this->resourceNotFoundResponse($resource, $record);
}
$payload = $this->validatedPayload($request, $resource, 'update', $model);
if ($payload instanceof JsonResponse) {
return $payload;
}
if ($payload === []) {
return $this->emptyPayloadResponse($resource);
}
$model->fill($payload);
$model->save();
app(SuperAdminAuditLogger::class)->record(
SupportApiRegistry::auditAction($resource, 'updated'),
$model,
SuperAdminAuditLogger::fieldsMetadata($payload),
actor: $request->user(),
source: static::class
);
return response()->json([
'data' => $model->refresh(),
]);
}
public function destroy(Request $request, string $resource, string $record): JsonResponse
{
if ($response = SupportApiAuthorizer::authorizeAnyAbility($request, SupportApiRegistry::abilitiesFor($resource, 'write'), 'write')) {
return $response;
}
if (! SupportApiRegistry::allowsMutation($resource, 'delete')) {
return $this->mutationNotAllowedResponse($resource, 'delete');
}
$model = $this->resolveRecord($resource, $record);
if (! $model) {
return $this->resourceNotFoundResponse($resource, $record);
}
$model->delete();
app(SuperAdminAuditLogger::class)->record(
SupportApiRegistry::auditAction($resource, 'deleted'),
$model,
SuperAdminAuditLogger::fieldsMetadata([]),
actor: $request->user(),
source: static::class
);
return response()->json(['ok' => true]);
}
private function resolveRecord(string $resource, string $record): ?Model
{
$config = SupportApiRegistry::get($resource);
if (! $config) {
return null;
}
$modelClass = $config['model'];
$query = $modelClass::query();
if (is_numeric($record)) {
return $query->find($record);
}
$keyName = (new $modelClass)->getKeyName();
return $query->where($keyName, $record)->first();
}
private function validatedPayload(SupportResourceRequest $request, string $resource, string $action, Model $model): array|JsonResponse
{
$payload = $request->validated('data');
if (! is_array($payload)) {
return [];
}
$validationClass = SupportApiRegistry::validationClass($resource, $action);
if ($validationClass && is_subclass_of($validationClass, SupportResourceFormRequest::class)) {
$allowedFields = $validationClass::allowedFields($action);
if ($allowedFields !== []) {
$unexpected = array_diff(array_keys($payload), $allowedFields);
if ($unexpected !== []) {
return $this->invalidFieldResponse($resource, $unexpected);
}
}
$rules = $validationClass::rulesFor($action, $model);
if ($rules !== []) {
$payload = Validator::make($payload, $rules)->validate();
}
if ($allowedFields !== []) {
$payload = Arr::only($payload, $allowedFields);
}
}
$fillable = $model->getFillable();
if ($fillable === [] && method_exists($model, 'getGuarded') && $model->getGuarded() !== ['*']) {
$columns = Schema::getColumnListing($model->getTable());
return Arr::only($payload, $columns);
}
if ($fillable === []) {
return [];
}
return Arr::only($payload, $fillable);
}
private function applySearch(Request $request, Builder $query, string $resource): void
{
$term = $request->string('search')->trim()->value();
if ($term === '') {
return;
}
$fields = SupportApiRegistry::searchFields($resource);
if ($fields === []) {
return;
}
$columns = Schema::getColumnListing($query->getModel()->getTable());
$fields = array_values(array_intersect($fields, $columns));
if ($fields === []) {
return;
}
$query->where(function (Builder $builder) use ($fields, $term): void {
foreach ($fields as $field) {
if ($field === 'id' && is_numeric($term)) {
$builder->orWhere($field, (int) $term);
} else {
$builder->orWhere($field, 'like', "%{$term}%");
}
}
});
}
private function applySorting(Request $request, Builder $query, string $resource): void
{
$sort = $request->string('sort')->trim()->value();
if ($sort === '') {
return;
}
$direction = 'asc';
$field = $sort;
if (str_starts_with($sort, '-')) {
$direction = 'desc';
$field = ltrim($sort, '-');
}
$allowed = SupportApiRegistry::searchFields($resource);
$allowed[] = 'id';
$columns = Schema::getColumnListing($query->getModel()->getTable());
$allowed = array_values(array_intersect($allowed, $columns));
if (! in_array($field, $allowed, true)) {
return;
}
$query->orderBy($field, $direction);
}
private function resolvePerPage(Request $request): int
{
$default = (int) config('support-api.pagination.default_per_page', 50);
$max = (int) config('support-api.pagination.max_per_page', 200);
$perPage = (int) $request->input('per_page', $default);
if ($perPage < 1) {
$perPage = $default;
}
return min($perPage, $max);
}
private function mutationNotAllowedResponse(string $resource, string $action): JsonResponse
{
return ApiError::response(
'support_mutation_not_allowed',
'Mutation Not Allowed',
"{$resource} does not allow {$action} operations in support API.",
403
);
}
private function emptyPayloadResponse(string $resource): JsonResponse
{
return ApiError::response(
'support_invalid_payload',
'Invalid Payload',
"No mutable fields provided for {$resource}.",
422
);
}
private function invalidFieldResponse(string $resource, array $fields): JsonResponse
{
return ApiError::response(
'support_invalid_fields',
'Invalid Fields',
"Unsupported fields provided for {$resource}.",
422,
[
'fields' => array_values($fields),
]
);
}
private function resourceNotFoundResponse(string $resource, ?string $record = null): JsonResponse
{
$message = $record
? "{$resource} record not found."
: "Support resource {$resource} is not registered.";
return ApiError::response(
'support_resource_not_found',
'Not Found',
$message,
404
);
}
private function normalizeDataExportPayload(Request $request, array $payload): array
{
$payload['user_id'] = $request->user()?->id;
$payload['status'] = DataExport::STATUS_PENDING;
if (($payload['scope'] ?? null) !== DataExportScope::EVENT->value) {
$payload['event_id'] = null;
}
return $payload;
}
}

View File

@@ -0,0 +1,411 @@
<?php
namespace App\Http\Controllers\Api\Support;
use App\Http\Controllers\Controller;
use App\Http\Requests\Support\Tenant\SupportTenantAddPackageRequest;
use App\Http\Requests\Support\Tenant\SupportTenantScheduleDeletionRequest;
use App\Http\Requests\Support\Tenant\SupportTenantSetGracePeriodRequest;
use App\Http\Requests\Support\Tenant\SupportTenantUpdateLimitsRequest;
use App\Http\Requests\Support\Tenant\SupportTenantUpdateSubscriptionRequest;
use App\Jobs\AnonymizeAccount;
use App\Models\Package;
use App\Models\PackagePurchase;
use App\Models\Tenant;
use App\Models\TenantPackage;
use App\Notifications\InactiveTenantDeletionWarning;
use App\Services\Audit\SuperAdminAuditLogger;
use App\Services\Tenant\TenantLifecycleLogger;
use App\Support\SupportApiAuthorizer;
use Carbon\Carbon;
use Filament\Notifications\Notification;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Notification as NotificationFacade;
class SupportTenantActionsController extends Controller
{
public function activate(Tenant $tenant): JsonResponse
{
if ($response = $this->authorizeAction('tenants', 'actions')) {
return $response;
}
$updated = $tenant->update(['is_active' => true]);
app(TenantLifecycleLogger::class)->record(
$tenant,
'activated',
actor: auth()->user()
);
app(SuperAdminAuditLogger::class)->record(
'tenant.activated',
$tenant,
SuperAdminAuditLogger::fieldsMetadata(['is_active']),
source: static::class
);
return response()->json(['ok' => $updated]);
}
public function deactivate(Tenant $tenant): JsonResponse
{
if ($response = $this->authorizeAction('tenants', 'actions')) {
return $response;
}
$updated = $tenant->update(['is_active' => false]);
app(TenantLifecycleLogger::class)->record(
$tenant,
'deactivated',
actor: auth()->user()
);
app(SuperAdminAuditLogger::class)->record(
'tenant.deactivated',
$tenant,
SuperAdminAuditLogger::fieldsMetadata(['is_active']),
source: static::class
);
return response()->json(['ok' => $updated]);
}
public function suspend(Tenant $tenant): JsonResponse
{
if ($response = $this->authorizeAction('tenants', 'actions')) {
return $response;
}
$updated = $tenant->update(['is_suspended' => true]);
app(TenantLifecycleLogger::class)->record(
$tenant,
'suspended',
actor: auth()->user()
);
app(SuperAdminAuditLogger::class)->record(
'tenant.suspended',
$tenant,
SuperAdminAuditLogger::fieldsMetadata(['is_suspended']),
source: static::class
);
return response()->json(['ok' => $updated]);
}
public function unsuspend(Tenant $tenant): JsonResponse
{
if ($response = $this->authorizeAction('tenants', 'actions')) {
return $response;
}
$updated = $tenant->update(['is_suspended' => false]);
app(TenantLifecycleLogger::class)->record(
$tenant,
'unsuspended',
actor: auth()->user()
);
app(SuperAdminAuditLogger::class)->record(
'tenant.unsuspended',
$tenant,
SuperAdminAuditLogger::fieldsMetadata(['is_suspended']),
source: static::class
);
return response()->json(['ok' => $updated]);
}
public function scheduleDeletion(SupportTenantScheduleDeletionRequest $request, Tenant $tenant): JsonResponse
{
if ($response = $this->authorizeAction('tenants', 'actions')) {
return $response;
}
$plannedDeletion = Carbon::parse($request->string('pending_deletion_at')->value());
$update = [
'pending_deletion_at' => $plannedDeletion,
];
if ($request->boolean('send_warning', true)) {
$email = $tenant->contact_email
?? $tenant->email
?? $tenant->user?->email;
if ($email) {
NotificationFacade::route('mail', $email)
->notify(new InactiveTenantDeletionWarning($tenant, $plannedDeletion));
$update['deletion_warning_sent_at'] = now();
} else {
Notification::make()
->danger()
->title(__('admin.tenants.actions.send_warning_missing_title'))
->body(__('admin.tenants.actions.send_warning_missing_body'))
->send();
}
}
$tenant->forceFill($update)->save();
app(TenantLifecycleLogger::class)->record(
$tenant,
'deletion_scheduled',
[
'pending_deletion_at' => $plannedDeletion->toDateTimeString(),
'send_warning' => $request->boolean('send_warning', true),
],
auth()->user()
);
app(SuperAdminAuditLogger::class)->record(
'tenant.deletion_scheduled',
$tenant,
SuperAdminAuditLogger::fieldsMetadata($request->validated()),
source: static::class
);
return response()->json(['ok' => true]);
}
public function cancelDeletion(Tenant $tenant): JsonResponse
{
if ($response = $this->authorizeAction('tenants', 'actions')) {
return $response;
}
$previous = $tenant->pending_deletion_at?->toDateTimeString();
$tenant->forceFill([
'pending_deletion_at' => null,
'deletion_warning_sent_at' => null,
])->save();
app(TenantLifecycleLogger::class)->record(
$tenant,
'deletion_cancelled',
['pending_deletion_at' => $previous],
auth()->user()
);
app(SuperAdminAuditLogger::class)->record(
'tenant.deletion_cancelled',
$tenant,
SuperAdminAuditLogger::fieldsMetadata(['pending_deletion_at', 'deletion_warning_sent_at']),
source: static::class
);
return response()->json(['ok' => true]);
}
public function anonymize(Tenant $tenant): JsonResponse
{
if ($response = $this->authorizeAction('tenants', 'actions')) {
return $response;
}
AnonymizeAccount::dispatch(null, $tenant->id);
app(TenantLifecycleLogger::class)->record(
$tenant,
'anonymize_requested',
actor: auth()->user()
);
app(SuperAdminAuditLogger::class)->record(
'tenant.anonymize_requested',
$tenant,
SuperAdminAuditLogger::fieldsMetadata([]),
source: static::class
);
return response()->json(['ok' => true]);
}
public function addPackage(SupportTenantAddPackageRequest $request, Tenant $tenant): JsonResponse
{
if ($response = $this->authorizeAction('tenants', 'actions')) {
return $response;
}
$package = Package::query()->find($request->integer('package_id'));
TenantPackage::query()->create([
'tenant_id' => $tenant->id,
'package_id' => $request->integer('package_id'),
'expires_at' => $request->date('expires_at'),
'active' => true,
'price' => $package?->price ?? 0,
'reason' => $request->string('reason')->value(),
]);
PackagePurchase::query()->create([
'tenant_id' => $tenant->id,
'package_id' => $request->integer('package_id'),
'provider' => 'manual',
'provider_id' => 'manual',
'type' => 'reseller_subscription',
'price' => 0,
'metadata' => ['reason' => $request->string('reason')->value() ?: 'manual assignment'],
]);
app(SuperAdminAuditLogger::class)->record(
'tenant.package_added',
$tenant,
SuperAdminAuditLogger::fieldsMetadata($request->validated()),
source: static::class
);
return response()->json(['ok' => true]);
}
public function updateLimits(SupportTenantUpdateLimitsRequest $request, Tenant $tenant): JsonResponse
{
if ($response = $this->authorizeAction('tenants', 'actions')) {
return $response;
}
$before = [
'max_photos_per_event' => $tenant->max_photos_per_event,
'max_storage_mb' => $tenant->max_storage_mb,
];
$tenant->forceFill([
'max_photos_per_event' => $request->integer('max_photos_per_event'),
'max_storage_mb' => $request->integer('max_storage_mb'),
])->save();
$after = [
'max_photos_per_event' => $tenant->max_photos_per_event,
'max_storage_mb' => $tenant->max_storage_mb,
];
app(TenantLifecycleLogger::class)->record(
$tenant,
'limits_updated',
[
'before' => $before,
'after' => $after,
'note' => $request->string('note')->value(),
],
auth()->user()
);
app(SuperAdminAuditLogger::class)->record(
'tenant.limits_updated',
$tenant,
SuperAdminAuditLogger::fieldsMetadata($request->validated()),
source: static::class
);
return response()->json(['ok' => true]);
}
public function updateSubscriptionExpiresAt(SupportTenantUpdateSubscriptionRequest $request, Tenant $tenant): JsonResponse
{
if ($response = $this->authorizeAction('tenants', 'actions')) {
return $response;
}
$before = [
'subscription_expires_at' => optional($tenant->subscription_expires_at)->toDateTimeString(),
];
$tenant->forceFill([
'subscription_expires_at' => $request->date('subscription_expires_at'),
])->save();
$after = [
'subscription_expires_at' => optional($tenant->subscription_expires_at)->toDateTimeString(),
];
app(TenantLifecycleLogger::class)->record(
$tenant,
'subscription_expires_at_updated',
[
'before' => $before,
'after' => $after,
'note' => $request->string('note')->value(),
],
auth()->user()
);
app(SuperAdminAuditLogger::class)->record(
'tenant.subscription_expires_at_updated',
$tenant,
SuperAdminAuditLogger::fieldsMetadata($request->validated()),
source: static::class
);
return response()->json(['ok' => true]);
}
public function setGracePeriod(SupportTenantSetGracePeriodRequest $request, Tenant $tenant): JsonResponse
{
if ($response = $this->authorizeAction('tenants', 'actions')) {
return $response;
}
$tenant->forceFill([
'grace_period_ends_at' => $request->date('grace_period_ends_at'),
])->save();
app(TenantLifecycleLogger::class)->record(
$tenant,
'grace_period_set',
[
'grace_period_ends_at' => optional($tenant->grace_period_ends_at)->toDateTimeString(),
'note' => $request->string('note')->value(),
],
auth()->user()
);
app(SuperAdminAuditLogger::class)->record(
'tenant.grace_period_set',
$tenant,
SuperAdminAuditLogger::fieldsMetadata($request->validated()),
source: static::class
);
return response()->json(['ok' => true]);
}
public function clearGracePeriod(Tenant $tenant): JsonResponse
{
if ($response = $this->authorizeAction('tenants', 'actions')) {
return $response;
}
$previous = $tenant->grace_period_ends_at?->toDateTimeString();
$tenant->forceFill([
'grace_period_ends_at' => null,
])->save();
app(TenantLifecycleLogger::class)->record(
$tenant,
'grace_period_cleared',
[
'grace_period_ends_at' => $previous,
],
auth()->user()
);
app(SuperAdminAuditLogger::class)->record(
'tenant.grace_period_cleared',
$tenant,
SuperAdminAuditLogger::fieldsMetadata(['grace_period_ends_at']),
source: static::class
);
return response()->json(['ok' => true]);
}
private function authorizeAction(string $resource, string $action): ?JsonResponse
{
return SupportApiAuthorizer::authorizeResource(request(), $resource, $action);
}
}

View File

@@ -0,0 +1,103 @@
<?php
namespace App\Http\Controllers\Api\Support;
use App\Http\Controllers\Controller;
use App\Http\Requests\Support\SupportTokenRequest;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;
class SupportTokenController extends Controller
{
public function store(SupportTokenRequest $request): JsonResponse
{
$credentials = $request->credentials();
$query = User::query();
if (isset($credentials['email'])) {
$query->where('email', $credentials['email']);
}
if (isset($credentials['username'])) {
$query->where('username', $credentials['username']);
}
/** @var User|null $user */
$user = $query->first();
if (! $user || ! Hash::check($credentials['password'], (string) $user->password)) {
throw ValidationException::withMessages([
'login' => [trans('auth.failed')],
]);
}
if (! $user->isSuperAdmin()) {
throw ValidationException::withMessages([
'login' => [trans('auth.not_authorized')],
]);
}
$tokenConfig = config('support-api.token');
$defaultAbilities = $tokenConfig['default_abilities'] ?? [];
$abilities = $credentials['abilities'] ?? $defaultAbilities;
if ($abilities !== $defaultAbilities) {
$abilities = array_values(array_intersect($abilities, $defaultAbilities));
}
if (! in_array('support-admin', $abilities, true)) {
$abilities[] = 'support-admin';
}
$tokenName = (string) ($tokenConfig['name'] ?? 'support-api');
$user->tokens()->where('name', $tokenName)->delete();
$token = $user->createToken($tokenName, $abilities);
return response()->json([
'token' => $token->plainTextToken,
'token_type' => 'Bearer',
'abilities' => $abilities,
'user' => Arr::only($user->toArray(), [
'id',
'email',
'name',
'role',
'tenant_id',
]),
]);
}
public function destroy(Request $request): JsonResponse
{
$token = $request->user()?->currentAccessToken();
if ($token) {
$token->delete();
}
return response()->json(['ok' => true]);
}
public function me(Request $request): JsonResponse
{
$user = $request->user();
return response()->json([
'user' => $user ? Arr::only($user->toArray(), [
'id',
'name',
'email',
'role',
'tenant_id',
]) : null,
'abilities' => $user?->currentAccessToken()?->abilities ?? [],
]);
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Http\Controllers\Api\Support;
use App\Http\Controllers\Controller;
use App\Http\Requests\Support\SupportWatermarkSettingsRequest;
use App\Models\WatermarkSetting;
use App\Services\Audit\SuperAdminAuditLogger;
use App\Support\SupportApiAuthorizer;
use Illuminate\Http\JsonResponse;
class SupportWatermarkSettingsController extends Controller
{
public function show(): JsonResponse
{
if ($response = SupportApiAuthorizer::authorizeAbilities(request(), ['support:settings'], 'settings')) {
return $response;
}
$settings = WatermarkSetting::query()->first();
return response()->json([
'data' => $settings,
]);
}
public function update(SupportWatermarkSettingsRequest $request): JsonResponse
{
if ($response = SupportApiAuthorizer::authorizeAbilities($request, ['support:settings'], 'settings')) {
return $response;
}
$settings = WatermarkSetting::query()->firstOrNew([]);
$settings->fill($request->validated());
$settings->save();
$changed = $settings->getChanges();
if ($changed !== []) {
app(SuperAdminAuditLogger::class)->record(
'watermark_settings.updated',
$settings,
SuperAdminAuditLogger::fieldsMetadata(array_keys($changed)),
source: static::class
);
}
return response()->json([
'data' => $settings->refresh(),
]);
}
}

View File

@@ -161,11 +161,13 @@ class EventController extends Controller
]);
}
$resolvedName = $this->resolveEventNameString($validated['name']);
$eventData = array_merge($validated, [
'tenant_id' => $tenantId,
'status' => $validated['status'] ?? 'draft',
'slug' => $this->generateUniqueSlug($validated['name'], $tenantId),
'slug' => $this->generateUniqueSlug($resolvedName, $tenantId),
]);
$eventData['name'] = $this->normalizeEventName($validated['name']);
if (isset($eventData['event_date'])) {
$eventData['date'] = $eventData['event_date'];
@@ -228,7 +230,7 @@ class EventController extends Controller
]);
if ($billingIsReseller && ! $isSuperAdmin) {
$note = sprintf('Event #%d created (%s)', $event->id, $event->name);
$note = sprintf('Event #%d created (%s)', $event->id, $this->resolveEventNameString($event->name));
if (! $tenant->consumeEventAllowanceFor($eventServicePackage->slug, 1, 'event.create', $note)) {
throw new HttpException(402, 'Insufficient package allowance.');
@@ -404,9 +406,13 @@ class EventController extends Controller
unset($validated['event_date']);
}
if ($nameProvided && $validated['name'] !== $event->name) {
$validated['slug'] = $this->generateUniqueSlug($validated['name'], $tenantId, $event->id);
$currentName = $this->resolveEventNameString($event->name);
$nextName = $this->resolveEventNameString($validated['name']);
if ($nameProvided && $nextName !== $currentName) {
$validated['slug'] = $this->generateUniqueSlug($nextName, $tenantId, $event->id);
}
$validated['name'] = $this->normalizeEventName($validated['name']);
foreach (['password', 'password_confirmation', 'password_protected'] as $unused) {
unset($validated[$unused]);
@@ -935,6 +941,45 @@ class EventController extends Controller
return $slug;
}
/**
* @param array<string, mixed>|string|null $name
* @return array<string, mixed>
*/
private function normalizeEventName(mixed $name): array
{
if (is_array($name)) {
return $name;
}
$value = is_string($name) ? trim($name) : '';
return ['de' => $value];
}
/**
* @param array<string, mixed>|string|null $name
*/
private function resolveEventNameString(mixed $name): string
{
if (is_array($name)) {
$candidates = [
$name['de'] ?? null,
$name['en'] ?? null,
reset($name) ?: null,
];
foreach ($candidates as $candidate) {
if (is_string($candidate) && $candidate !== '') {
return $candidate;
}
}
return '';
}
return is_string($name) ? $name : '';
}
public function search(Request $request): AnonymousResourceCollection
{
$tenantId = $request->attributes->get('tenant_id');

View File

@@ -22,7 +22,14 @@ class EventJoinTokenLayoutController extends Controller
*/
private const BACKGROUND_PRESETS = [
'bg-blue-floral' => 'storage/layouts/backgrounds-portrait/bg-blue-floral.png',
'bg-artdeco' => 'storage/layouts/backgrounds-portrait/bg-artdeco.png',
'bg-eukalyptus-floral' => 'storage/layouts/backgrounds-portrait/bg-eukalyptus-floral.png',
'bg-eukalyptus-rahmen' => 'storage/layouts/backgrounds-portrait/bg-eukalyptus-rahmen.png',
'bg-eukalyptus' => 'storage/layouts/backgrounds-portrait/bg-eukalyptus.png',
'bg-goldframe' => 'storage/layouts/backgrounds-portrait/bg-goldframe.png',
'bg-jugendstil' => 'storage/layouts/backgrounds-portrait/bg-jugendstil.png',
'bg-kornblumen' => 'storage/layouts/backgrounds-portrait/bg-kornblumen.png',
'bg-kornblumen2' => 'storage/layouts/backgrounds-portrait/bg-kornblumen2.png',
'gr-green-floral' => 'storage/layouts/backgrounds-portrait/gr-green-floral.png',
];

View File

@@ -20,6 +20,7 @@ use App\Support\WatermarkConfigResolver;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
@@ -115,6 +116,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(
@@ -321,7 +323,7 @@ class PhotoController extends Controller
$disk = $this->eventStorageManager->getHotDiskForEvent($event);
// Generate unique filename
$extension = $file->getClientOriginalExtension();
$extension = $this->resolvePhotoExtension($file);
$filename = Str::uuid().'.'.$extension;
$path = "events/{$eventSlug}/photos/{$filename}";
@@ -563,6 +565,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(
@@ -779,6 +782,7 @@ class PhotoController extends Controller
$event = Event::where('slug', $eventSlug)
->where('tenant_id', $tenantId)
->firstOrFail();
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
$photos = Photo::where('event_id', $event->id)
->where('status', 'pending')
@@ -1043,4 +1047,23 @@ class PhotoController extends Controller
return array_values(array_unique(array_filter($candidates)));
}
private function resolvePhotoExtension(UploadedFile $file): string
{
$extension = strtolower((string) $file->extension());
if (! in_array($extension, ['jpg', 'jpeg', 'png', 'webp'], true)) {
$extension = strtolower((string) $file->getClientOriginalExtension());
}
if (! in_array($extension, ['jpg', 'jpeg', 'png', 'webp'], true)) {
$extension = match ($file->getMimeType()) {
'image/png' => 'png',
'image/webp' => 'webp',
default => 'jpg',
};
}
return $extension === 'jpeg' ? 'jpg' : $extension;
}
}

View File

@@ -157,6 +157,10 @@ class AuthenticatedSessionController extends Controller
return null;
}
if (str_starts_with($candidate, '//')) {
return null;
}
if (str_starts_with($candidate, '/')) {
return $candidate;
}
@@ -170,7 +174,7 @@ class AuthenticatedSessionController extends Controller
$appHost = parse_url($request->getSchemeAndHttpHost(), PHP_URL_HOST);
if ($appHost && ! Str::endsWith($targetHost, $appHost)) {
if (! $appHost || ! $this->isAllowedReturnHost($targetHost, $appHost)) {
return null;
}
@@ -222,7 +226,7 @@ class AuthenticatedSessionController extends Controller
$scheme = $parsed['scheme'] ?? null;
$requestHost = parse_url($request->getSchemeAndHttpHost(), PHP_URL_HOST);
if ($scheme && $host && $requestHost && ! Str::endsWith($host, $requestHost)) {
if ($scheme && $host && $requestHost && ! $this->isAllowedReturnHost($host, $requestHost)) {
return '/event-admin/dashboard';
}
@@ -265,6 +269,15 @@ class AuthenticatedSessionController extends Controller
return $decoded;
}
private function isAllowedReturnHost(string $targetHost, string $appHost): bool
{
if ($targetHost === $appHost) {
return true;
}
return Str::endsWith($targetHost, '.'.$appHost);
}
private function rememberTenantAdminTarget(Request $request, ?string $target): void
{
$user = Auth::user();

View File

@@ -48,6 +48,9 @@ class CheckoutController extends Controller
$googleStatus = session()->pull('checkout_google_status');
$googleError = session()->pull('checkout_google_error');
$googleProfile = session()->pull('checkout_google_profile');
$facebookStatus = session()->pull('checkout_facebook_status');
$facebookError = session()->pull('checkout_facebook_error');
$facebookProfile = session()->pull('checkout_facebook_profile');
$packageOptions = Package::orderBy('price')->get()
->map(fn (Package $pkg) => $this->presentPackage($pkg))
@@ -66,6 +69,11 @@ class CheckoutController extends Controller
'error' => $googleError,
'profile' => $googleProfile,
],
'facebookAuth' => [
'status' => $facebookStatus,
'error' => $facebookError,
'profile' => $facebookProfile,
],
'paddle' => [
'environment' => config('paddle.environment'),
'client_token' => config('paddle.client_token'),

View File

@@ -0,0 +1,217 @@
<?php
namespace App\Http\Controllers;
use App\Models\Package;
use App\Models\Tenant;
use App\Models\User;
use App\Support\CheckoutRoutes;
use App\Support\LocaleConfig;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Laravel\Socialite\Facades\Socialite;
use Symfony\Component\HttpFoundation\RedirectResponse;
class CheckoutFacebookController extends Controller
{
private const SESSION_KEY = 'checkout_facebook_payload';
public function redirect(Request $request): RedirectResponse
{
$validated = $request->validate([
'package_id' => ['required', 'exists:packages,id'],
'locale' => ['nullable', 'string'],
]);
$payload = [
'package_id' => (int) $validated['package_id'],
'locale' => $validated['locale'] ?? app()->getLocale(),
];
$request->session()->put(self::SESSION_KEY, $payload);
$request->session()->put('selected_package_id', $payload['package_id']);
return Socialite::driver('facebook')
->redirectUrl(route('checkout.facebook.callback'))
->scopes(['email'])
->fields(['name', 'email', 'first_name', 'last_name'])
->redirect();
}
public function callback(Request $request): RedirectResponse
{
$payload = $request->session()->get(self::SESSION_KEY, []);
$packageId = $payload['package_id'] ?? null;
$locale = $payload['locale'] ?? null;
try {
$facebookUser = Socialite::driver('facebook')->user();
} catch (\Throwable $e) {
Log::warning('Facebook checkout login failed', ['message' => $e->getMessage()]);
$this->flashError($request, __('checkout.facebook_error_fallback'));
return $this->redirectBackToWizard($packageId, $locale);
}
$email = $facebookUser->getEmail();
if (! $email) {
$this->flashError($request, __('checkout.facebook_missing_email'));
return $this->redirectBackToWizard($packageId, $locale);
}
$raw = $facebookUser->getRaw();
$givenName = $raw['first_name'] ?? null;
$familyName = $raw['last_name'] ?? null;
$request->session()->put('checkout_facebook_profile', array_filter([
'email' => $email,
'name' => $facebookUser->getName(),
'given_name' => $givenName,
'family_name' => $familyName,
'avatar' => $facebookUser->getAvatar(),
'locale' => $raw['locale'] ?? null,
]));
$existing = User::where('email', $email)->first();
if (! $existing) {
$request->session()->put('checkout_facebook_profile', array_filter([
'email' => $email,
'name' => $facebookUser->getName(),
'given_name' => $givenName,
'family_name' => $familyName,
'avatar' => $facebookUser->getAvatar(),
'locale' => $raw['locale'] ?? null,
]));
$request->session()->put('checkout_facebook_status', 'prefill');
return $this->redirectBackToWizard($packageId, $locale);
}
$user = DB::transaction(function () use ($existing, $facebookUser, $email) {
$existing->forceFill([
'name' => $facebookUser->getName() ?: $existing->name,
'pending_purchase' => true,
'email_verified_at' => $existing->email_verified_at ?? now(),
])->save();
if (! $existing->tenant) {
$this->createTenantForUser($existing, $facebookUser->getName(), $email);
}
return $existing->fresh();
});
if (! $user->tenant) {
$this->createTenantForUser($user, $facebookUser->getName(), $email);
}
Auth::login($user, true);
$request->session()->regenerate();
$request->session()->forget(self::SESSION_KEY);
$request->session()->forget('checkout_facebook_profile');
$request->session()->put('checkout_facebook_status', 'signin');
if ($packageId) {
$this->ensurePackageAttached($user, (int) $packageId);
}
return $this->redirectBackToWizard($packageId, $locale);
}
private function createTenantForUser(User $user, ?string $displayName, string $email): Tenant
{
$tenantName = trim($displayName ?: Str::before($email, '@')) ?: 'Fotospiel Tenant';
$slugBase = Str::slug($tenantName) ?: 'tenant';
$slug = $slugBase;
$counter = 1;
while (Tenant::where('slug', $slug)->exists()) {
$slug = $slugBase.'-'.$counter;
$counter++;
}
$tenant = Tenant::create([
'user_id' => $user->id,
'name' => $tenantName,
'slug' => $slug,
'email' => $email,
'contact_email' => $email,
'is_active' => true,
'is_suspended' => false,
'subscription_tier' => 'free',
'subscription_status' => 'free',
'subscription_expires_at' => null,
'settings' => json_encode([
'branding' => [
'logo_url' => null,
'primary_color' => '#FF5A5F',
'secondary_color' => '#FFF8F5',
'font_family' => 'Inter, sans-serif',
],
'features' => [
'photo_likes_enabled' => false,
'event_checklist' => false,
'custom_domain' => false,
'advanced_analytics' => false,
],
'custom_domain' => null,
'contact_email' => $email,
'event_default_type' => 'general',
]),
]);
$user->forceFill(['tenant_id' => $tenant->id])->save();
return $tenant;
}
private function ensurePackageAttached(User $user, int $packageId): void
{
$tenant = $user->tenant;
if (! $tenant) {
return;
}
$package = Package::find($packageId);
if (! $package) {
return;
}
if ($tenant->packages()->where('package_id', $packageId)->exists()) {
return;
}
$tenant->packages()->attach($packageId, [
'price' => $package->price,
'purchased_at' => now(),
'expires_at' => now()->addYear(),
'active' => $package->price <= 0,
]);
}
private function redirectBackToWizard(?int $packageId, ?string $locale = null): RedirectResponse
{
if ($packageId) {
return redirect()->to(CheckoutRoutes::wizardUrl($packageId, $locale));
}
$firstPackageId = Package::query()->orderBy('price')->value('id');
if ($firstPackageId) {
return redirect()->to(CheckoutRoutes::wizardUrl($firstPackageId, $locale));
}
return redirect()->route('packages', [
'locale' => LocaleConfig::canonicalize($locale ?? app()->getLocale()),
]);
}
private function flashError(Request $request, string $message): void
{
$request->session()->flash('checkout_facebook_error', $message);
}
}

View File

@@ -35,6 +35,7 @@ class CheckoutGoogleController extends Controller
$request->session()->put('selected_package_id', $payload['package_id']);
return Socialite::driver('google')
->redirectUrl(route('checkout.google.callback'))
->scopes(['email', 'profile'])
->with(['prompt' => 'select_account'])
->redirect();

View File

@@ -64,7 +64,6 @@ class MarketingController extends Controller
'name' => 'required|string|max:255',
'email' => 'required|email|max:255',
'message' => 'required|string|max:1000',
'nickname' => 'present|size:0',
]);
$locale = app()->getLocale();

View File

@@ -0,0 +1,129 @@
<?php
namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Laravel\Socialite\Facades\Socialite;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Throwable;
class TenantAdminFacebookController extends Controller
{
public function redirect(Request $request): RedirectResponse
{
$returnTo = $request->query('return_to');
if (is_string($returnTo) && $returnTo !== '') {
$request->session()->put('tenant_oauth_return_to', $returnTo);
}
return Socialite::driver('facebook')
->redirectUrl(route('tenant.admin.facebook.callback'))
->scopes(['email'])
->fields(['name', 'email', 'first_name', 'last_name'])
->redirect();
}
public function callback(Request $request): RedirectResponse
{
try {
$facebookUser = Socialite::driver('facebook')->user();
} catch (Throwable $exception) {
Log::warning('Tenant admin Facebook sign-in failed', [
'message' => $exception->getMessage(),
]);
return $this->sendBackWithError($request, 'facebook_failed', 'Unable to complete Facebook sign-in.');
}
$email = $facebookUser->getEmail();
if (! $email) {
return $this->sendBackWithError($request, 'facebook_failed', 'Facebook account did not provide an email address.');
}
/** @var User|null $user */
$user = User::query()->where('email', $email)->first();
if (! $user || ! in_array($user->role, ['tenant_admin', 'super_admin', 'superadmin'], true)) {
return $this->sendBackWithError($request, 'facebook_no_match', 'No tenant admin account is linked to this Facebook address.');
}
$user->forceFill([
'name' => $facebookUser->getName() ?: $user->name,
'email_verified_at' => $user->email_verified_at ?? now(),
])->save();
Auth::login($user, true);
$request->session()->regenerate();
$request->session()->forget('url.intended');
$returnTo = $request->session()->pull('tenant_oauth_return_to');
if (is_string($returnTo)) {
$decoded = $this->decodeReturnTo($returnTo, $request);
if ($decoded) {
return redirect()->to($decoded);
}
}
$fallback = $request->session()->pull('tenant_admin.return_to');
if (is_string($fallback) && str_starts_with($fallback, '/event-admin')) {
return redirect()->to($fallback);
}
return redirect()->to('/event-admin/dashboard');
}
private function sendBackWithError(Request $request, string $code, string $message): RedirectResponse
{
$query = [
'error' => $code,
'error_description' => $message,
];
if ($request->session()->has('tenant_oauth_return_to')) {
$query['return_to'] = $request->session()->get('tenant_oauth_return_to');
}
return redirect()->route('tenant.admin.login', $query);
}
private function decodeReturnTo(string $encoded, Request $request): ?string
{
$padded = str_pad($encoded, strlen($encoded) + ((4 - (strlen($encoded) % 4)) % 4), '=');
$normalized = strtr($padded, '-_', '+/');
$decoded = base64_decode($normalized);
if (! is_string($decoded) || $decoded === '') {
return null;
}
if (str_starts_with($decoded, '//')) {
return null;
}
if (str_starts_with($decoded, '/')) {
return $decoded;
}
$targetHost = parse_url($decoded, PHP_URL_HOST);
$appHost = parse_url($request->getSchemeAndHttpHost(), PHP_URL_HOST);
if (! $targetHost || ! $appHost || ! $this->isAllowedReturnHost($targetHost, $appHost)) {
return null;
}
return $decoded;
}
private function isAllowedReturnHost(string $targetHost, string $appHost): bool
{
if ($targetHost === $appHost) {
return true;
}
return Str::endsWith($targetHost, '.'.$appHost);
}
}

View File

@@ -21,6 +21,7 @@ class TenantAdminGoogleController extends Controller
}
return Socialite::driver('google')
->redirectUrl(route('tenant.admin.google.callback'))
->scopes(['openid', 'profile', 'email'])
->with(['prompt' => 'select_account'])
->redirect();
@@ -57,6 +58,7 @@ class TenantAdminGoogleController extends Controller
Auth::login($user, true);
$request->session()->regenerate();
$request->session()->forget('url.intended');
$returnTo = $request->session()->pull('tenant_oauth_return_to');
if (is_string($returnTo)) {
@@ -66,7 +68,12 @@ class TenantAdminGoogleController extends Controller
}
}
return redirect()->intended('/event-admin/dashboard');
$fallback = $request->session()->pull('tenant_admin.return_to');
if (is_string($fallback) && str_starts_with($fallback, '/event-admin')) {
return redirect()->to($fallback);
}
return redirect()->to('/event-admin/dashboard');
}
private function sendBackWithError(Request $request, string $code, string $message): RedirectResponse
@@ -93,13 +100,30 @@ class TenantAdminGoogleController extends Controller
return null;
}
if (str_starts_with($decoded, '//')) {
return null;
}
if (str_starts_with($decoded, '/')) {
return $decoded;
}
$targetHost = parse_url($decoded, PHP_URL_HOST);
$appHost = parse_url($request->getSchemeAndHttpHost(), PHP_URL_HOST);
if ($targetHost && $appHost && ! Str::endsWith($targetHost, $appHost)) {
if (! $targetHost || ! $appHost || ! $this->isAllowedReturnHost($targetHost, $appHost)) {
return null;
}
return $decoded;
}
private function isAllowedReturnHost(string $targetHost, string $appHost): bool
{
if ($targetHost === $appHost) {
return true;
}
return Str::endsWith($targetHost, '.'.$appHost);
}
}

View File

@@ -118,11 +118,18 @@ class ContentSecurityPolicy
$styleSources[] = 'data:';
$connectSources[] = 'https:';
$fontSources[] = 'https:';
$styleElemSources = array_values(array_filter(
$styleSources,
static fn (string $source): bool => ! str_starts_with($source, "'nonce-")
));
$styleElemSources = array_unique(array_merge($styleElemSources, ["'unsafe-inline'"]));
$directives = [
'default-src' => ["'self'"],
'script-src' => array_unique($scriptSources),
'style-src' => array_unique($styleSources),
'style-src-elem' => $styleElemSources,
'style-src-attr' => ["'unsafe-inline'"],
'img-src' => array_unique($imgSources),
'font-src' => array_unique($fontSources),
'connect-src' => array_unique($connectSources),

View File

@@ -0,0 +1,66 @@
<?php
namespace App\Http\Middleware;
use App\Support\ApiError;
use Closure;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Laravel\Sanctum\PersonalAccessToken;
use Symfony\Component\HttpFoundation\Response;
class EnsureSupportToken
{
/**
* Handle an incoming request.
*/
public function handle(Request $request, Closure $next): JsonResponse|Response
{
$user = $request->user();
if (! $user) {
return $this->unauthorizedResponse('Unauthenticated request.');
}
$accessToken = $user->currentAccessToken();
if (! $accessToken instanceof PersonalAccessToken) {
return $this->unauthorizedResponse('Missing personal access token context.');
}
if (! $user->isSuperAdmin()) {
return $this->forbiddenResponse('Only super administrators may access support APIs.');
}
if (! $accessToken->can('support-admin') && ! $accessToken->can('super-admin')) {
return $this->forbiddenResponse('Access token does not include the support-admin ability.');
}
$request->attributes->set('support_token_id', $accessToken->id);
Auth::shouldUse('sanctum');
return $next($request);
}
private function unauthorizedResponse(string $message): JsonResponse
{
return ApiError::response(
'unauthenticated',
'Unauthenticated',
$message,
Response::HTTP_UNAUTHORIZED
);
}
private function forbiddenResponse(string $message): JsonResponse
{
return ApiError::response(
'support_forbidden',
'Forbidden',
$message,
Response::HTTP_FORBIDDEN
);
}
}

View File

@@ -6,6 +6,7 @@ use App\Support\LocaleConfig;
use Illuminate\Foundation\Inspiring;
use Illuminate\Http\Request;
use Inertia\Middleware;
use Spatie\Honeypot\Honeypot;
class HandleInertiaRequests extends Middleware
{
@@ -67,6 +68,7 @@ class HandleInertiaRequests extends Middleware
'error' => fn () => $request->session()->get('error'),
'verification' => fn () => $request->session()->get('verification'),
],
'honeypot' => fn () => new Honeypot(config('honeypot')),
];
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace App\Http\Requests\Support\Resources;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Validation\Rule;
class SupportBlogPostResourceRequest extends SupportResourceFormRequest
{
public static function rulesFor(string $action, ?Model $model = null): array
{
$postId = $model?->getKey();
$rules = [
'blog_category_id' => ['sometimes', 'integer', 'exists:blog_categories,id'],
'slug' => [
'sometimes',
'string',
'max:255',
Rule::unique('blog_posts', 'slug')->ignore($postId),
],
'banner' => ['sometimes', 'nullable', 'string', 'max:255'],
'published_at' => ['sometimes', 'nullable', 'date'],
'is_published' => ['sometimes', 'boolean'],
'title' => ['sometimes', 'array'],
'title.de' => ['required_with:title', 'string', 'max:255'],
'title.en' => ['nullable', 'string', 'max:255'],
'content' => ['sometimes', 'array'],
'content.de' => ['required_with:content', 'string'],
'content.en' => ['nullable', 'string'],
'excerpt' => ['sometimes', 'array'],
'excerpt.de' => ['nullable', 'string'],
'excerpt.en' => ['nullable', 'string'],
'meta_title' => ['sometimes', 'array'],
'meta_title.de' => ['nullable', 'string', 'max:255'],
'meta_title.en' => ['nullable', 'string', 'max:255'],
'meta_description' => ['sometimes', 'array'],
'meta_description.de' => ['nullable', 'string'],
'meta_description.en' => ['nullable', 'string'],
'translations' => ['sometimes', 'array'],
];
if ($action === 'create') {
$rules['blog_category_id'] = ['required', 'integer', 'exists:blog_categories,id'];
$rules['slug'] = ['required', 'string', 'max:255', Rule::unique('blog_posts', 'slug')];
$rules['title'] = ['required', 'array'];
$rules['title.de'] = ['required', 'string', 'max:255'];
$rules['content'] = ['required', 'array'];
$rules['content.de'] = ['required', 'string'];
}
return $rules;
}
public static function allowedFields(string $action): array
{
return [
'blog_category_id',
'slug',
'banner',
'published_at',
'is_published',
'title',
'content',
'excerpt',
'meta_title',
'meta_description',
'translations',
];
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Http\Requests\Support\Resources;
use App\Enums\DataExportScope;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Validation\Rule;
class SupportDataExportResourceRequest extends SupportResourceFormRequest
{
public static function rulesFor(string $action, ?Model $model = null): array
{
$scopeValues = array_map(static fn (DataExportScope $scope): string => $scope->value, DataExportScope::cases());
return [
'scope' => ['required', 'string', Rule::in($scopeValues)],
'tenant_id' => ['required', 'integer', 'exists:tenants,id'],
'event_id' => ['nullable', 'integer', 'exists:events,id', 'required_if:scope,event'],
'include_media' => ['sometimes', 'boolean'],
];
}
public static function allowedFields(string $action): array
{
return [
'scope',
'tenant_id',
'event_id',
'include_media',
];
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Http\Requests\Support\Resources;
use Illuminate\Database\Eloquent\Model;
class SupportEmotionResourceRequest extends SupportResourceFormRequest
{
public static function rulesFor(string $action, ?Model $model = null): array
{
$rules = [
'name' => ['sometimes', 'array'],
'name.de' => ['required_with:name', 'string', 'max:255'],
'name.en' => ['required_with:name', 'string', 'max:255'],
'description' => ['sometimes', 'array'],
'description.de' => ['nullable', 'string'],
'description.en' => ['nullable', 'string'],
'icon' => ['sometimes', 'nullable', 'string', 'max:50'],
'color' => ['sometimes', 'nullable', 'string', 'max:7'],
'sort_order' => ['sometimes', 'integer', 'min:0'],
'is_active' => ['sometimes', 'boolean'],
'tenant_id' => ['sometimes', 'nullable', 'integer', 'exists:tenants,id'],
];
if ($action === 'create') {
$rules['name'] = ['required', 'array'];
$rules['name.de'] = ['required', 'string', 'max:255'];
$rules['name.en'] = ['required', 'string', 'max:255'];
}
return $rules;
}
public static function allowedFields(string $action): array
{
return [
'name',
'description',
'icon',
'color',
'sort_order',
'is_active',
'tenant_id',
];
}
}

View File

@@ -0,0 +1,74 @@
<?php
namespace App\Http\Requests\Support\Resources;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Validation\Rule;
class SupportEventResourceRequest extends SupportResourceFormRequest
{
public static function rulesFor(string $action, ?Model $model = null): array
{
$eventId = $model?->getKey();
$rules = [
'name' => ['sometimes', 'array'],
'name.de' => ['required_with:name', 'string', 'max:255'],
'name.en' => ['nullable', 'string', 'max:255'],
'description' => ['sometimes', 'array'],
'description.de' => ['nullable', 'string'],
'description.en' => ['nullable', 'string'],
'slug' => [
'sometimes',
'string',
'max:255',
Rule::unique('events', 'slug')->ignore($eventId),
],
'date' => ['sometimes', 'date'],
'location' => ['sometimes', 'nullable', 'string', 'max:255'],
'max_participants' => ['sometimes', 'nullable', 'integer', 'min:0'],
'event_type_id' => ['sometimes', 'nullable', 'integer', 'exists:event_types,id'],
'default_locale' => ['sometimes', 'string', 'max:5'],
'is_active' => ['sometimes', 'boolean'],
'status' => ['sometimes', 'string', Rule::in(['draft', 'published', 'archived'])],
'settings' => ['sometimes', 'array'],
'join_link_enabled' => ['sometimes', 'boolean'],
'photo_upload_enabled' => ['sometimes', 'boolean'],
'task_checklist_enabled' => ['sometimes', 'boolean'],
];
if ($action === 'create') {
$rules['name'] = ['required', 'array'];
$rules['name.de'] = ['required', 'string', 'max:255'];
$rules['slug'] = [
'required',
'string',
'max:255',
Rule::unique('events', 'slug'),
];
$rules['date'] = ['required', 'date'];
}
return $rules;
}
public static function allowedFields(string $action): array
{
return [
'name',
'description',
'slug',
'date',
'location',
'max_participants',
'event_type_id',
'default_locale',
'is_active',
'status',
'settings',
'join_link_enabled',
'photo_upload_enabled',
'task_checklist_enabled',
];
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests\Support\Resources;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Validation\Rule;
class SupportPhotoResourceRequest extends SupportResourceFormRequest
{
public static function rulesFor(string $action, ?Model $model = null): array
{
return [
'status' => ['sometimes', 'string', Rule::in(['pending', 'approved', 'rejected', 'hidden'])],
'moderation_notes' => ['nullable', 'required_if:status,rejected', 'string', 'max:1000'],
'is_featured' => ['sometimes', 'boolean'],
'emotion_id' => ['sometimes', 'nullable', 'integer', 'exists:emotions,id'],
'task_id' => ['sometimes', 'nullable', 'integer', 'exists:tasks,id'],
];
}
public static function allowedFields(string $action): array
{
return [
'status',
'moderation_notes',
'is_featured',
'emotion_id',
'task_id',
];
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Http\Requests\Support\Resources;
use Illuminate\Database\Eloquent\Model;
class SupportPhotoboothSettingResourceRequest extends SupportResourceFormRequest
{
public static function rulesFor(string $action, ?Model $model = null): array
{
return [
'ftp_port' => ['sometimes', 'integer', 'min:1', 'max:65535'],
'rate_limit_per_minute' => ['sometimes', 'integer', 'min:1', 'max:200'],
'expiry_grace_days' => ['sometimes', 'integer', 'min:0', 'max:14'],
'require_ftps' => ['sometimes', 'boolean'],
'allowed_ip_ranges' => ['sometimes', 'array'],
'control_service_base_url' => ['sometimes', 'nullable', 'string', 'max:191'],
'control_service_token_identifier' => ['sometimes', 'nullable', 'string', 'max:191'],
];
}
public static function allowedFields(string $action): array
{
return [
'ftp_port',
'rate_limit_per_minute',
'expiry_grace_days',
'require_ftps',
'allowed_ip_ranges',
'control_service_base_url',
'control_service_token_identifier',
];
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Http\Requests\Support\Resources;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Http\FormRequest;
abstract class SupportResourceFormRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public static function rulesFor(string $action, ?Model $model = null): array
{
return [];
}
/**
* @return array<int, string>
*/
public static function allowedFields(string $action): array
{
return [];
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return static::rulesFor('update', null);
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Http\Requests\Support\Resources;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Validation\Rule;
class SupportTaskResourceRequest extends SupportResourceFormRequest
{
public static function rulesFor(string $action, ?Model $model = null): array
{
$rules = [
'emotion_id' => ['sometimes', 'integer', 'exists:emotions,id'],
'event_type_id' => ['sometimes', 'nullable', 'integer', 'exists:event_types,id'],
'title' => ['sometimes', 'array'],
'title.de' => ['required_with:title', 'string', 'max:255'],
'title.en' => ['required_with:title', 'string', 'max:255'],
'description' => ['sometimes', 'array'],
'description.de' => ['nullable', 'string'],
'description.en' => ['nullable', 'string'],
'example_text' => ['sometimes', 'array'],
'example_text.de' => ['nullable', 'string'],
'example_text.en' => ['nullable', 'string'],
'difficulty' => ['sometimes', 'string', Rule::in(['easy', 'medium', 'hard'])],
'sort_order' => ['sometimes', 'integer', 'min:0'],
'is_active' => ['sometimes', 'boolean'],
];
if ($action === 'create') {
$rules['emotion_id'] = ['required', 'integer', 'exists:emotions,id'];
$rules['title'] = ['required', 'array'];
$rules['title.de'] = ['required', 'string', 'max:255'];
$rules['title.en'] = ['required', 'string', 'max:255'];
}
return $rules;
}
public static function allowedFields(string $action): array
{
return [
'emotion_id',
'event_type_id',
'title',
'description',
'example_text',
'difficulty',
'sort_order',
'is_active',
];
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Http\Requests\Support\Resources;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Validation\Rule;
class SupportTenantFeedbackResourceRequest extends SupportResourceFormRequest
{
public static function rulesFor(string $action, ?Model $model = null): array
{
return [
'status' => [
'sometimes',
'string',
Rule::in(['pending', 'resolved', 'hidden', 'deleted']),
],
'moderation_notes' => ['sometimes', 'nullable', 'string', 'max:1000'],
];
}
public static function allowedFields(string $action): array
{
return [
'status',
'moderation_notes',
];
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Http\Requests\Support\Resources;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Validation\Rule;
class SupportTenantResourceRequest extends SupportResourceFormRequest
{
public static function rulesFor(string $action, ?Model $model = null): array
{
$tenantId = $model?->getKey();
return [
'slug' => [
'sometimes',
'string',
'max:255',
Rule::unique('tenants', 'slug')->ignore($tenantId),
],
'contact_email' => ['sometimes', 'email', 'max:255'],
'paddle_customer_id' => ['sometimes', 'nullable', 'string', 'max:191'],
'is_active' => ['sometimes', 'boolean'],
'is_suspended' => ['sometimes', 'boolean'],
'features' => ['sometimes', 'array'],
];
}
public static function allowedFields(string $action): array
{
return [
'slug',
'contact_email',
'paddle_customer_id',
'is_active',
'is_suspended',
'features',
];
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Http\Requests\Support\Resources;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Validation\Rule;
class SupportUserResourceRequest extends SupportResourceFormRequest
{
public static function rulesFor(string $action, ?Model $model = null): array
{
$userId = $model?->getKey();
return [
'first_name' => ['sometimes', 'string', 'max:255'],
'last_name' => ['sometimes', 'string', 'max:255'],
'username' => [
'sometimes',
'string',
'max:255',
Rule::unique('users', 'username')->ignore($userId),
],
'email' => [
'sometimes',
'email',
'max:255',
Rule::unique('users', 'email')->ignore($userId),
],
'address' => ['sometimes', 'string', 'max:1000'],
'phone' => ['sometimes', 'string', 'max:50'],
'preferred_locale' => ['sometimes', 'string', 'max:10'],
];
}
public static function allowedFields(string $action): array
{
return [
'first_name',
'last_name',
'username',
'email',
'address',
'phone',
'preferred_locale',
];
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Http\Requests\Support;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class SupportGuestPolicyRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'guest_downloads_enabled' => ['sometimes', 'boolean'],
'guest_sharing_enabled' => ['sometimes', 'boolean'],
'guest_upload_visibility' => ['sometimes', 'string'],
'per_device_upload_limit' => ['sometimes', 'integer', 'min:0'],
'join_token_failure_limit' => ['sometimes', 'integer', 'min:1'],
'join_token_failure_decay_minutes' => ['sometimes', 'integer', 'min:1'],
'join_token_access_limit' => ['sometimes', 'integer', 'min:0'],
'join_token_access_decay_minutes' => ['sometimes', 'integer', 'min:1'],
'join_token_download_limit' => ['sometimes', 'integer', 'min:0'],
'join_token_download_decay_minutes' => ['sometimes', 'integer', 'min:1'],
'join_token_ttl_hours' => ['sometimes', 'integer', 'min:0'],
'share_link_ttl_hours' => ['sometimes', 'integer', 'min:1'],
'guest_notification_ttl_hours' => ['nullable', 'integer', 'min:1'],
];
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Http\Requests\Support;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class SupportResourceRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'data' => ['required', 'array'],
];
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Http\Requests\Support;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class SupportTokenRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'login' => ['required', 'string'],
'password' => ['required', 'string'],
'abilities' => ['sometimes', 'array'],
'abilities.*' => ['string'],
];
}
/**
* @return array{email?: string, username?: string, password: string, abilities?: array<int, string>}
*/
public function credentials(): array
{
$login = $this->string('login')->trim()->value();
$credentials = [
'password' => $this->string('password')->value(),
];
if (filter_var($login, FILTER_VALIDATE_EMAIL)) {
$credentials['email'] = $login;
} else {
$credentials['username'] = $login;
}
$abilities = $this->input('abilities');
if (is_array($abilities) && $abilities !== []) {
$credentials['abilities'] = array_values(array_filter($abilities, 'is_string'));
}
return $credentials;
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests\Support;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class SupportWatermarkSettingsRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'asset' => ['sometimes', 'string'],
'position' => ['sometimes', 'string'],
'opacity' => ['sometimes', 'numeric', 'min:0', 'max:1'],
'scale' => ['sometimes', 'numeric', 'min:0.05', 'max:1'],
'padding' => ['sometimes', 'integer', 'min:0'],
'offset_x' => ['sometimes', 'integer', 'min:-500', 'max:500'],
'offset_y' => ['sometimes', 'integer', 'min:-500', 'max:500'],
];
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Http\Requests\Support\Tenant;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class SupportTenantAddPackageRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'package_id' => ['required', 'integer'],
'expires_at' => ['nullable', 'date'],
'reason' => ['nullable', 'string'],
];
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Http\Requests\Support\Tenant;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class SupportTenantScheduleDeletionRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'pending_deletion_at' => ['required', 'date', 'after:now'],
'send_warning' => ['sometimes', 'boolean'],
];
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Http\Requests\Support\Tenant;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class SupportTenantSetGracePeriodRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'grace_period_ends_at' => ['required', 'date', 'after_or_equal:now'],
'note' => ['nullable', 'string'],
];
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Http\Requests\Support\Tenant;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class SupportTenantUpdateLimitsRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'max_photos_per_event' => ['required', 'integer', 'min:0'],
'max_storage_mb' => ['required', 'integer', 'min:0'],
'note' => ['nullable', 'string'],
];
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Http\Requests\Support\Tenant;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class SupportTenantUpdateSubscriptionRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'subscription_expires_at' => ['nullable', 'date'],
'note' => ['nullable', 'string'],
];
}
}

View File

@@ -71,12 +71,44 @@ class ArchiveEventMediaAssets implements ShouldQueue
Storage::disk($archiveDisk)->put($archivePath, $stream);
$checksumMeta = null;
$archiveChecksum = null;
if ($this->checksumValidationEnabled()) {
$archiveChecksum = $this->computeChecksum($archiveDisk, $archivePath);
if (! $archiveChecksum) {
throw new \RuntimeException('Archive checksum unavailable');
}
$expectedChecksum = $asset->checksum;
if ($expectedChecksum) {
if (! hash_equals($expectedChecksum, $archiveChecksum)) {
$this->handleChecksumMismatch($asset, $expectedChecksum, $archiveChecksum, $sourceDisk, $archiveDisk);
$this->deleteArchiveCopy($archiveDisk, $archivePath);
continue;
}
$checksumMeta = [
'checksum_status' => 'verified',
'checksum_verified_at' => now()->toIso8601String(),
];
} else {
$asset->checksum = $archiveChecksum;
$checksumMeta = [
'checksum_status' => 'seeded',
'checksum_verified_at' => now()->toIso8601String(),
];
}
}
$asset->fill([
'disk' => $archiveDisk,
'media_storage_target_id' => $archiveTargetId,
'status' => 'archived',
'archived_at' => now(),
'error_message' => null,
'checksum' => $asset->checksum,
'meta' => $this->mergeMeta($asset->meta, $checksumMeta),
])->save();
if ($this->deleteSource) {
@@ -102,4 +134,92 @@ class ArchiveEventMediaAssets implements ShouldQueue
}
}
}
private function checksumValidationEnabled(): bool
{
return (bool) config('storage-monitor.checksum_validation.enabled', true);
}
private function computeChecksum(string $disk, string $path): ?string
{
try {
$stream = Storage::disk($disk)->readStream($path);
} catch (\Throwable $e) {
Log::channel('storage-jobs')->warning('Failed to open stream for checksum', [
'disk' => $disk,
'path' => $path,
'error' => $e->getMessage(),
]);
return null;
}
if (! $stream) {
return null;
}
try {
$context = hash_init('sha256');
$ok = hash_update_stream($context, $stream);
if ($ok === false) {
return null;
}
return hash_final($context);
} finally {
if (is_resource($stream)) {
fclose($stream);
}
}
}
private function handleChecksumMismatch(
EventMediaAsset $asset,
string $expectedChecksum,
string $actualChecksum,
string $sourceDisk,
string $archiveDisk,
): void {
Log::channel('storage-jobs')->alert('Checksum mismatch detected during archive', [
'asset_id' => $asset->id,
'event_id' => $asset->event_id,
'source_disk' => $sourceDisk,
'archive_disk' => $archiveDisk,
'expected_checksum' => $expectedChecksum,
'actual_checksum' => $actualChecksum,
]);
$asset->update([
'status' => 'failed',
'error_message' => 'checksum_mismatch',
'meta' => $this->mergeMeta($asset->meta, [
'checksum_status' => 'mismatch',
'checksum_verified_at' => now()->toIso8601String(),
'checksum_expected' => $expectedChecksum,
'checksum_actual' => $actualChecksum,
]),
]);
}
private function deleteArchiveCopy(string $archiveDisk, string $path): void
{
try {
Storage::disk($archiveDisk)->delete($path);
} catch (\Throwable $e) {
Log::channel('storage-jobs')->warning('Failed to clean up archive copy after checksum mismatch', [
'disk' => $archiveDisk,
'path' => $path,
'error' => $e->getMessage(),
]);
}
}
private function mergeMeta(?array $meta, ?array $updates): ?array
{
if (! $updates) {
return $meta;
}
return array_merge($meta ?? [], $updates);
}
}

View File

@@ -63,12 +63,11 @@ class BlogPost extends Model
}
$path = ltrim($this->banner, '/');
if (str_starts_with($path, 'storage/')) {
$path = substr($path, strlen('storage/'));
}
return \URL::temporarySignedRoute(
'api.v1.branding.asset',
now()->addMinutes(30),
['path' => $path]
);
return \Storage::disk('public')->url($path);
});
}

View File

@@ -103,6 +103,11 @@ class TenantPackage extends Model
$tenantPackage->purchased_at = now();
}
if ($tenantPackage->price === null) {
$package = $tenantPackage->package ?? Package::query()->find($tenantPackage->package_id);
$tenantPackage->price = $package?->price ?? 0;
}
$package = $tenantPackage->package;
if ($package && $package->isReseller()) {

View File

@@ -47,6 +47,7 @@ use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\ServiceProvider;
use Inertia\Inertia;
use Livewire\Livewire;
class AppServiceProvider extends ServiceProvider
{
@@ -200,6 +201,11 @@ class AppServiceProvider extends ServiceProvider
];
});
Livewire::component(
'support-api-token-manager',
\App\Filament\SuperAdmin\Widgets\SupportApiTokenManager::class
);
RateLimiter::for('gift-resend', function (Request $request) {
$code = strtoupper((string) $request->input('code'));
$ip = $request->ip() ?? 'unknown';

View File

@@ -81,7 +81,7 @@ class SuperAdminPanelProvider extends PanelProvider
/*->plugin(
BlogPlugin::make()
)*/
->profile()
->profile(\App\Filament\SuperAdmin\Pages\Auth\EditProfile::class, isSimple: false)
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets')
->widgets([
Widgets\AccountWidget::class,

View File

@@ -93,11 +93,15 @@ class SuperAdminAuditLogger
return $panel->getId() === 'superadmin';
}
if (request()->is('super-admin*') || request()->is('api/v1/support*')) {
return true;
}
if (app()->runningInConsole()) {
return false;
}
return request()->is('super-admin*');
return false;
}
/**

View File

@@ -40,6 +40,48 @@ class DokployClient
}, 30);
}
public function projects(): array
{
return $this->cached($this->projectsCacheKey(), function () {
$projects = $this->get('/project.all');
return is_array($projects) ? $projects : [];
}, 60);
}
public function project(string $projectId): array
{
return $this->cached($this->projectCacheKey($projectId), function () use ($projectId) {
$project = $this->get('/project.one', [
'projectId' => $projectId,
]);
return is_array($project) ? $project : [];
}, 60);
}
public function findProject(string $projectIdOrName): ?array
{
$projects = $this->projects();
foreach ($projects as $project) {
if (Arr::get($project, 'projectId') === $projectIdOrName) {
return $project;
}
}
foreach ($projects as $project) {
if (
Arr::get($project, 'name') === $projectIdOrName
|| Arr::get($project, 'projectName') === $projectIdOrName
) {
return $project;
}
}
return null;
}
public function recentDeployments(string $applicationId, int $limit = 5): array
{
return $this->cached($this->deploymentCacheKey($applicationId), function () use ($applicationId, $limit) {
@@ -321,6 +363,16 @@ class DokployClient
return "dokploy.compose.deployments.{$composeId}";
}
protected function projectsCacheKey(): string
{
return 'dokploy.projects';
}
protected function projectCacheKey(string $projectId): string
{
return "dokploy.project.{$projectId}";
}
protected function forgetApplicationCaches(string $applicationId): void
{
Cache::forget($this->applicationCacheKey($applicationId));

View File

@@ -4,6 +4,7 @@ namespace App\Services;
use App\Models\Event;
use App\Models\EventJoinToken;
use App\Models\EventJoinTokenEvent;
use App\Models\GuestPolicySetting;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon;
@@ -63,7 +64,7 @@ class EventJoinTokenService
return $joinToken;
}
public function incrementUsage(EventJoinToken $joinToken): void
public function incrementUsage(EventJoinToken $joinToken, ?string $deviceId = null, ?string $ipAddress = null): void
{
$joinToken->increment('usage_count');
@@ -78,6 +79,12 @@ class EventJoinTokenService
$eventPackage = $limitEvaluator->resolveEventPackageForPhotoUpload($event->tenant, $event->id, $event);
if ($eventPackage && $eventPackage->package?->max_guests !== null) {
$normalizedDeviceId = $this->normalizeDeviceId($deviceId);
$normalizedIp = is_string($ipAddress) && trim($ipAddress) !== '' ? $ipAddress : null;
if (! $this->shouldCountGuest($event->id, $normalizedDeviceId, $normalizedIp)) {
return;
}
$previous = (int) $eventPackage->used_guests;
$eventPackage->increment('used_guests');
$eventPackage->refresh();
@@ -87,6 +94,28 @@ class EventJoinTokenService
}
}
public function hasSeenGuest(int $eventId, ?string $deviceId, ?string $ipAddress): bool
{
$normalizedDeviceId = $this->normalizeDeviceId($deviceId);
$normalizedIp = is_string($ipAddress) && trim($ipAddress) !== '' ? $ipAddress : null;
if (! $normalizedDeviceId && ! $normalizedIp) {
return false;
}
$query = EventJoinTokenEvent::query()
->where('event_id', $eventId)
->where('event_type', 'access_granted');
if ($normalizedDeviceId) {
$query->where('device_id', $normalizedDeviceId);
} else {
$query->where('ip_address', $normalizedIp);
}
return $query->exists();
}
public function findToken(string $token, bool $includeInactive = false): ?EventJoinToken
{
$hash = $this->hashToken($token);
@@ -132,4 +161,35 @@ class EventJoinTokenService
{
return hash('sha256', $token);
}
private function normalizeDeviceId(?string $deviceId): ?string
{
if (! is_string($deviceId) || trim($deviceId) === '') {
return null;
}
$cleaned = preg_replace('/[^a-zA-Z0-9_-]/', '', $deviceId) ?? '';
$cleaned = substr($cleaned, 0, 64);
return $cleaned !== '' ? $cleaned : null;
}
private function shouldCountGuest(int $eventId, ?string $deviceId, ?string $ipAddress): bool
{
if (! $deviceId && ! $ipAddress) {
return false;
}
$query = EventJoinTokenEvent::query()
->where('event_id', $eventId)
->where('event_type', 'access_granted');
if ($deviceId) {
$query->where('device_id', $deviceId);
} else {
$query->where('ip_address', $ipAddress);
}
return $query->count() <= 1;
}
}

View File

@@ -4,6 +4,7 @@ namespace App\Services\Help;
use Illuminate\Filesystem\Filesystem;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
@@ -70,6 +71,8 @@ class HelpSyncService
}
}
$articles = $this->hydrateRelatedTitles($articles);
$disk = config('help.disk');
$compiledPath = trim(config('help.compiled_path'), '/');
$written = [];
@@ -87,6 +90,58 @@ class HelpSyncService
return $written;
}
private function hydrateRelatedTitles(Collection $articles): Collection
{
$titleIndex = $articles->mapWithKeys(function (array $article) {
$audience = Arr::get($article, 'audience');
$locale = Arr::get($article, 'locale');
$slug = Arr::get($article, 'slug');
if (! $audience || ! $locale || ! $slug) {
return [];
}
return [$this->articleKey((string) $audience, (string) $locale, (string) $slug) => Arr::get($article, 'title')];
});
return $articles->map(function (array $article) use ($titleIndex) {
$related = Arr::get($article, 'related', []);
if (empty($related)) {
return $article;
}
$audience = (string) Arr::get($article, 'audience');
$locale = (string) Arr::get($article, 'locale');
$article['related'] = collect($related)
->map(function ($item) use ($titleIndex, $audience, $locale) {
$slug = is_array($item) ? Arr::get($item, 'slug') : (is_string($item) ? $item : null);
if (! $slug) {
return null;
}
$title = $titleIndex->get($this->articleKey($audience, $locale, $slug));
return array_filter([
'slug' => $slug,
'title' => $title,
], static fn ($value) => $value !== null && $value !== '');
})
->filter()
->values()
->all();
return $article;
});
}
private function articleKey(string $audience, string $locale, string $slug): string
{
return sprintf('%s::%s::%s', $audience, $locale, $slug);
}
private function parseFile(SplFileInfo $file): array
{
$contents = $this->files->get($file->getPathname());

View File

@@ -233,10 +233,13 @@ class PackageLimitEvaluator
config('package-limits.photo_thresholds', [])
);
$guestSummary = $this->buildUsageSummary(
(int) $eventPackage->used_guests,
$limits['max_guests'],
config('package-limits.guest_thresholds', [])
$guestSummary = $this->applyGuestGrace(
$this->buildUsageSummary(
(int) $eventPackage->used_guests,
$limits['max_guests'],
config('package-limits.guest_thresholds', [])
),
(int) $eventPackage->used_guests
);
$gallerySummary = $this->buildGallerySummary(
@@ -429,4 +432,37 @@ class PackageLimitEvaluator
return $value;
}
/**
* @param array{limit: ?int, used: int, remaining: ?int, percentage: ?float, state: string, threshold_reached: ?float, next_threshold: ?float, thresholds: array} $summary
* @return array{limit: ?int, used: int, remaining: ?int, percentage: ?float, state: string, threshold_reached: ?float, next_threshold: ?float, thresholds: array}
*/
private function applyGuestGrace(array $summary, int $used): array
{
$limit = $summary['limit'] ?? null;
if ($limit === null || $limit <= 0) {
return $summary;
}
$grace = (int) config('package-limits.guest_grace', 10);
$hardLimit = $limit + max(0, $grace);
if ($used >= $hardLimit) {
$summary['state'] = 'limit_reached';
$summary['threshold_reached'] = 1.0;
$summary['next_threshold'] = null;
$summary['remaining'] = 0;
return $summary;
}
if ($used >= $limit) {
$summary['state'] = 'warning';
$summary['threshold_reached'] = 1.0;
$summary['next_threshold'] = null;
$summary['remaining'] = 0;
}
return $summary;
}
}

View File

@@ -58,6 +58,8 @@ class PackageUsageTracker
}
$newUsed = $eventPackage->used_guests;
$grace = (int) config('package-limits.guest_grace', 10);
$hardLimit = $limit + max(0, $grace);
$thresholds = collect(config('package-limits.guest_thresholds', []))
->filter(fn (float $value) => $value > 0 && $value < 1)
@@ -80,8 +82,8 @@ class PackageUsageTracker
}
}
if ($newUsed >= $limit && ($previousUsed < $limit)) {
$this->dispatcher->dispatch(new EventPackageGuestLimitReached($eventPackage, $limit));
if ($newUsed >= $hardLimit && ($previousUsed < $hardLimit)) {
$this->dispatcher->dispatch(new EventPackageGuestLimitReached($eventPackage, $hardLimit));
}
}
}

View File

@@ -0,0 +1,86 @@
<?php
namespace App\Support;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class SupportApiAuthorizer
{
public static function authorizeResource(Request $request, string $resource, string $action): ?JsonResponse
{
$abilities = SupportApiRegistry::abilitiesFor($resource, $action);
return self::authorizeAbilities($request, $abilities, $action);
}
/**
* @param array<int, string> $abilities
*/
public static function authorizeAbilities(Request $request, array $abilities, string $actionLabel = 'resource'): ?JsonResponse
{
if ($abilities === []) {
return null;
}
$token = $request->user()?->currentAccessToken();
if (! $token) {
return ApiError::response(
'unauthenticated',
'Unauthenticated',
'Missing access token for support request.',
401
);
}
foreach ($abilities as $ability) {
if (! $token->can($ability)) {
return ApiError::response(
'forbidden',
'Forbidden',
"Missing required ability for support {$actionLabel}.",
403,
['required' => $abilities]
);
}
}
return null;
}
/**
* @param array<int, string> $abilities
*/
public static function authorizeAnyAbility(Request $request, array $abilities, string $actionLabel = 'resource'): ?JsonResponse
{
if ($abilities === []) {
return null;
}
$token = $request->user()?->currentAccessToken();
if (! $token) {
return ApiError::response(
'unauthenticated',
'Unauthenticated',
'Missing access token for support request.',
401
);
}
foreach ($abilities as $ability) {
if ($token->can($ability)) {
return null;
}
}
return ApiError::response(
'forbidden',
'Forbidden',
"Missing required ability for support {$actionLabel}.",
403,
['required' => $abilities]
);
}
}

View File

@@ -0,0 +1,148 @@
<?php
namespace App\Support;
class SupportApiRegistry
{
/**
* @return array<string, array<string, mixed>>
*/
public static function resources(): array
{
return config('support-api.resources', []);
}
/**
* @return array<string, mixed>|null
*/
public static function get(string $resource): ?array
{
$resources = self::resources();
return $resources[$resource] ?? null;
}
public static function validationClass(string $resource, string $action): ?string
{
$config = self::get($resource);
if (! $config) {
return null;
}
$validation = $config['validation'][$action] ?? null;
return is_string($validation) ? $validation : null;
}
/**
* @return array<int, string>
*/
public static function resourceKeys(): array
{
return array_keys(self::resources());
}
public static function resourcePattern(): string
{
$keys = self::resourceKeys();
if ($keys === []) {
return '.*';
}
$escaped = array_map(static fn (string $key): string => preg_quote($key, '/'), $keys);
return implode('|', $escaped);
}
/**
* @return array<int, string>
*/
public static function abilitiesFor(string $resource, string $action): array
{
$config = self::get($resource);
if (! $config) {
return [];
}
$abilities = $config['abilities'][$action] ?? null;
if (is_array($abilities) && $abilities !== []) {
return $abilities;
}
return match ($action) {
'read' => ['support:read'],
'write' => ['support:write'],
'actions' => ['support:actions'],
default => [],
};
}
public static function isReadOnly(string $resource): bool
{
$config = self::get($resource);
return (bool) ($config['read_only'] ?? false);
}
public static function auditAction(string $resource, string $operation): string
{
$config = self::get($resource);
$action = null;
if ($config && is_array($config['audit'] ?? null)) {
$action = $config['audit'][$operation] ?? null;
}
if (is_string($action) && $action !== '') {
return $action;
}
return $resource.'.'.$operation;
}
public static function allowsMutation(string $resource, string $action): bool
{
if (self::isReadOnly($resource)) {
return false;
}
$config = self::get($resource);
$mutations = $config['mutations'] ?? null;
if (! is_array($mutations)) {
return true;
}
return (bool) ($mutations[$action] ?? false);
}
/**
* @return array<int, string>
*/
public static function searchFields(string $resource): array
{
$config = self::get($resource);
$fields = $config['search'] ?? [];
return is_array($fields) ? $fields : [];
}
/**
* @return array<int, string>
*/
public static function withRelations(string $resource): array
{
$config = self::get($resource);
$relations = $config['with'] ?? [];
return is_array($relations) ? $relations : [];
}
}

View File

@@ -1,6 +1,7 @@
<?php
use App\Http\Middleware\CreditCheckMiddleware;
use App\Http\Middleware\EnsureSupportToken;
use App\Http\Middleware\EnsureTenantAdminToken;
use App\Http\Middleware\EnsureTenantCollaboratorToken;
use App\Http\Middleware\HandleAppearance;
@@ -104,6 +105,7 @@ return Application::configure(basePath: dirname(__DIR__))
'credit.check' => CreditCheckMiddleware::class,
'tenant.admin' => EnsureTenantAdminToken::class,
'tenant.collaborator' => EnsureTenantCollaboratorToken::class,
'support.token' => EnsureSupportToken::class,
]);
$middleware->encryptCookies(except: ['appearance', 'sidebar_state']);

View File

@@ -22,6 +22,7 @@
"minishlink/web-push": "*",
"sentry/sentry-laravel": "*",
"simplesoftwareio/simple-qrcode": "^4.2",
"spatie/laravel-honeypot": "*",
"spatie/laravel-translatable": "^6.11",
"staudenmeir/belongs-to-through": "^2.17",
"stripe/stripe-php": "*",

78
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "5e1d60e650853d6113b01e1adaf49d65",
"content-hash": "a4956012b0e374c8f74b61a892e6b984",
"packages": [
{
"name": "anourvalar/eloquent-serialize",
@@ -6804,6 +6804,82 @@
],
"time": "2024-05-17T09:06:10+00:00"
},
{
"name": "spatie/laravel-honeypot",
"version": "4.6.2",
"source": {
"type": "git",
"url": "https://github.com/spatie/laravel-honeypot.git",
"reference": "62ec9dbecd2a17a4e2af62b09675f89813295cac"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/laravel-honeypot/zipball/62ec9dbecd2a17a4e2af62b09675f89813295cac",
"reference": "62ec9dbecd2a17a4e2af62b09675f89813295cac",
"shasum": ""
},
"require": {
"illuminate/contracts": "^11.0|^12.0",
"illuminate/encryption": "^11.0|^12.0",
"illuminate/http": "^11.0|^12.0",
"illuminate/support": "^11.0|^12.0",
"illuminate/validation": "^11.0|^12.0",
"nesbot/carbon": "^2.0|^3.0",
"php": "^8.2",
"spatie/laravel-package-tools": "^1.9",
"symfony/http-foundation": "^7.0|^8.0"
},
"require-dev": {
"livewire/livewire": "^3.0",
"orchestra/testbench": "^9.0|^10.0",
"pestphp/pest": "^2.0|^3.0|^4.0",
"pestphp/pest-plugin-livewire": "^1.0|^2.1|^3.0|^4.0",
"spatie/pest-plugin-snapshots": "^1.1|^2.1",
"spatie/phpunit-snapshot-assertions": "^4.2|^5.1",
"spatie/test-time": "^1.2.1"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Spatie\\Honeypot\\HoneypotServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Spatie\\Honeypot\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Freek Van der Herten",
"email": "freek@spatie.be",
"homepage": "https://spatie.be",
"role": "Developer"
}
],
"description": "Preventing spam submitted through forms",
"homepage": "https://github.com/spatie/laravel-honeypot",
"keywords": [
"laravel-honeypot",
"spatie"
],
"support": {
"source": "https://github.com/spatie/laravel-honeypot/tree/4.6.2"
},
"funding": [
{
"url": "https://spatie.be/open-source/support-us",
"type": "custom"
}
],
"time": "2025-11-28T09:57:48+00:00"
},
{
"name": "spatie/laravel-package-tools",
"version": "1.92.7",

View File

@@ -8,5 +8,6 @@ return [
],
'web_url' => env('DOKPLOY_WEB_URL'),
'applications' => json_decode(env('DOKPLOY_APPLICATION_IDS', '{}'), true) ?? [],
'projects' => json_decode(env('DOKPLOY_PROJECT_IDS', '{}'), true) ?? [],
'composes' => json_decode(env('DOKPLOY_COMPOSE_IDS', '{}'), true) ?? [],
];

View File

@@ -30,21 +30,21 @@ return [
],
[
'key' => 'gift-standard',
'label' => 'Geschenk Standard',
'label' => 'Geschenk Classic',
'amount' => 59.00,
'currency' => 'EUR',
'paddle_price_id' => env('PADDLE_GIFT_PRICE_STANDARD', 'pri_01kbwccfvzrf4z2f1r62vns7gh'),
],
[
'key' => 'gift-standard-usd',
'label' => 'Gift Standard (USD)',
'label' => 'Gift Classic (USD)',
'amount' => 65.00,
'currency' => 'USD',
'paddle_price_id' => env('PADDLE_GIFT_PRICE_STANDARD_USD'),
],
[
'key' => 'gift-standard-gbp',
'label' => 'Gift Standard (GBP)',
'label' => 'Gift Classic (GBP)',
'amount' => 55.00,
'currency' => 'GBP',
'paddle_price_id' => env('PADDLE_GIFT_PRICE_STANDARD_GBP'),
@@ -70,27 +70,6 @@ return [
'currency' => 'GBP',
'paddle_price_id' => env('PADDLE_GIFT_PRICE_PREMIUM_GBP'),
],
[
'key' => 'gift-premium-plus',
'label' => 'Geschenk Premium Plus',
'amount' => 149.00,
'currency' => 'EUR',
'paddle_price_id' => env('PADDLE_GIFT_PRICE_PREMIUM_PLUS', 'pri_01kbwccgnjzwrjy5xg1yp981p6'),
],
[
'key' => 'gift-premium-plus-usd',
'label' => 'Gift Premium Plus (USD)',
'amount' => 159.00,
'currency' => 'USD',
'paddle_price_id' => env('PADDLE_GIFT_PRICE_PREMIUM_PLUS_USD'),
],
[
'key' => 'gift-premium-plus-gbp',
'label' => 'Gift Premium Plus (GBP)',
'amount' => 139.00,
'currency' => 'GBP',
'paddle_price_id' => env('PADDLE_GIFT_PRICE_PREMIUM_PLUS_GBP'),
],
],
// Package types a voucher coupon should apply to.

View File

@@ -9,6 +9,7 @@ return [
0.8,
0.95,
],
'guest_grace' => 10,
'gallery_warning_days' => [
7,
1,

View File

@@ -57,6 +57,11 @@ return [
'client_secret' => env('GOOGLE_CLIENT_SECRET'),
'redirect' => env('GOOGLE_REDIRECT_URI', rtrim(env('APP_URL', ''), '/').'/checkout/auth/google/callback'),
],
'facebook' => [
'client_id' => env('FACEBOOK_CLIENT_ID'),
'client_secret' => env('FACEBOOK_CLIENT_SECRET'),
'redirect' => env('FACEBOOK_REDIRECT_URI', rtrim(env('APP_URL', ''), '/').'/checkout/auth/facebook/callback'),
],
'matomo' => [
'enabled' => env('MATOMO_ENABLED', false),

View File

@@ -12,6 +12,15 @@ return [
'critical' => (int) env('STORAGE_CAPACITY_CRITICAL', 90),
],
'checksum_validation' => [
'enabled' => (bool) env('STORAGE_CHECKSUM_VALIDATION', true),
'alert_window_minutes' => (int) env('STORAGE_CHECKSUM_ALERT_WINDOW_MINUTES', 60),
'thresholds' => [
'warning' => (int) env('STORAGE_CHECKSUM_WARNING', 1),
'critical' => (int) env('STORAGE_CHECKSUM_CRITICAL', 5),
],
],
'monitor' => [
'lock_seconds' => (int) env('STORAGE_MONITOR_LOCK_SECONDS', 300),
'cache_minutes' => (int) env('STORAGE_MONITOR_CACHE_MINUTES', 15),

418
config/support-api.php Normal file
View File

@@ -0,0 +1,418 @@
<?php
use App\Http\Requests\Support\Resources\SupportBlogPostResourceRequest;
use App\Http\Requests\Support\Resources\SupportDataExportResourceRequest;
use App\Http\Requests\Support\Resources\SupportEmotionResourceRequest;
use App\Http\Requests\Support\Resources\SupportEventResourceRequest;
use App\Http\Requests\Support\Resources\SupportPhotoboothSettingResourceRequest;
use App\Http\Requests\Support\Resources\SupportPhotoResourceRequest;
use App\Http\Requests\Support\Resources\SupportTaskResourceRequest;
use App\Http\Requests\Support\Resources\SupportTenantFeedbackResourceRequest;
use App\Http\Requests\Support\Resources\SupportTenantResourceRequest;
use App\Http\Requests\Support\Resources\SupportUserResourceRequest;
use App\Models\BlogCategory;
use App\Models\BlogPost;
use App\Models\Coupon;
use App\Models\DataExport;
use App\Models\Emotion;
use App\Models\Event;
use App\Models\EventPurchase;
use App\Models\EventType;
use App\Models\GiftVoucher;
use App\Models\InfrastructureActionLog;
use App\Models\LegalPage;
use App\Models\MediaStorageTarget;
use App\Models\Package;
use App\Models\PackageAddon;
use App\Models\PackagePurchase;
use App\Models\Photo;
use App\Models\PhotoboothSetting;
use App\Models\PurchaseHistory;
use App\Models\RetentionOverride;
use App\Models\SuperAdminActionLog;
use App\Models\Task;
use App\Models\TaskCollection;
use App\Models\Tenant;
use App\Models\TenantAnnouncement;
use App\Models\TenantFeedback;
use App\Models\TenantPackage;
use App\Models\User;
return [
'token' => [
'name' => 'support-api',
'default_abilities' => [
'support-admin',
'support:read',
'support:write',
'support:actions',
'support:billing',
'support:ops',
'support:content',
'support:settings',
'support:infrastructure',
],
],
'pagination' => [
'default_per_page' => 50,
'max_per_page' => 200,
],
'resources' => [
'tenants' => [
'model' => Tenant::class,
'search' => ['name', 'slug', 'contact_email', 'paddle_customer_id'],
'with' => ['user', 'activeResellerPackage'],
'abilities' => [
'read' => ['support:read'],
'write' => ['support:write'],
'actions' => ['support:actions'],
],
'validation' => [
'update' => SupportTenantResourceRequest::class,
],
'mutations' => [
'create' => false,
'update' => true,
'delete' => false,
],
],
'users' => [
'model' => User::class,
'search' => ['email', 'username', 'name', 'first_name', 'last_name'],
'abilities' => [
'read' => ['support:read'],
'write' => ['support:write'],
],
'validation' => [
'update' => SupportUserResourceRequest::class,
],
'mutations' => [
'create' => false,
'update' => true,
'delete' => false,
],
],
'events' => [
'model' => Event::class,
'search' => ['name', 'slug'],
'abilities' => [
'read' => ['support:read'],
'write' => ['support:write'],
],
'validation' => [
'update' => SupportEventResourceRequest::class,
],
'mutations' => [
'create' => false,
'update' => true,
'delete' => true,
],
],
'event-types' => [
'model' => EventType::class,
'search' => ['name', 'slug'],
'read_only' => true,
'abilities' => [
'read' => ['support:read'],
],
'mutations' => [
'create' => false,
'update' => false,
'delete' => false,
],
],
'photos' => [
'model' => Photo::class,
'search' => ['id'],
'abilities' => [
'read' => ['support:read'],
'write' => ['support:write'],
],
'validation' => [
'update' => SupportPhotoResourceRequest::class,
],
'mutations' => [
'create' => false,
'update' => true,
'delete' => true,
],
],
'event-purchases' => [
'model' => EventPurchase::class,
'search' => ['id'],
'read_only' => true,
'abilities' => [
'read' => ['support:billing'],
],
],
'purchases' => [
'model' => PackagePurchase::class,
'search' => ['provider_id'],
'read_only' => true,
'abilities' => [
'read' => ['support:billing'],
],
],
'purchase-histories' => [
'model' => PurchaseHistory::class,
'search' => ['provider_id'],
'read_only' => true,
'abilities' => [
'read' => ['support:billing'],
],
],
'packages' => [
'model' => Package::class,
'search' => ['name', 'slug'],
'read_only' => true,
'abilities' => [
'read' => ['support:billing'],
],
'mutations' => [
'create' => false,
'update' => false,
'delete' => false,
],
],
'package-addons' => [
'model' => PackageAddon::class,
'search' => ['name', 'slug'],
'read_only' => true,
'abilities' => [
'read' => ['support:billing'],
],
'mutations' => [
'create' => false,
'update' => false,
'delete' => false,
],
],
'tenant-packages' => [
'model' => TenantPackage::class,
'search' => ['id'],
'read_only' => true,
'abilities' => [
'read' => ['support:billing'],
],
],
'coupons' => [
'model' => Coupon::class,
'search' => ['code', 'name'],
'read_only' => true,
'abilities' => [
'read' => ['support:billing'],
],
'mutations' => [
'create' => false,
'update' => false,
'delete' => false,
],
],
'gift-vouchers' => [
'model' => GiftVoucher::class,
'search' => ['code', 'email'],
'read_only' => true,
'abilities' => [
'read' => ['support:billing'],
],
'mutations' => [
'create' => false,
'update' => false,
'delete' => false,
],
],
'tenant-feedback' => [
'model' => TenantFeedback::class,
'search' => ['email', 'message'],
'abilities' => [
'read' => ['support:read'],
'write' => ['support:write'],
],
'validation' => [
'update' => SupportTenantFeedbackResourceRequest::class,
],
'mutations' => [
'create' => false,
'update' => true,
'delete' => false,
],
],
'tenant-announcements' => [
'model' => TenantAnnouncement::class,
'search' => ['title', 'body'],
'read_only' => true,
'abilities' => [
'read' => ['support:read'],
],
'mutations' => [
'create' => false,
'update' => false,
'delete' => false,
],
],
'media-storage-targets' => [
'model' => MediaStorageTarget::class,
'search' => ['name', 'driver'],
'read_only' => true,
'abilities' => [
'read' => ['support:ops'],
],
'mutations' => [
'create' => false,
'update' => false,
'delete' => false,
],
],
'retention-overrides' => [
'model' => RetentionOverride::class,
'search' => ['id'],
'read_only' => true,
'abilities' => [
'read' => ['support:ops'],
],
'mutations' => [
'create' => false,
'update' => false,
'delete' => false,
],
],
'data-exports' => [
'model' => DataExport::class,
'search' => ['id'],
'abilities' => [
'read' => ['support:ops'],
'write' => ['support:ops'],
],
'validation' => [
'create' => SupportDataExportResourceRequest::class,
],
'mutations' => [
'create' => true,
'update' => false,
'delete' => false,
],
],
'photobooth-settings' => [
'model' => PhotoboothSetting::class,
'search' => ['label'],
'abilities' => [
'read' => ['support:ops'],
'write' => ['support:ops'],
],
'validation' => [
'update' => SupportPhotoboothSettingResourceRequest::class,
],
'mutations' => [
'create' => false,
'update' => true,
'delete' => false,
],
],
'legal-pages' => [
'model' => LegalPage::class,
'search' => ['slug', 'title'],
'read_only' => true,
'abilities' => [
'read' => ['support:content'],
],
'mutations' => [
'create' => false,
'update' => false,
'delete' => false,
],
],
'blog-categories' => [
'model' => BlogCategory::class,
'search' => ['name', 'slug'],
'read_only' => true,
'abilities' => [
'read' => ['support:content'],
],
'mutations' => [
'create' => false,
'update' => false,
'delete' => false,
],
],
'blog-posts' => [
'model' => BlogPost::class,
'search' => ['title', 'slug'],
'abilities' => [
'read' => ['support:content'],
'write' => ['support:content'],
],
'validation' => [
'create' => SupportBlogPostResourceRequest::class,
'update' => SupportBlogPostResourceRequest::class,
],
'mutations' => [
'create' => true,
'update' => true,
'delete' => true,
],
],
'emotions' => [
'model' => Emotion::class,
'search' => ['name', 'slug'],
'abilities' => [
'read' => ['support:content'],
'write' => ['support:content'],
],
'validation' => [
'create' => SupportEmotionResourceRequest::class,
'update' => SupportEmotionResourceRequest::class,
],
'mutations' => [
'create' => true,
'update' => true,
'delete' => true,
],
],
'tasks' => [
'model' => Task::class,
'search' => ['title'],
'abilities' => [
'read' => ['support:content'],
'write' => ['support:content'],
],
'validation' => [
'create' => SupportTaskResourceRequest::class,
'update' => SupportTaskResourceRequest::class,
],
'mutations' => [
'create' => true,
'update' => true,
'delete' => true,
],
],
'task-collections' => [
'model' => TaskCollection::class,
'search' => ['name'],
'read_only' => true,
'abilities' => [
'read' => ['support:read'],
],
'mutations' => [
'create' => false,
'update' => false,
'delete' => false,
],
],
'super-admin-action-logs' => [
'model' => SuperAdminActionLog::class,
'search' => ['action', 'target_type'],
'read_only' => true,
'abilities' => [
'read' => ['support:infrastructure'],
],
],
'infrastructure-action-logs' => [
'model' => InfrastructureActionLog::class,
'search' => ['action', 'target_type'],
'read_only' => true,
'abilities' => [
'read' => ['support:infrastructure'],
],
],
],
];

View File

@@ -41,7 +41,7 @@ class BlogPostErgaenzungSeeder extends Seeder
$articles = [
[
'slug' => 'nachhaltige-events-und-hochzeiten',
'published_at' => '2026-04-16 10:00:00',
'published_at' => '2026-05-07 10:00:00',
'de' => [
'title' => 'Nachhaltige Events & Hochzeiten So feiert ihr bewusst, ohne auf Emotionen zu verzichten',
'excerpt' => 'Green Wedding, Zero Waste Event, klimabewusste Firmenfeier nachhaltige Events sind mehr als ein Trend. In diesem Leitfaden erfährst du, wie du Deko, Drucksachen, Catering und natürlich die Fotografie so planst, dass sie zur Umwelt und zu euch passen inklusive Ideen, wie die Fotospiel App digitale Prozesse vereinfacht.',
@@ -300,7 +300,7 @@ Sustainability and emotional events are not opposites they reinforce each ot
// 2. Ideale Timeline für Hochzeitsfotos
[
'slug' => 'ideale-hochzeitsfoto-timeline',
'published_at' => '2026-04-24 10:00:00',
'published_at' => '2026-05-15 10:00:00',
'de' => [
'title' => 'Die ideale Timeline für Hochzeitsfotos So verpasst ihr keinen Moment',
'excerpt' => 'First Look, Trauung, Gruppenbilder, Paarshooting, Party: Eine gute Foto-Timeline sorgt dafür, dass ihr alle wichtigen Momente entspannt erlebt und trotzdem ein komplettes Album bekommt. In diesem Artikel zeige ich dir, wie du euren Hochzeitstag fotografisch planst, ohne dass er sich nach Drehplan anfühlt.',
@@ -497,7 +497,7 @@ A clear photo timeline doesn\'t make your day rigid it makes it easier. You
// 3. Fotospiele für Kinder
[
'slug' => 'fotospiele-fuer-kinder-auf-hochzeiten-und-events',
'published_at' => '2026-05-05 10:00:00',
'published_at' => '2026-05-26 10:00:00',
'de' => [
'title' => 'Fotospiele für Kinder auf Hochzeiten & Events So werden die Kleinsten zu großen Geschichtenerzählern',
'excerpt' => 'Kinder auf Events sind Wunderwaffen: Sie nehmen Druck aus der Situation, sorgen für ehrliches Lachen und sehen die Welt aus einer ganz anderen Perspektive. Mit kindgerechten Fotospielen beschäftigst du sie sinnvoll und bekommst gleichzeitig einzigartige Bilder.',
@@ -639,7 +639,7 @@ Photo games turn children into **active storytellers**. Their pictures may be cr
// 4. Storytelling mit Bildern / Album
[
'slug' => 'storytelling-mit-eventfotos',
'published_at' => '2026-05-14 10:00:00',
'published_at' => '2026-06-04 10:00:00',
'de' => [
'title' => 'Storytelling mit Eventfotos So wird aus Bildern eine Geschichte',
'excerpt' => 'Hunderte Fotos sind schnell gemacht aber erst eine gute Auswahl und Reihenfolge macht daraus eine Geschichte, die man gerne anschaut. In diesem Artikel zeige ich dir, wie du aus Profi- und Gastfotos ein stimmiges Album, eine Slideshow oder ein Recap-Video baust.',
@@ -828,7 +828,7 @@ Photos are not just proof that something happened. When curated thoughtfully, th
// 5. Hybride Events
[
'slug' => 'hybride-events-und-remote-gaeste',
'published_at' => '2026-05-24 10:00:00',
'published_at' => '2026-06-14 10:00:00',
'de' => [
'title' => 'Hybride Events & entfernte Gäste So werden alle Teil der Foto-Geschichte',
'excerpt' => 'Nicht alle können bei einer Hochzeit oder einem Firmenevent live dabei sein trotzdem sollen sie Teil der Erinnerungen werden. In diesem Artikel erfährst du, wie du vor Ort und remote Gäste in einer gemeinsamen Fotostory verbindest.',
@@ -953,7 +953,7 @@ This way, people feel: **We were part of the same story**, even if we weren\'t i
// 6. Checkliste Fotowand
[
'slug' => 'checkliste-fotowand-und-selfie-station',
'published_at' => '2026-06-02 10:00:00',
'published_at' => '2026-06-23 10:00:00',
'de' => [
'title' => 'Checkliste Fotowand & Selfie-Station So entstehen eure meistgenutzten Eventmotive',
'excerpt' => 'Eine gute Fotowand ist nicht nur „nice to have“, sondern Magnet für witzige Gruppenbilder und Selfies. Mit dieser Checkliste richtest du eine Fotowand ein, die auf Fotos großartig aussieht und perfekt zur Fotospiel App passt.',
@@ -1068,7 +1068,7 @@ With thoughtful placement, simple lighting, and integration into the Photo Game
// 7. Kamerascheue Gäste
[
'slug' => 'kamerascheue-gaeste-respektvoll-fotografieren',
'published_at' => '2026-06-11 10:00:00',
'published_at' => '2026-07-02 10:00:00',
'de' => [
'title' => 'Kamerascheue Gäste respektvoll fotografieren So bleibt ihr nah dran, ohne Grenzen zu überschreiten',
'excerpt' => 'Nicht alle Menschen lieben Kameras und trotzdem sollen sie sich auf eurem Event wohl fühlen. Hier erfährst du, wie du kamerascheue Gäste respektierst und trotzdem eine vollständige Bildgeschichte erzielst.',
@@ -1187,7 +1187,7 @@ When guests feel seen and respected, they are much more likely to **willingly pa
// 8. After-Event Marketing für Firmen
[
'slug' => 'eventfotos-im-marketing-nutzen',
'published_at' => '2026-06-21 10:00:00',
'published_at' => '2026-07-12 10:00:00',
'de' => [
'title' => 'Eventfotos im Marketing nutzen Ohne peinliche Bilder und rechtliche Stolperfallen',
'excerpt' => 'Firmenevents liefern wertvollen Content für Employer Branding, Social Media und Recruiting. Hier erfährst du, wie du Eventfotos sinnvoll und DSGVO-bewusst im Marketing einsetzt.',
@@ -1326,7 +1326,7 @@ Event photos can be a strong marketing tool when you balance visibility with res
// 9. Mikro-Momente statt gestellter Posen
[
'slug' => 'mikro-momente-statt-gestellter-posen',
'published_at' => '2026-07-01 10:00:00',
'published_at' => '2026-07-22 10:00:00',
'de' => [
'title' => 'Mikro-Momente statt gestellter Posen Wie ihr echte Emotionen einfängt',
'excerpt' => 'Die stärksten Eventfotos sind selten die perfekt inszenierten Motive sondern die kleinen, echten Momente dazwischen. In diesem Artikel geht es darum, den Blick für Mikro-Momente zu schärfen und Gäste aktiv daran zu beteiligen.',
@@ -1447,7 +1447,7 @@ Once you start valuing micro-moments, your photography changes. The programme is
// 10. Technisches Setup
[
'slug' => 'technisches-setup-fuer-stressfreie-eventfotografie',
'published_at' => '2026-07-10 10:00:00',
'published_at' => '2026-07-31 10:00:00',
'de' => [
'title' => 'Technisches Setup für stressfreie Eventfotografie WLAN, Strom & Uploads im Griff',
'excerpt' => 'Die beste Fotoidee bringt wenig, wenn WLAN ausfällt, Akkus leer sind oder Uploads hängen. In diesem Artikel geht es um das technische Mindest-Setup, damit Fotospiel App, Uploads und Fotos insgesamt zuverlässig funktionieren.',

View File

@@ -48,7 +48,7 @@ class BlogPostSeeder extends Seeder
$articles = [
[
'slug' => '10-kreative-fotoaufgaben-fuer-eure-hochzeit',
'published_at' => '2026-01-11 10:00:00',
'published_at' => '2026-02-01 10:00:00',
'de' => [
'title' => '10 kreative Fotoaufgaben für eure Hochzeit Ideen, die Spaß machen und Emotionen wecken',
'excerpt' => 'Jede Hochzeit erzählt ihre eigene Geschichte aber oft fehlen genau die Fotos, die das echte Gefühl des Tages zeigen: das spontane Lachen, die liebevollen Blicke, die kleinen Missgeschicke, die später zu euren Lieblingsmomenten werden. Die Lösung: **Fotoaufgaben für Gäste!**',
@@ -288,7 +288,7 @@ Try the Photo Game App for your wedding scan the QR code, start the challeng
],
[
'slug' => 'datenschutz-bei-eventfotos',
'published_at' => '2025-12-29 09:00:00',
'published_at' => '2026-01-19 09:00:00',
'de' => [
'title' => 'Datenschutz bei Eventfotos Was du wissen musst (verständlich erklärt)',
'excerpt' => 'Ein rauschendes Fest, fröhliche Menschen, unzählige Kameras und schon schwebt die Frage im Raum: **Darf man das eigentlich alles fotografieren und teilen?** Mit unserer Fotospiel App sicher und DSGVO-konform!',
@@ -506,7 +506,7 @@ Scan the event QR code, inform your guests and collect memories with a clear
],
[
'slug' => 'firmenevent-unvergesslich-fotos-spiele-teamgeist',
'published_at' => '2026-01-05 11:00:00',
'published_at' => '2026-01-26 11:00:00',
'de' => [
'title' => 'So macht ihr euer Firmenevent unvergesslich Fotos, Spiele & Teamgeist',
'excerpt' => 'Ein gelungenes Firmenevent ist mehr als ein Buffet und ein paar Reden. Es ist die Gelegenheit, das Wir-Gefühl zu stärken, neue Energie zu tanken und Erinnerungen zu schaffen, die lange nachwirken. Doch wie entsteht aus einem „netten Abend" ein echtes Gemeinschaftserlebnis?',
@@ -735,7 +735,7 @@ Plan your next corporate event with clear emotional moments, interactive element
],
[
'slug' => 'hochzeitsbilder-qr-code',
'published_at' => '2025-12-27 10:30:00',
'published_at' => '2026-01-17 10:30:00',
'de' => [
'title' => 'Hochzeitsbilder & QRCode Die moderne Art, Erinnerungen zu sammeln und zu teilen',
'excerpt' => 'Du kennst das Spiel: Nach der Hochzeit hat jede*r tolle Bilder auf dem Handy doch Wochen später fehlen sie im gemeinsamen Album. Ein **QRCode** beendet dieses Chaos und macht das Teilen von Hochzeitsfotos so einfach wie einen kurzen Scan. In diesem Leitfaden zeige ich dir, wie du QRCodes **smart, datenschutzfreundlich und mit PraxisTipps** einsetzt und wie unsere **Fotospiel App** daraus ein echtes Gemeinschaftserlebnis macht.',
@@ -867,7 +867,7 @@ That way, a small black-and-white square becomes the key to your shared wedding
],
[
'slug' => 'hochzeitsfotografie-mit-kleinem-budget',
'published_at' => '2026-01-14 09:30:00',
'published_at' => '2026-02-04 09:30:00',
'de' => [
'title' => 'Hochzeitsfotografie mit kleinem Budget So bekommst du traumhafte Erinnerungen ohne Profi-Fotograf',
'excerpt' => 'Heiraten ist wunderschön aber teuer. Wenn du mitten in der Planung steckst, weißt du: Kleid, Location, Musik, Essen … alles summiert sich. Und dann kommt da noch der Fotograf mit seinem stolzen Preis. Doch gute Nachrichten: **Wunderschöne Hochzeitsfotos müssen kein Luxus sein.**',
@@ -975,7 +975,7 @@ You don\'t need an expensive allinclusive photo package to get moving, meanin
],
[
'slug' => 'qr-codes-auf-events-kreativ-nutzen',
'published_at' => '2025-12-31 12:00:00',
'published_at' => '2026-01-21 12:00:00',
'de' => [
'title' => 'So nutzt ihr QR-Codes auf Events kreativ Von Fotos bis Networking',
'excerpt' => 'QR-Codes sind längst mehr als kleine schwarz-weiße Quadrate. Sie sind die Brücke zwischen der realen und der digitalen Welt und auf Events die **einfachste Möglichkeit, Interaktion zu schaffen**. Ob Hochzeit, Firmenevent oder Festival: Mit einem Scan können Gäste Fotos hochladen, Feedback geben, Kontakte austauschen oder Informationen abrufen. In diesem Artikel zeige ich dir, wie du QR-Codes kreativ nutzt mit vielen Praxis-Tipps und konkreten Ideen für den Einsatz mit **unserer Fotospiel App**.',
@@ -1109,7 +1109,7 @@ QR codes are simple to set up but incredibly powerful when used intentionally. T
],
[
'slug' => 'top-alternativen-zum-hochzeitsfotografen',
'published_at' => '2026-01-08 10:00:00',
'published_at' => '2026-01-29 10:00:00',
'de' => [
'title' => 'Top-Alternativen zum Hochzeitsfotografen So bekommst du echte Erinnerungen ohne Profi-Shooting',
'excerpt' => 'Ein professioneller Fotograf ist toll aber nicht jede Hochzeit braucht ein teures Fotoshooting. Vielleicht möchtet ihr lieber spontan, kreativ oder budgetbewusst feiern. Gute Nachrichten: Es gibt viele Wege, großartige Hochzeitsbilder zu bekommen, ohne dass ihr ein Vermögen ausgebt. Hier findest du **praktische Ideen, kreative Alternativen und echte Erfahrungs-Tipps**, wie du mit unserer **Fotospiel App** trotzdem eine einzigartige Hochzeitsgalerie erhältst.',
@@ -1127,7 +1127,7 @@ QR codes are simple to set up but incredibly powerful when used intentionally. T
],
[
'slug' => 'wann-hochzeitseinladungen-verschicken',
'published_at' => '2025-12-26 15:00:00',
'published_at' => '2026-01-16 15:00:00',
'de' => [
'title' => 'Wann Hochzeitseinladungen verschickt werden sollten Der ultimative Leitfaden für perfektes Timing',
'excerpt' => 'Planung ist alles besonders bei einer Hochzeit. Zwischen Gästelisten, Location und Budget gerät das Thema **Einladungen** oft ins Hintertreffen. Doch das richtige Timing entscheidet, ob eure Liebsten kommen können oder schon verplant sind. In diesem Leitfaden erfährst du **wann** und **wie** du Einladungen am besten verschickst plus clevere **PraxisTipps**, wie du QRCodes, Fotoboxen und unsere **Fotospiel App** elegant in deine Kommunikation integrierst.',
@@ -1145,7 +1145,7 @@ QR codes are simple to set up but incredibly powerful when used intentionally. T
],
[
'slug' => 'warum-gastfotos-unverzichtbar-sind',
'published_at' => '2026-01-17 14:00:00',
'published_at' => '2026-02-07 14:00:00',
'de' => [
'title' => 'Warum Gastfotos wichtig sind auch wenn du einen Profi-Fotografen hast',
'excerpt' => 'Dein Hochzeitstag vergeht wie im Flug. Zwischen Vorfreude, Tränen und Tanz merkst du kaum, wie viele kleine Momente an dir vorbeiziehen. Natürlich ist ein Profi-Fotograf unverzichtbar aber selbst der beste kann nicht überall gleichzeitig sein. **Gastfotos** ergänzen das große Ganze: Sie zeigen deine Feier aus allen Perspektiven, ungefiltert, echt und emotional.',
@@ -1255,7 +1255,7 @@ With a bit of coordination and the Photo Game App, all these perspectives come t
],
[
'slug' => 'wie-gaeste-eventmomente-festhalten',
'published_at' => '2026-01-03 10:00:00',
'published_at' => '2026-01-24 10:00:00',
'de' => [
'title' => 'Wie Gäste eure schönsten Eventmomente festhalten und was du dafür tun musst',
'excerpt' => 'Jedes Event lebt von seinen Gästen sie sind die Energie, die Stimmung und am Ende die besten Geschichtenerzähler. Doch während Profis meist die großen Highlights ablichten, sind es die Gäste, die die **echten Momente** einfangen: spontanes Lachen, kleine Gesten, ehrliche Emotionen. Damit diese Schätze nicht verloren gehen, braucht es ein bisschen Organisation und ein cleveres System, um sie zu sammeln. Hier erfährst du, wie du Gäste motivierst, aktiv zu fotografieren, und wie **unsere Fotospiel App** daraus ein unvergessliches Erlebnis für alle macht.',

View File

@@ -60,7 +60,7 @@ class CouponSeeder extends Seeder
[
'code' => 'UPGRADE30',
'name' => 'Upgrade 30 €',
'description' => '30 € Nachlass als Upgrade-Anreiz von Starter auf Standard/Premium.',
'description' => '30 € Nachlass als Upgrade-Anreiz von Starter auf Classic/Premium.',
'type' => CouponType::FLAT,
'amount' => 30.00,
'currency' => 'EUR',
@@ -77,7 +77,7 @@ class CouponSeeder extends Seeder
[
'code' => 'SEASON50',
'name' => 'Hochzeits-Saison 50 €',
'description' => 'Saisonaler 50 € Rabatt für die Hochzeitssaison auf Standard/Premium.',
'description' => 'Saisonaler 50 € Rabatt für die Hochzeitssaison auf Classic/Premium.',
'type' => CouponType::FLAT,
'amount' => 50.00,
'currency' => 'EUR',

View File

@@ -36,7 +36,7 @@ class DemoEventSeeder extends Seeder
$events = [
[
'slug' => 'demo-wedding-2025',
'name' => ['de' => 'Demo Hochzeit 2025', 'en' => 'Demo Wedding 2025'],
'name' => ['de' => 'Hochzeit von Klara & Ben', 'en' => "Klara & Ben's Wedding"],
'description' => ['de' => 'Demo-Event', 'en' => 'Demo event'],
'date' => Carbon::now()->addMonths(3),
'event_type' => $weddingType,

View File

@@ -11,6 +11,7 @@ use App\Models\Tenant;
use Carbon\Carbon;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
@@ -98,7 +99,7 @@ class DemoPhotosSeeder extends Seeder
}
$guestNames = $config['guest_names'];
$photosToSeed = min($photoFiles->count(), count($guestNames));
$photosToSeed = min($photoFiles->count(), count($guestNames), 20);
if ($photosToSeed === 0) {
continue;
@@ -143,23 +144,29 @@ class DemoPhotosSeeder extends Seeder
$taskId = $taskIds ? $taskIds[array_rand($taskIds)] : null;
$emotionId = $emotions->random()->id;
$photoData = [
'task_id' => $taskId,
'emotion_id' => $emotionId,
'guest_name' => $guestName,
'thumbnail_path' => $thumbDest,
'likes_count' => $likes,
'is_featured' => $i === 0,
'metadata' => ['demo' => true],
'created_at' => $timestamp,
'updated_at' => $timestamp,
];
if (Schema::hasColumn('photos', 'status')) {
$photoData['status'] = 'approved';
}
$photo = Photo::updateOrCreate(
[
'tenant_id' => $tenant->id,
'event_id' => $event->id,
'file_path' => $destPath,
],
[
'task_id' => $taskId,
'emotion_id' => $emotionId,
'guest_name' => $guestName,
'thumbnail_path' => $thumbDest,
'likes_count' => $likes,
'is_featured' => $i === 0,
'metadata' => ['demo' => true],
'created_at' => $timestamp,
'updated_at' => $timestamp,
]
$photoData
);
PhotoLike::where('photo_id', $photo->id)->delete();

View File

@@ -31,7 +31,7 @@ class PackageSeeder extends Seeder
'max_events_per_year' => 1,
'watermark_allowed' => false,
'branding_allowed' => false,
'features' => ['basic_uploads', 'limited_sharing', 'custom_tasks'],
'features' => ['basic_uploads', 'limited_sharing', 'custom_tasks', 'live_slideshow'],
'paddle_product_id' => 'pro_01k8jcxx2g1vj9snqbga4283ej',
'paddle_price_id' => 'pri_01k8jcxx8qktxvqzzv0nkjjj27',
'description' => <<<'TEXT'
@@ -51,10 +51,10 @@ TEXT,
],
[
'slug' => 'standard',
'name' => 'Standard',
'name' => 'Classic',
'name_translations' => [
'de' => 'Standard',
'en' => 'Standard',
'de' => 'Classic',
'en' => 'Classic',
],
'type' => PackageType::ENDCUSTOMER,
'price' => 59.00,
@@ -151,10 +151,10 @@ TEXT,
],
[
'slug' => 'm-medium-reseller',
'name' => 'Partner Standard',
'name' => 'Partner Classic',
'name_translations' => [
'de' => 'Partner Standard',
'en' => 'Partner Standard',
'de' => 'Partner Classic',
'en' => 'Partner Classic',
],
'type' => PackageType::RESELLER,
'included_package_slug' => 'standard',
@@ -171,15 +171,15 @@ TEXT,
'paddle_product_id' => 'pro_01k8jcxtrxw7jsew52jnax901q',
'paddle_price_id' => 'pri_01k8jcxv06nsgy8ym8mnfrfm5v',
'description' => <<<'TEXT'
Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf StandardNiveau. Empfohlen innerhalb von 24 Monaten zu nutzen.
Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf ClassicNiveau. Empfohlen innerhalb von 24 Monaten zu nutzen.
TEXT,
'description_translations' => [
'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.',
'de' => 'Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf ClassicNiveau. Empfohlen innerhalb von 24 Monaten zu nutzen.',
'en' => 'Event-Kontingent for Partner / Agencies: {{max_events_per_year}} events at Classic level. Recommended to use within 24 months.',
],
'description_table' => [
['title' => 'Events', 'value' => '{{max_events_per_year}} Events'],
['title' => 'Inklusive Event-Level', 'value' => 'Standard'],
['title' => 'Inklusive Event-Level', 'value' => 'Classic'],
['title' => 'Empfehlung', 'value' => 'Empfohlen innerhalb von 24 Monaten zu nutzen.'],
],
],
@@ -273,15 +273,15 @@ TEXT,
'paddle_product_id' => 'pro_01k8jct3gz9ks5mg6z61q6nrxb',
'paddle_price_id' => 'pri_01k8jcxsa8axwpjnybhjbcrb06',
'description' => <<<'TEXT'
Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf StandardNiveau. Empfohlen innerhalb von 24 Monaten zu nutzen.
Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf ClassicNiveau. Empfohlen innerhalb von 24 Monaten zu nutzen.
TEXT,
'description_translations' => [
'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.',
'de' => 'Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf ClassicNiveau. Empfohlen innerhalb von 24 Monaten zu nutzen.',
'en' => 'Event-Kontingent for Partner / Agencies: {{max_events_per_year}} events at Classic level. Recommended to use within 24 months.',
],
'description_table' => [
['title' => 'Events', 'value' => '{{max_events_per_year}} Events'],
['title' => 'Inklusive Event-Level', 'value' => 'Standard'],
['title' => 'Inklusive Event-Level', 'value' => 'Classic'],
['title' => 'Empfehlung', 'value' => 'Empfohlen innerhalb von 24 Monaten zu nutzen.'],
],
],

View File

@@ -19,6 +19,7 @@ class SuperAdminSeeder extends Seeder
'last_name' => 'Admin',
'password' => Hash::make($password),
'role' => 'super_admin',
'email_verified_at' => now(),
]);
$tenantSlug = env('OWNER_TENANT_SLUG', 'owner-tenant');
@@ -58,5 +59,9 @@ class SuperAdminSeeder extends Seeder
if ($user->tenant_id !== $tenant->id) {
$user->forceFill(['tenant_id' => $tenant->id])->save();
}
if (! $user->email_verified_at) {
$user->forceFill(['email_verified_at' => now()])->save();
}
}
}

View File

@@ -1,112 +0,0 @@
<?php
namespace Database\Seeders;
use App\Models\Emotion;
use App\Models\EventType;
use App\Models\Task;
use Illuminate\Database\Seeder;
class WeddingTasksSeeder extends Seeder
{
public function run(): void
{
$weddingType = EventType::where('slug', 'wedding')->first();
if (! $weddingType) {
return;
}
// Helper to resolve emotion by English name (more stable given encoding issues)
$by = fn (string $en) => Emotion::where('name->en', $en)->first();
$emLove = $by('Love');
$emJoy = $by('Joy');
$emTouched = $by('Touched');
$emNostalgia = $by('Nostalgia');
$emSurprise = $by('Surprise');
$emPride = $by('Pride');
$tasks = [
// Liebe (10)
[$emLove, 'Kuss-Foto', 'Kiss Photo', 'Macht ein romantisches Kuss-Foto.', 'Take a romantic kiss photo.', 'easy'],
[$emLove, 'Herz mit Haenden', 'Hands Heart', 'Formt ein Herz mit euren Haenden.', 'Make a heart with your hands.', 'easy'],
[$emLove, 'Brautstrauss im Fokus', 'Bouquet Close-up', 'Brautstrauss nah an die Kamera halten.', 'Hold the bouquet close to the camera.', 'easy'],
[$emLove, 'Stirnkuss', 'Forehead Kiss', 'Sanfter Stirnkuss ganz verliebt.', 'A gentle forehead kiss.', 'easy'],
[$emLove, 'Herzensblick', 'Loving Gaze', 'Schaut euch nur in die Augen.', 'Look only into each others eyes.', 'easy'],
[$emLove, 'Schleiermoment', 'Veil Moment', 'Schleier ueber beide Koepfe legen.', 'Drape the veil over both of you.', 'medium'],
[$emLove, 'Ringnahaufnahme', 'Ring Macro', 'Zeigt eure Ringe nah an der Kamera.', 'Show your rings close to the camera.', 'easy'],
[$emLove, 'Hand in Hand', 'Holding Hands', 'Haende greifen, Kamera im Hintergrund.', 'Hold hands with the camera behind.', 'easy'],
[$emLove, 'Tanzschritt', 'First Dance Step', 'Ein kleiner Tanzschritt fuer das Foto.', 'A small dance step for the photo.', 'medium'],
[$emLove, 'Kuss hinter dem Strauss', 'Peek-a-boo Kiss', 'Kuss hinter dem Brautstrauss verstecken.', 'Hide a kiss behind the bouquet.', 'easy'],
// Freude (10)
[$emJoy, 'Sprung-Foto', 'Jump Photo', 'Alle springen gleichzeitig!', 'Everyone jump together!', 'medium'],
[$emJoy, 'Lachendes Gruppenfoto', 'Laughing Group', 'Erzaehlt einen Witz und klick!', 'Tell a joke and click!', 'easy'],
[$emJoy, 'Konfetti-Moment', 'Confetti Moment', 'Konfetti werfen (oder so tun).', 'Throw confetti (or pretend).', 'easy'],
[$emJoy, 'Cheers!', 'Cheers!', 'Glaser anstossen in die Kamera.', 'Clink glasses toward the camera.', 'easy'],
[$emJoy, 'Freudensprung zu zweit', 'Couple Jump', 'Brautpaar springt gemeinsam.', 'Couple jumps together.', 'medium'],
[$emJoy, 'Luftkuesse', 'Blowing Kisses', 'Luftkuesse in Richtung Kamera.', 'Blow kisses toward the camera.', 'easy'],
[$emJoy, 'Scherzbrillen', 'Silly Glasses', 'Accessoires aufsetzen und lachen.', 'Wear props and laugh.', 'easy'],
[$emJoy, 'Freudige Umarmung', 'Happy Hug', 'Grosse Umarmung in der Runde.', 'Big group hug.', 'easy'],
[$emJoy, 'Daumen hoch', 'Thumbs Up', 'Alle Daumen nach oben!', 'Thumbs up, everyone!', 'easy'],
[$emJoy, 'Victory-Zeichen', 'Peace Sign', 'Peace-Zeichen in die Kamera.', 'Peace sign to the camera.', 'easy'],
// Touched (8)
[$emTouched, 'Traenen des Gluecks', 'Tears of Joy', 'Sanftes Traenchen abtupfen.', 'Dab a happy tear.', 'easy'],
[$emTouched, 'Eltern-Umarmung', 'Parents Hug', 'Umarmung mit Eltern oder Trauzeugen.', 'Hug with parents or witnesses.', 'easy'],
[$emTouched, 'Hand aufs Herz', 'Hand on Heart', 'Hand aufs Herz ehrlicher Moment.', 'Hand on heart — a sincere moment.', 'easy'],
[$emTouched, 'Danke-Geste', 'Thank You Gesture', '„Danke“-Geste in die Kamera.', 'A “thank you” gesture to the camera.', 'easy'],
[$emTouched, 'Enger Nasenstups', 'Nose Boop', 'Stirn an Stirn, sanfter Nasenstups.', 'Forehead to forehead, a soft nose boop.', 'easy'],
[$emTouched, 'Geliebtes Andenken', 'Keepsake', 'Ein bedeutsames Andenken zeigen.', 'Show a meaningful keepsake.', 'easy'],
[$emTouched, 'Leise Worte', 'Whisper', 'Ein leises Kompliment ins Ohr.', 'Whisper a compliment.', 'easy'],
[$emTouched, 'Ruhe vor dem Sturm', 'Quiet Moment', 'Augen schliessen, tief durchatmen.', 'Close eyes and take a deep breath.', 'easy'],
// Nostalgia (8)
[$emNostalgia, 'Altes Foto nachstellen', 'Recreate Old Photo', 'Ein altes Familienfoto nachstellen.', 'Recreate an old family photo.', 'medium'],
[$emNostalgia, 'Kindheits-Pose', 'Childhood Pose', 'Lieblingspose aus der Kindheit.', 'Favorite childhood pose.', 'easy'],
[$emNostalgia, 'Erste Nachricht', 'First Message', 'Handys mit erster Nachricht zeigen.', 'Show your first message on phones.', 'medium'],
[$emNostalgia, 'Ringbox Vintage', 'Vintage Ring Box', 'Ringbox im Vintage-Stil inszenieren.', 'Stage the vintage ring box.', 'easy'],
[$emNostalgia, 'Familienerbstueck', 'Family Heirloom', 'Ein Familienerbstueck ins Bild.', 'Feature a family heirloom.', 'easy'],
[$emNostalgia, 'Schwarzweiss', 'Black & White', 'Schwarzweiss-Pose fuer klassisches Foto.', 'Pose for a black & white shot.', 'easy'],
[$emNostalgia, 'Erster Tanz (Mini)', 'Mini First Dance', 'Ein Schritt vom ersten Tanz.', 'One step of the first dance.', 'easy'],
[$emNostalgia, 'Gastebuch-Moment', 'Guestbook Moment', 'Eintrag ins Gaestebuch festhalten.', 'Capture a guestbook entry.', 'easy'],
// Surprise (7)
[$emSurprise, 'Photobomb!', 'Photobomb!', 'Ueberraschung im Hintergrund.', 'Surprise in the background.', 'easy'],
[$emSurprise, 'Erster Blick', 'First Look', 'Reaktion beim First Look nachstellen.', 'Recreate a first-look reaction.', 'medium'],
[$emSurprise, 'Ueberraschungs-Dip', 'Surprise Dip', 'Ueberraschender Tanz-Dip.', 'A surprise dance dip.', 'medium'],
[$emSurprise, 'Ballon-Pop', 'Balloon Pop', 'Ballon zerplatzen (oder so tun).', 'Pop a balloon (or pretend).', 'easy'],
[$emSurprise, 'Hutwechsel', 'Hat Swap', 'Huete/Accessoires spontan tauschen.', 'Swap hats/props on the fly.', 'easy'],
[$emSurprise, 'Versteckspiel', 'Peekaboo', 'Hinter Deko kurz verstecken.', 'Peek from behind decor.', 'easy'],
[$emSurprise, 'Gespiegelte Pose', 'Mirror Pose', 'Gegensaetzliche, gespiegelte Pose.', 'Opposite mirrored pose.', 'easy'],
// Pride (7)
[$emPride, 'Just Married', 'Just Married', '„Just Married“-Schild zeigen.', 'Show a “Just Married” sign.', 'easy'],
[$emPride, 'Ring zeigen', 'Show the Ring', 'Ring zur Kamera strecken.', 'Stretch ring toward the camera.', 'easy'],
[$emPride, 'Brautkleid-Detail', 'Dress Detail', 'Lieblingsdetail am Kleid zeigen.', 'Show a favorite dress detail.', 'easy'],
[$emPride, 'Anzug-Detail', 'Suit Detail', 'Manschette/Knopfloch zeigen.', 'Show cuff/ boutonniere.', 'easy'],
[$emPride, 'Team Braut', 'Team Bride', '„Team Braut“-Gruppenpose.', '“Team Bride” group pose.', 'easy'],
[$emPride, 'Team Braeutigam', 'Team Groom', '„Team Braeutigam“-Gruppenpose.', '“Team Groom” group pose.', 'easy'],
[$emPride, 'Siegesschrei', 'Victory Cheer', 'Arme hoch, Jubel in die Kamera.', 'Arms up, cheer to the camera.', 'easy'],
];
$sort = 1;
foreach ($tasks as [$emotion, $titleDe, $titleEn, $descDe, $descEn, $difficulty]) {
if (! $emotion) {
continue;
}
Task::updateOrCreate([
'emotion_id' => $emotion->id,
'title->de' => $titleDe,
], [
'emotion_id' => $emotion->id,
'event_type_id' => $weddingType->id,
'title' => ['de' => $titleDe, 'en' => $titleEn],
'description' => ['de' => $descDe, 'en' => $descEn],
'difficulty' => $difficulty,
'sort_order' => $sort++,
'is_active' => true,
]);
}
}
}

View File

@@ -65,10 +65,8 @@ services:
- app-code:/var/www/html
- app-storage:/var/www/html/storage
- app-bootstrap-cache:/var/www/html/bootstrap/cache
- photobooth-import:/var/www/html/storage/app/photobooth
networks:
- default
- photobooth-network
depends_on:
mysql:
condition: service_healthy
@@ -150,18 +148,6 @@ services:
condition: service_completed_successfully
app:
condition: service_healthy
labels:
- traefik.enable=true
- traefik.http.middlewares.fotospiel-https-redirect.redirectscheme.scheme=https
- traefik.http.routers.fotospiel-http.rule=Host(`test-y0k0.fotospiel.app`)
- traefik.http.routers.fotospiel-http.entrypoints=web
- traefik.http.routers.fotospiel-http.middlewares=fotospiel-https-redirect
- traefik.http.routers.fotospiel-https.rule=Host(`test-y0k0.fotospiel.app`)
- traefik.http.routers.fotospiel-https.entrypoints=websecure
- traefik.http.routers.fotospiel-https.tls=true
- traefik.http.routers.fotospiel-https.service=fotospiel-web
- traefik.http.services.fotospiel-web.loadbalancer.server.port=80
- traefik.docker.network=dokploy-network
volumes:
- app-code:/var/www/html:ro
- app-storage:/var/www/html/storage:ro
@@ -174,41 +160,6 @@ services:
- dokploy-network
restart: unless-stopped
photobooth-ftp:
build:
context: ./docker/photobooth-control
image: ${PHOTOBOOTH_CONTROL_IMAGE_REPO:-fotospiel-photobooth-control}:${PHOTOBOOTH_CONTROL_IMAGE_TAG:-latest}
env_file:
- path: .env
environment:
CONTROL_TOKEN: ${PHOTOBOOTH_CONTROL_TOKEN}
FTP_PUBLIC_HOST: ${PHOTOBOOTH_FTP_ADDRESS:-test-y0k0.fotospiel.app}
FTP_PORT: ${PHOTOBOOTH_FTP_PORT:-2121}
FTP_PASSIVE_MIN: ${PHOTOBOOTH_FTP_PASV_MIN_PORT:-30000}
FTP_PASSIVE_MAX: ${PHOTOBOOTH_FTP_PASV_MAX_PORT:-30009}
REQUIRE_FTPS: ${PHOTOBOOTH_REQUIRE_FTPS:-0}
PHOTOBOOTH_ROOT: /photobooth
FTP_SYSTEM_USER: ${PHOTOBOOTH_FTP_USER:-ftpuser}
FTP_SYSTEM_GROUP: ${PHOTOBOOTH_FTP_GROUP:-ftpgroup}
FTP_MAX_CLIENTS: ${PHOTOBOOTH_FTP_MAX_CLIENTS:-50}
FTP_MAX_PER_IP: ${PHOTOBOOTH_FTP_MAX_PER_IP:-10}
volumes:
- photobooth-import:/photobooth
- photobooth-ftp-auth:/etc/pure-ftpd
ports:
- "${PHOTOBOOTH_FTP_PORT:-2121}:21"
- "${PHOTOBOOTH_FTP_PASV_MIN_PORT:-30000}-${PHOTOBOOTH_FTP_PASV_MAX_PORT:-30009}:${PHOTOBOOTH_FTP_PASV_MIN_PORT:-30000}-${PHOTOBOOTH_FTP_PASV_MAX_PORT:-30009}"
networks:
- dokploy-network
- photobooth-network
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/health >/dev/null 2>&1 && nc -z localhost 21"]
interval: 30s
timeout: 10s
retries: 5
start_period: 10s
restart: unless-stopped
queue:
image: ${APP_IMAGE_REPO:-fotospiel-app}:${APP_IMAGE_TAG:-latest}
env_file:
@@ -223,7 +174,6 @@ services:
- app-bootstrap-cache:/var/www/html/bootstrap/cache
networks:
- default
- photobooth-network
depends_on:
app:
condition: service_healthy
@@ -247,7 +197,6 @@ services:
- app-bootstrap-cache:/var/www/html/bootstrap/cache
networks:
- default
- photobooth-network
depends_on:
app:
condition: service_healthy
@@ -271,7 +220,6 @@ services:
- app-bootstrap-cache:/var/www/html/bootstrap/cache
networks:
- default
- photobooth-network
depends_on:
app:
condition: service_healthy
@@ -291,10 +239,8 @@ services:
- app-code:/var/www/html
- app-storage:/var/www/html/storage
- app-bootstrap-cache:/var/www/html/bootstrap/cache
- photobooth-import:/var/www/html/storage/app/photobooth
networks:
- default
- photobooth-network
depends_on:
app:
condition: service_healthy
@@ -314,7 +260,6 @@ services:
- app-bootstrap-cache:/var/www/html/bootstrap/cache
networks:
- default
- photobooth-network
depends_on:
app:
condition: service_healthy
@@ -352,6 +297,17 @@ services:
retries: 5
restart: unless-stopped
uptime-kuma:
image: louislam/uptime-kuma:1
volumes:
- uptime-kuma-data:/app/data
ports:
- "${UPTIME_KUMA_PORT:-3001}:3001"
networks:
- default
- dokploy-network
restart: unless-stopped
volumes:
app-code:
app-storage:
@@ -359,13 +315,10 @@ volumes:
name: fotospiel-${APP_ENV:-prod}-storage
app-bootstrap-cache:
nuget-cache:
photobooth-import:
photobooth-ftp-auth:
mysql-data:
redis-data:
uptime-kuma-data:
networks:
dokploy-network:
external: true
photobooth-network:
name: fotospiel-${APP_ENV:-prod}-photobooth

View File

@@ -133,7 +133,16 @@ services:
retries: 5
restart: unless-stopped
uptime-kuma:
image: louislam/uptime-kuma:1
volumes:
- uptime-kuma-data:/app/data
ports:
- "${UPTIME_KUMA_PORT:-3001}:3001"
restart: unless-stopped
volumes:
app-code:
mysql-data:
redis-data:
uptime-kuma-data:

View File

@@ -12,7 +12,6 @@
- `SEC-FE-01` CSP nonce utility.
- Week 2
- `SEC-IO-02` refresh-token management UI. *(delivered 2025-10-23)*
- `SEC-GT-02` token analytics dashboards.
- `SEC-API-02` incident response playbook.
- `SEC-MS-02` streaming upload refactor.
- `SEC-BILL-02` webhook signature freshness.

View File

@@ -60,6 +60,20 @@
- **Robots.txt**: Allow both locales; noindex for dev.
- **Accessibility**: ARIA labels with `t()`; screen reader support for language switches.
### SEO Implementation Notes (Marketing)
- **Source of tags**: `resources/js/layouts/mainWebsite.tsx` renders canonical + hreflang links for Inertia marketing pages.
- **URL building**: `resources/js/lib/localizedPath.ts` handles locale rewrites (e.g., `/kontakt``/contact`) and prefixing.
- **Data inputs**: `supportedLocales`, `locale`, and `appUrl` are shared via `app/Http/Middleware/HandleInertiaRequests.php`.
- **SSR**: Inertia SSR is disabled (`config/inertia.php`), so canonical/hreflang tags are client-rendered.
### Validation Checklist (Search Console + Lighthouse)
- Verify canonical + hreflang output on key marketing pages (home, contact, packages, blog, occasions).
- Use Search Console URL inspection to confirm rendered HTML contains canonical + hreflang tags.
- Run Lighthouse SEO audit on both `/de/*` and `/en/*` routes (spot-check canonical + alternate links).
### Tests
- **Vitest**: `resources/js/layouts/__tests__/mainWebsite.seo.test.tsx` validates canonical/hreflang output and localized slug rewrites.
## Migration from PHP to JSON
- Extract keys from `resources/lang/\{locale\}/marketing.php` to `public/lang/\{locale\}/marketing.json`.
- Consolidate: Remove duplicates; use nested objects (e.g., `{ "header": { "login": "Anmelden" } }`).

View File

@@ -40,6 +40,7 @@ Ziel: Vollständige Migration zu Inertia.js für SPA-ähnliche Konsistenz, mit e
- **Auth-Integration**: usePage().props.auth für CTAs (z.B. Login-Button im Header).
- **Responsive Design**: Tailwind: block md:hidden für Mobile-Carousel in Packages.
- **Fonts & Assets**: Vite-Plugin für Fonts/SVGs; preload in Layout.
- **SEO (hreflang/canonical)**: `resources/js/layouts/mainWebsite.tsx` rendert canonical + hreflang auf Basis von `supportedLocales`, `locale`, `appUrl` und `resources/js/lib/localizedPath.ts` (Locale-Rewrites).
- **Analytics (Matomo)**: Aktivierung via `.env` (`MATOMO_ENABLED=true`, `MATOMO_URL`, `MATOMO_SITE_ID`). `AppServiceProvider` teilt die Konfiguration als `analytics.matomo`; `MarketingLayout` rendert `MatomoTracker`, der das Snippet aus `/docs/piwik-trackingcode.txt` nur bei erteilter Analyse-Zustimmung lädt, `disableCookies` setzt und bei jedem Inertia-Navigationsevent `trackPageView` sendet. Ein lokalisierter Consent-Banner (DE/EN) übernimmt die DSGVO-konforme Einwilligung und ist über den Footer erneut erreichbar.
- **Tests**: E2E mit Playwright (z.B. navigate to /packages, check header/footer presence).

View File

@@ -1,37 +0,0 @@
---
title: "Troubleshooting & Incident-Playbooks"
locale: de
slug: admin-issue-resolution
audience: admin
summary: "Leitfäden für typische Admin-Vorfälle von hängenden Uploads bis zu Billing-Sperren."
version_introduced: 2025.4
requires_app_version: "^3.2.0"
status: draft
translation_state: aligned
last_reviewed_at: 2025-02-22
owner: reliability@fotospiel.app
related:
- slug: live-ops-control
- slug: privacy-and-support
---
## Upload-Vorfälle
| Symptom | Diagnose | Lösung |
| --- | --- | --- |
| Warteschlange >10Min fest | Live-Ops-Health-Widget prüfen | `php artisan media:backfill-thumbnails --tenant=XYZ` ausführen, Event neu öffnen |
| Einzelner Gast blockiert | Geräte-Limit erreicht | Limit unter Event → Upload-Regeln erhöhen oder Gast bittet Entwürfe zu löschen |
| Fotos ohne EXIF | Gast importiert Screenshots | Kein Fehler; Hinweis geben, dass EXIF optional ist |
## Zugriffsprobleme
- **Admin kommt nicht rein**: Prüfen, ob Einladung akzeptiert wurde; über *Team → Einladung erneut senden* resetten. Bei SSO Pflicht Zuordnung kontrollieren.
- **Gast kann nicht beitreten**: Event-Status muss *Published* sein; direkten Join-Link `https://app.fotospiel.com/join/<code>` teilen.
## Billing & Quoten
- Paddle-Webhook-Fehler sperrt Uploads: `storage/logs/paddle.log` prüfen, Webhook im Paddle-Dashboard erneut senden, anschließend Abo-Status toggeln.
- Speicher zu 90% voll: Archivierung vorziehen oder Add-on im Paddle-Kundenportal buchen.
## Kommunikationsvorlagen
Nutze die vorformulierten Antworten in `docs/content/fotospiel_howto_artikel_detailliert.md`, um Messaging konsistent zu halten.
### Weitere Hilfe
Eskalation an reliability@fotospiel.app mit Event-ID, Kunde und Zeitstempel. Screenshots/Logs anhängen, wenn verfügbar.

View File

@@ -0,0 +1,35 @@
---
title: "Pakete, Abrechnung & Exporte"
locale: de
slug: billing-packages-exports
audience: admin
summary: "Paketlimits prüfen, Add-ons kaufen und Datenexporte anfordern."
version_introduced: 2025.4
requires_app_version: "^3.2.0"
status: draft
translation_state: aligned
last_reviewed_at: 2026-01-23
owner: success@fotospiel.app
related:
- slug: post-event-wrapup
- slug: tenant-dashboard-overview
---
## Pakete & Limits
- Verbleibende Events, Gäste, Fotos und Galerietage prüfen.
- Bei Bedarf Paket upgraden oder Addons kaufen.
## BillingPortal
- Zahlungsdaten oder Rechnungen im Portal verwalten.
- Für Upgrades den PaketShop nutzen.
## Datenexporte
- Tenant oder EventExport anfordern.
- Größere Exporte brauchen etwas Zeit; Liste aktualisieren.
## RetentionHinweis
- Galerietage prüfen, damit der Zugriff nicht abläuft.
- Für Archivierung und Compliance `post-event-wrapup` verwenden.
### Weitere Hilfe
`post-event-wrapup` für Aufgaben nach dem Event.

View File

@@ -0,0 +1,40 @@
---
title: "Control Room: Moderation & Queue"
locale: de
slug: control-room-moderation
audience: admin
summary: "Uploads moderieren, Highlights setzen und die Live-Queue steuern."
version_introduced: 2025.4
requires_app_version: "^3.2.0"
status: draft
translation_state: aligned
last_reviewed_at: 2026-01-23
owner: ops@fotospiel.app
related:
- slug: event-prep-checklist
- slug: live-show-setup
---
## Wann nutze ich diese Seite?
Nutze den Control Room, wenn du Uploads prüfen, Highlights setzen oder die Live-Show-Queue steuern musst.
## Moderation im Überblick
- **Freigeben** veröffentlicht das Foto in der Galerie.
- **Ausblenden** entfernt es sofort aus der Galerie.
- **Highlight** markiert das Foto als Featured-Inhalt.
## Filter & Status
- **Offen** hilft, die Warteschlange schnell abzuarbeiten.
- **Highlights** prüfen, ob markierte Inhalte passen.
- **Alle** nur bei Bedarf für Audits älterer Uploads.
## Live-Show-Queue
- Fotos freigeben, wenn die Live-Show moderiert wird.
- Einträge entfernen, die nicht auf der Leinwand erscheinen sollen.
### Tipps
- Control Room während des Events auf einem Team-Gerät geöffnet lassen.
- Wenn die Queue wächst, Moderation straffen oder Effekte reduzieren.
### Weitere Hilfe
`live-show-setup` für die Player-Einrichtung oder `event-prep-checklist` für die Vorbereitung.

View File

@@ -0,0 +1,35 @@
---
title: "Branding & Assets"
locale: de
slug: event-branding-assets
audience: admin
summary: "Logos hochladen, Farben setzen und Wasserzeichen verwalten."
version_introduced: 2025.4
requires_app_version: "^3.2.0"
status: draft
translation_state: aligned
last_reviewed_at: 2026-01-23
owner: onboarding@fotospiel.app
related:
- slug: event-prep-checklist
- slug: tenant-dashboard-overview
---
## Was du anpassen kannst
- **Coverbild** und **Logo** für den GastStartscreen.
- **Primärfarbe** und **Akzent** für Buttons und Highlights.
- **Begrüßungstext** pro Sprache.
- **Wasserzeichen** für exportierte Fotos.
## Empfohlener Ablauf
1. Logo und Coverbild zuerst hochladen.
2. Farben passend zum Event einstellen.
3. Vorschau prüfen und Texte anpassen.
4. Wasserzeichen nur aktivieren, wenn vertraglich nötig.
### Tipps
- Hoher Kontrast sorgt für bessere Lesbarkeit.
- Begrüßungstext kurz halten (12 Sätze).
### Weitere Hilfe
Event-Vorbereitung: Checkliste für die Vorbereitung oder Event-Dashboard im Überblick für den Status.

View File

@@ -1,38 +1,45 @@
---
title: "Checkliste Event-Vorbereitung"
title: "Event-Vorbereitung: Checkliste"
locale: de
slug: event-prep-checklist
audience: admin
summary: "48-Stunden-Countdown, damit Geräte, Gäste und Automationen ready sind, bevor es losgeht."
summary: "Die wichtigsten Schritte zur Event-Vorbereitung vor dem Start."
version_introduced: 2025.4
requires_app_version: "^3.2.0"
status: draft
translation_state: aligned
last_reviewed_at: 2025-02-22
last_reviewed_at: 2026-01-23
owner: ops@fotospiel.app
related:
- slug: live-ops-control
- slug: post-event-wrapup
- slug: tenant-dashboard-overview
- slug: event-settings
- slug: event-branding-assets
- slug: event-tasks-setup
- slug: guest-access-qr
- slug: control-room-moderation
- slug: live-show-setup
---
## 4824 Stunden vorher
- [ ] Event in der Admin-App mit korrekter Zeitzone + Aufbewahrungsfrist anlegen.
- [ ] Titelbild (1200×630) hochladen und Übersetzungen für Titel/Beschreibung prüfen.
- [ ] Gästelisten importieren oder QR-Badges erzeugen.
- [ ] Push-Vorlagen testen (Reminder, Achievement-Freischaltung).
- [ ] **Event anlegen** mit korrektem Datum, Zeitzone und Aufbewahrung.
- [ ] **Branding hochladen** (Cover, Logo, Begrüßungstext) in allen Sprachen.
- [ ] **Aufgaben definieren**: Paket wählen, Aufgaben anpassen, Emotionen/Sammlungen setzen.
- [ ] **Gästezugang vorbereiten**: QR-Code, Join-Link oder Einladungen.
## 242 Stunden vorher
- [ ] `tenant:attach-demo-event` im Staging ausführen, um den Ablauf mit dem Team zu proben.
- [ ] Join-QR nahe Eingang und Fotoboxen ausdrucken oder anzeigen.
- [ ] WLAN-SSID/Passwort-Beschilderung vorbereiten.
- [ ] Moderationsregeln mit Kundenvertrag abgleichen (z.B. explizite Inhalte blocken, Freigabe nötig).
- [ ] Paddle/RevenueCat-Status prüfen (alle Ampeln auf Grün).
- [ ] **Event veröffentlichen**, sobald alles freigegeben ist.
- [ ] **Uploads testen** mit einem Gerät (Kamera-Freigabe + erster Upload).
- [ ] **Moderation prüfen** (Sichtbarkeit, Freigabe-Regeln).
- [ ] **Live-Show einstellen**, falls eine Leinwand genutzt wird.
## Letzte 2 Stunden
- [ ] Demodaten aus dem Live-Event entfernen.
- [ ] Gäste-App auf Testgeräten öffnen und den Schnellstart durchspielen.
- [ ] Live-Ops-Ansicht auf Tablet/Laptop in Bühnennähe starten.
- [ ] Team zu Eskalationswegen briefen (Supportkontakte, Ersatzgeräte, Foto-Guidelines).
- [ ] **QR aushängen** an Eingängen und Fotobooth-Punkten.
- [ ] **Control Room öffnen** auf einem Team-Gerät bei hohem Upload-Volumen.
- [ ] **Team briefen** für sensible Inhalte oder Support-Anfragen.
### Tipps
- Das Event bleibt bis zur Freigabe im Entwurf.
- Mit einem Testgast den Ablauf einmal komplett durchspielen.
### Weitere Hilfe
Siehe `live-ops-control` für Echtzeit-Monitoring oder melde dich bei ops@fotospiel.app.
Control Room für die Queue oder Event-Dashboard im Überblick für Status-Checks.

View File

@@ -0,0 +1,35 @@
---
title: "Event-Einstellungen"
locale: de
slug: event-settings
audience: admin
summary: "Event-Basics, Veröffentlichungsstatus und Upload-Regeln anpassen."
version_introduced: 2025.4
requires_app_version: "^3.2.0"
status: draft
translation_state: aligned
last_reviewed_at: 2026-01-23
owner: onboarding@fotospiel.app
related:
- slug: event-prep-checklist
- slug: tenant-dashboard-overview
---
## Grunddaten
- **Name, Datum, Ort** müssen stimmen sie erscheinen in Exporten.
- **Event-Typ** hilft bei Vorlagen und Reporting.
## Status & Sichtbarkeit
- **Veröffentlicht**: Gäste können beitreten.
- **Entwurf**: für die Vorbereitung, kein Gästezugang.
## Upload-Regeln
- **AutoFreigabe** veröffentlicht Uploads sofort.
- **Fotoaufgaben-Modus** steuert, ob Gäste Aufgaben sehen.
### Tipps
- Event im Entwurf lassen, bis Branding und Aufgaben fertig sind.
- Bei Moderationsbedarf AutoFreigabe deaktivieren.
### Weitere Hilfe
Event-Vorbereitung: Checkliste für den Ablauf oder Gästezugang & QR für die Freigabe.

View File

@@ -0,0 +1,38 @@
---
title: "Event-Aufgaben & Sammlungen"
locale: de
slug: event-tasks-setup
audience: admin
summary: "Fotoaufgaben, Aufgabenbibliothek, Emotionen und Sammlungen für Gäste konfigurieren."
version_introduced: 2025.4
requires_app_version: "^3.2.0"
status: draft
translation_state: aligned
last_reviewed_at: 2026-01-23
owner: onboarding@fotospiel.app
related:
- slug: event-prep-checklist
- slug: tenant-dashboard-overview
---
## Wann nutze ich diese Seite?
Hier stellst du die Aufgaben zusammen, die Gäste im Event sehen und erfüllen sollen.
## Aufgaben-Tab
- Zugewiesene Aufgaben prüfen und Titel/Beschreibung anpassen.
- Aufgaben entfernen, die nicht zum Event passen.
## Aufgabenbibliothek
- Paket importieren, um eine kuratierte Basis zu erhalten.
- Eigene Aufgaben für besondere Momente hinzufügen.
## Emotionen & Sammlungen
- **Emotionen** helfen beim Taggen der Stimmung (z. B. fröhlich, emotional, wild).
- **Sammlungen** gruppieren Aufgaben nach Themen (z. B. Zeremonie, Party, Familie).
### Tipps
- Aufgaben kurz und konkret formulieren.
- 1220 Aufgaben reichen meist aus.
### Weitere Hilfe
Event-Vorbereitung: Checkliste für die Vorbereitung oder Event-Dashboard im Überblick für den Status.

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