Compare commits
459 Commits
8f13465415
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9409e961d | ||
|
|
1f9a43806a | ||
|
|
e3bb1642db | ||
|
|
83cf863548 | ||
|
|
6cc463fc70 | ||
|
|
c0c082975e | ||
|
|
64b3bf3ed4 | ||
|
|
922da46331 | ||
|
|
ddbfa38db1 | ||
|
|
3ba4d11d92 | ||
|
|
d2808ffa4f | ||
|
|
8cc0918881 | ||
|
|
fb45d1f6ab | ||
|
|
1d2242fb4d | ||
|
|
36bed12ff9 | ||
|
|
df00deb0df | ||
|
|
0291d537fb | ||
|
|
fa114ac0dc | ||
|
|
61d1bbc707 | ||
|
|
0a08f2704f | ||
|
|
b14435df8b | ||
|
|
18b4f36fcf | ||
|
|
c6aaf859f5 | ||
|
|
ba56cb4e61 | ||
|
|
5f75c7ca6a | ||
|
|
4e0d156065 | ||
|
|
fa630e335d | ||
|
|
6eafec2128 | ||
|
|
04c399aeb6 | ||
|
|
7262617897 | ||
|
|
0d7a861875 | ||
|
|
c43327af74 | ||
|
|
e911c2bd16 | ||
|
|
beaff1c4e9 | ||
|
|
eee58f2d0c | ||
|
|
1cb150b819 | ||
|
|
17025df47b | ||
|
|
7025418d9e | ||
|
|
5c78ac00dd | ||
|
|
66c7131d79 | ||
|
|
239f55f9c5 | ||
|
|
fc5dfb272c | ||
|
|
56a39d0535 | ||
|
|
0eacb5646c | ||
|
|
b6d7118772 | ||
|
|
8d7a1d80c2 | ||
|
|
35f68e68d8 | ||
|
|
4be01d77ee | ||
|
|
197e9c988b | ||
|
|
ba8890839b | ||
|
|
6c12d73d68 | ||
|
|
c59f9ae994 | ||
|
|
f2d7ed6646 | ||
|
|
17d979e3c3 | ||
|
|
fc1149118c | ||
|
|
8cee7ef004 | ||
|
|
1b131851fb | ||
|
|
7980980bed | ||
|
|
c6a7f31ea0 | ||
|
|
c4ccfa1353 | ||
|
|
b38b63e04a | ||
|
|
25e3d5ef7d | ||
|
|
1119adcfe9 | ||
|
|
c91480a870 | ||
|
|
7f1e6c06fb | ||
|
|
a820ef2e8b | ||
|
|
a0ef90e13a | ||
|
|
2f4ebfefd4 | ||
|
|
f161366119 | ||
|
|
6bc73637b1 | ||
|
|
7ee56cefe4 | ||
|
|
38f89be99e | ||
|
|
6e19c3d7b6 | ||
|
|
b3bf45482a | ||
|
|
d4162dd105 | ||
|
|
2270462ecb | ||
|
|
af4685f703 | ||
|
|
43dc71815f | ||
|
|
f5f6555b09 | ||
|
|
0e2638c0f8 | ||
|
|
798fa5e9f5 | ||
|
|
802a3fe132 | ||
|
|
303a33dd2e | ||
|
|
074cd0f431 | ||
|
|
bca2e90b54 | ||
|
|
bd3a96a58a | ||
|
|
876731b051 | ||
|
|
93efb78091 | ||
|
|
68f2eb871b | ||
|
|
1ca2cdf2a8 | ||
|
|
0ad8b9d8a9 | ||
|
|
779dd520ad | ||
|
|
061ad6cf24 | ||
|
|
f707feb45e | ||
|
|
93ea12fa04 | ||
|
|
f56b2b81ed | ||
|
|
4ce64f0a35 | ||
|
|
dd2997808b | ||
|
|
19f5e60870 | ||
|
|
b4a2e39903 | ||
|
|
d8f67522ee | ||
|
|
e2948c0388 | ||
|
|
594c3b1772 | ||
|
|
59cedf216a | ||
|
|
4e2ab9e589 | ||
|
|
6c857b5765 | ||
|
|
4e65fe1d5f | ||
|
|
86b7eddd47 | ||
|
|
975e257c44 | ||
|
|
3115a6461d | ||
|
|
d93e6475a4 | ||
|
|
a9c7242e15 | ||
|
|
8887d8e16c | ||
|
|
684f54f58f | ||
|
|
b17dd655db | ||
|
|
3255917201 | ||
|
|
29453089cc | ||
|
|
0f4d7450ff | ||
|
|
6c83f4ee4e | ||
|
|
e8bd962a55 | ||
|
|
7f7bebcfde | ||
|
|
d9aea9a6ca | ||
|
|
9ab230f5b7 | ||
|
|
7ee91ff7d7 | ||
|
|
6a056b199c | ||
|
|
6701b48cc8 | ||
|
|
0caed3cc56 | ||
|
|
ab7881077b | ||
|
|
67948cb4f8 | ||
|
|
8c507b8b13 | ||
|
|
f19a83d4ee | ||
|
|
531c666cf0 | ||
|
|
d9c842a7d4 | ||
|
|
c6293628e7 | ||
|
|
d5a447fb28 | ||
|
|
352cfdb69c | ||
|
|
c234b1a1cc | ||
|
|
233a2b224c | ||
|
|
bb462a1709 | ||
|
|
8d140ff86f | ||
|
|
d7a8120ffe | ||
|
|
335087b20f | ||
|
|
564e1dbcf4 | ||
|
|
a35b32892a | ||
|
|
86971bbf0e | ||
|
|
a8edba85df | ||
|
|
1089b09412 | ||
|
|
06f8d892ef | ||
|
|
d47b1deada | ||
|
|
0b9c1a520d | ||
|
|
cedfa28d00 | ||
|
|
f861ea6b07 | ||
|
|
b81be20d60 | ||
|
|
7cde7293cb | ||
|
|
6f9bfd0601 | ||
|
|
8ddc361c91 | ||
|
|
2c55857699 | ||
|
|
458d39a41f | ||
|
|
ff84f9c1e9 | ||
|
|
0207056ec7 | ||
|
|
026259c199 | ||
|
|
e175cc7dde | ||
|
|
086b12b4a5 | ||
|
|
3d59e73b5f | ||
|
|
606c152603 | ||
|
|
bc73884a6d | ||
|
|
b1a7cdbe09 | ||
|
|
34bc7f25d3 | ||
|
|
d773aafdcc | ||
|
|
ab8bd6b10b | ||
|
|
9b5c71cd8b | ||
|
|
cf04988fc2 | ||
|
|
7ed4e581f7 | ||
|
|
20f60e9277 | ||
|
|
549611fb1c | ||
|
|
eb8201ec56 | ||
|
|
91bb09248a | ||
|
|
b6e0005734 | ||
|
|
42b61b122f | ||
|
|
66eb4edd2c | ||
|
|
bc62154bfb | ||
|
|
ddf0c4c389 | ||
|
|
0b352eba12 | ||
|
|
9d2294de5a | ||
|
|
effddf5ab0 | ||
|
|
e709337df2 | ||
|
|
0a03ea71a1 | ||
|
|
3a81c0991b | ||
|
|
32f3696ffb | ||
|
|
901419798d | ||
|
|
82e41790d9 | ||
|
|
ff34175dc3 | ||
|
|
80dd12bb92 | ||
|
|
a01a7ec399 | ||
|
|
5eb0941512 | ||
|
|
e6d1414353 | ||
|
|
9313605c20 | ||
|
|
47fcd72cce | ||
|
|
6481e980c8 | ||
|
|
7e488a865e | ||
|
|
39016f669a | ||
|
|
b39bbfad87 | ||
|
|
683939a354 | ||
|
|
13af8005b8 | ||
|
|
aac4744cb3 | ||
|
|
ffc7a4e80d | ||
|
|
3b35024d23 | ||
|
|
df11a5e37c | ||
|
|
9b7dd0b8ef | ||
|
|
a4abe51c2a | ||
|
|
d186dbad4d | ||
|
|
b6fa8f437f | ||
|
|
09132125c6 | ||
|
|
a70a842778 | ||
|
|
417c9d2615 | ||
|
|
1ce0fad720 | ||
|
|
620dfa415a | ||
|
|
8a456d2c0d | ||
|
|
ddd61e8165 | ||
|
|
0ef8508414 | ||
|
|
3761c92c3f | ||
|
|
1b80c7b3ee | ||
|
|
acd19ccfa0 | ||
|
|
d365536b7d | ||
|
|
d03f7252df | ||
|
|
790ffc5157 | ||
|
|
cb9d40055d | ||
|
|
a7df624865 | ||
|
|
c46f113011 | ||
|
|
845471394c | ||
|
|
43d78dc37d | ||
|
|
7ac33769d2 | ||
|
|
edd17f17e3 | ||
|
|
8141518cfa | ||
|
|
b6102c4ec3 | ||
|
|
cb8e119ef1 | ||
|
|
5a974b25b6 | ||
|
|
84777d7192 | ||
|
|
e253e47943 | ||
|
|
53319922e9 | ||
|
|
7573c58552 | ||
|
|
a59b2c30bc | ||
|
|
782712451f | ||
|
|
323ddea72d | ||
|
|
d6ee372671 | ||
|
|
b53f809769 | ||
|
|
39d568adbd | ||
|
|
6059cb85e1 | ||
|
|
d69cf0e7f4 | ||
|
|
9599a28b95 | ||
|
|
e8ea663da8 | ||
|
|
ada69133a6 | ||
|
|
c27038b741 | ||
|
|
cb9a456b49 | ||
|
|
7bb911b3eb | ||
|
|
1974e31211 | ||
|
|
3b3aee1703 | ||
|
|
bc4142de4d | ||
|
|
c124fee659 | ||
|
|
1239f2b526 | ||
|
|
8655322495 | ||
|
|
8ac0220f5d | ||
|
|
c533d43c0f | ||
|
|
8941860140 | ||
|
|
2945c710e3 | ||
|
|
7b88c1d365 | ||
|
|
b732f88b0e | ||
|
|
7095afa43e | ||
|
|
eb4ec94e01 | ||
|
|
94b736d6ae | ||
|
|
8f556a5678 | ||
|
|
6e8d871d4b | ||
|
|
caaa1eba4b | ||
|
|
9d1cf016d3 | ||
|
|
738af5f76f | ||
|
|
14acce0f4b | ||
|
|
115422e51e | ||
|
|
c7e42c1bf1 | ||
|
|
b09c80ee8d | ||
|
|
2b187e7864 | ||
|
|
b0ba29fcb6 | ||
|
|
a1f37bb491 | ||
|
|
53096fbf29 | ||
|
|
5c19cfb80a | ||
|
|
caba9e77c9 | ||
|
|
d60bdaaf01 | ||
|
|
22931f1500 | ||
|
|
d3ef54a410 | ||
|
|
318f812adf | ||
|
|
2016c32dbb | ||
|
|
00b3132d31 | ||
|
|
0486aaaf88 | ||
|
|
5388a051cb | ||
|
|
7614d896aa | ||
|
|
736ea322dd | ||
|
|
9506affdc3 | ||
|
|
c53a1448d9 | ||
|
|
99a02b991c | ||
|
|
6f49564e1b | ||
|
|
c9000f8f8c | ||
|
|
00fdfa2948 | ||
|
|
387c1cb71b | ||
|
|
7712d2940c | ||
|
|
c4d274c614 | ||
|
|
c4e155fc02 | ||
|
|
e07322d3d3 | ||
|
|
d27dd5aa48 | ||
|
|
c78ac978a1 | ||
|
|
59c7b18ccc | ||
|
|
5da553ae54 | ||
|
|
1918c1c101 | ||
|
|
e084049891 | ||
|
|
003f48addb | ||
|
|
0f9fc76711 | ||
|
|
8944710e22 | ||
|
|
92870b3b8c | ||
|
|
bd2064a19a | ||
|
|
bd804f373b | ||
|
|
249a5639a9 | ||
|
|
5f3d6af9f0 | ||
|
|
775786c6f1 | ||
|
|
3546802921 | ||
|
|
7ab6cd2125 | ||
|
|
6dddf6e2f0 | ||
|
|
1de338620c | ||
|
|
ab74ae723d | ||
|
|
412931d755 | ||
|
|
3c11aebdbc | ||
|
|
fd24014852 | ||
|
|
19c8a67ce0 | ||
|
|
8df6511b18 | ||
|
|
e668f75633 | ||
|
|
6c121fc8ba | ||
|
|
27dff67b69 | ||
|
|
c76081de05 | ||
|
|
307521c12b | ||
|
|
5d3c0f8dc9 | ||
|
|
a7abf4636f | ||
|
|
e31a581a50 | ||
|
|
f2cd027472 | ||
|
|
feff332357 | ||
|
|
678de91446 | ||
|
|
ec78da9f17 | ||
|
|
dc5e5181b6 | ||
|
|
7a205b11ec | ||
|
|
4cd9c62fb9 | ||
|
|
fc2a14d78d | ||
|
|
0a42a1b2cd | ||
|
|
2b1b9e30a3 | ||
|
|
6e5e3f5ecc | ||
|
|
6e74d8f06f | ||
|
|
2d81a3a319 | ||
|
|
c90066408d | ||
|
|
dea5656e62 | ||
|
|
37e20ed32f | ||
|
|
749cb04cec | ||
|
|
f452c486d4 | ||
|
|
9d737cd743 | ||
|
|
1ddabe4f0e | ||
|
|
bb7fc84363 | ||
|
|
253bda2f16 | ||
|
|
a251740aff | ||
|
|
47f6343347 | ||
|
|
ad336f5e18 | ||
|
|
c08fbf2e45 | ||
|
|
0f20052db7 | ||
|
|
f303e31fad | ||
|
|
9201207dc9 | ||
|
|
2070e518af | ||
|
|
66f1935c2e | ||
|
|
a860285132 | ||
|
|
b23331d069 | ||
|
|
2cb5171420 | ||
|
|
78f2dc4e74 | ||
|
|
44f4a7656b | ||
|
|
5866a0826c | ||
|
|
8e1031fff0 | ||
|
|
1ec4987b38 | ||
|
|
6542ac66f1 | ||
|
|
9bf4e8894f | ||
|
|
704683421f | ||
|
|
9e9e04b97e | ||
|
|
59c463dbd3 | ||
|
|
8af2db2976 | ||
|
|
a22bff1879 | ||
|
|
5009697f7b | ||
|
|
a8b9c3623a | ||
|
|
d5d53b563c | ||
|
|
c4fa0fc06e | ||
|
|
ee3e9737c4 | ||
|
|
322cafa3c2 | ||
|
|
232302eb6f | ||
|
|
ef1773d966 | ||
|
|
e3deec9741 | ||
|
|
10fbee4e6e | ||
|
|
4fe589f0e2 | ||
|
|
a3538f6470 | ||
|
|
e82a10cb8b | ||
|
|
cc89cc667a | ||
|
|
a796973861 | ||
|
|
eba212a056 | ||
|
|
54b3fa0d87 | ||
|
|
51e8beb46c | ||
|
|
33af04db1b | ||
|
|
29c3c42134 | ||
|
|
f89f6d6223 | ||
|
|
34eb2b94b3 | ||
|
|
88012c35bd | ||
|
|
3f3061a899 | ||
|
|
53eb560aa5 | ||
|
|
11dc0d77b4 | ||
|
|
35ef8f1586 | ||
|
|
15be3b847c | ||
|
|
7bbce79394 | ||
|
|
99186e8e2f | ||
|
|
148c075d58 | ||
|
|
e3b7271f69 | ||
|
|
7802bed394 | ||
|
|
2abd1d113f | ||
|
|
4718998e07 | ||
|
|
c07687102e | ||
|
|
8805c8264c | ||
|
|
15e19d4e8b | ||
|
|
48b1cfde09 | ||
|
|
540bb97f31 | ||
|
|
cbb010acca | ||
|
|
1afd49bd24 | ||
|
|
fae5ec26fb | ||
|
|
103c8d4dfd | ||
|
|
69fc869990 | ||
|
|
76c04f6873 | ||
|
|
6f5aa5e09b | ||
|
|
8764915fcd | ||
|
|
bd6a8b9c7c | ||
|
|
eb6c8857d1 | ||
|
|
a35808ac15 | ||
|
|
ef05822b70 | ||
|
|
4f1fbcc98b | ||
|
|
43b626cbfc | ||
|
|
3d0ff40382 | ||
|
|
f41578905f | ||
|
|
08fe64b965 | ||
|
|
7ea34b3b20 | ||
|
|
030a00ba46 | ||
|
|
41ed682fbe | ||
|
|
75d862748b | ||
|
|
66bf9e4a8c | ||
|
|
dfdbf09bf8 | ||
|
|
3c0e7afeb2 | ||
|
|
bb67d68eba | ||
|
|
0430f0b1cc | ||
|
|
8b445ae998 | ||
|
|
77b7af13d4 | ||
|
|
3e9f09571b | ||
|
|
eed7699549 | ||
|
|
5fd546c428 | ||
|
|
fc3e6715db | ||
|
|
9057a4cd15 | ||
|
|
bc99929040 |
6
.beads/.gitignore
vendored
6
.beads/.gitignore
vendored
@@ -11,6 +11,12 @@ daemon.log
|
||||
daemon.pid
|
||||
bd.sock
|
||||
sync-state.json
|
||||
.sync.lock
|
||||
last-touched
|
||||
sync_base.jsonl
|
||||
.sync.lock
|
||||
last-touched
|
||||
sync_base.jsonl
|
||||
|
||||
# Local version tracking (prevents upgrade notification spam after git ops)
|
||||
.local_version
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
# This setting persists across clones (unlike database config which is gitignored).
|
||||
# Can also use BEADS_SYNC_BRANCH env var for local override.
|
||||
# If not set, bd sync will require you to run 'bd config set sync.branch <branch>'.
|
||||
# sync-branch: "beads-sync"
|
||||
sync-branch: "beads-sync"
|
||||
|
||||
# Multi-repo configuration (experimental - bd-307)
|
||||
# Allows hydrating from multiple repositories and routing writes to the correct JSONL
|
||||
@@ -59,4 +59,4 @@
|
||||
# - linear.url
|
||||
# - linear.api-key
|
||||
# - github.org
|
||||
# - github.repo
|
||||
# - github.repo
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
fotospiel-app-097
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"database": "beads.db",
|
||||
"jsonl_export": "issues.jsonl"
|
||||
}
|
||||
"jsonl_export": "issues.jsonl",
|
||||
"last_bd_version": "0.49.0"
|
||||
}
|
||||
33
.env.example
33
.env.example
@@ -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=
|
||||
@@ -112,14 +117,22 @@ PAYPAL_CLIENT_ID=
|
||||
PAYPAL_SECRET=
|
||||
PAYPAL_SANDBOX=true
|
||||
|
||||
# Paddle Billing
|
||||
PADDLE_SANDBOX=true
|
||||
PADDLE_API_KEY=
|
||||
PADDLE_CLIENT_ID=
|
||||
PADDLE_WEBHOOK_SECRET=
|
||||
PADDLE_PUBLIC_KEY=
|
||||
PADDLE_BASE_URL=
|
||||
PADDLE_CONSOLE_URL=
|
||||
# Lemon Squeezy Billing
|
||||
LEMONSQUEEZY_STORE_ID=284860
|
||||
LEMONSQUEEZY_API_KEY=
|
||||
LEMONSQUEEZY_WEBHOOK_SECRET=
|
||||
LEMONSQUEEZY_WEBHOOK_EVENTS=
|
||||
LEMONSQUEEZY_TEST_MODE=false
|
||||
LEMONSQUEEZY_BASE_URL=https://api.lemonsqueezy.com/v1
|
||||
LEMONSQUEEZY_GIFT_VARIANT_STARTER=
|
||||
LEMONSQUEEZY_GIFT_VARIANT_STARTER_USD=
|
||||
LEMONSQUEEZY_GIFT_VARIANT_STARTER_GBP=
|
||||
LEMONSQUEEZY_GIFT_VARIANT_STANDARD=
|
||||
LEMONSQUEEZY_GIFT_VARIANT_STANDARD_USD=
|
||||
LEMONSQUEEZY_GIFT_VARIANT_STANDARD_GBP=
|
||||
LEMONSQUEEZY_GIFT_VARIANT_PREMIUM=
|
||||
LEMONSQUEEZY_GIFT_VARIANT_PREMIUM_USD=
|
||||
LEMONSQUEEZY_GIFT_VARIANT_PREMIUM_GBP=
|
||||
|
||||
# Sanctum / SPA auth
|
||||
SANCTUM_STATEFUL_DOMAINS=localhost,localhost:3000
|
||||
@@ -187,5 +200,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
|
||||
|
||||
|
||||
|
||||
13
.gitignore
vendored
13
.gitignore
vendored
@@ -12,7 +12,10 @@ fotospiel-tenant-app
|
||||
/resources/js/wayfinder
|
||||
/storage/*.key
|
||||
/storage/pail
|
||||
/C:\\wwwroot\\fotospiel-app\\storage\\app/
|
||||
/vendor
|
||||
/clients/photobooth-uploader/**/bin
|
||||
/clients/photobooth-uploader/**/obj
|
||||
.env
|
||||
.env.backup
|
||||
.env.production
|
||||
@@ -23,11 +26,9 @@ Homestead.yaml
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
/auth.json
|
||||
/.fleet
|
||||
/.idea
|
||||
/.nova
|
||||
/.vscode
|
||||
/.zed
|
||||
tools/git-askpass.ps1
|
||||
podman-compose.dev.yml
|
||||
test-results
|
||||
GEMINI.md
|
||||
.beads/.sync.lock
|
||||
.beads/daemon-error
|
||||
.beads/sync_base.jsonl
|
||||
|
||||
@@ -337,8 +337,8 @@ Tokens are design system values that can be referenced using the `$` prefix.
|
||||
|
||||
### Color Tokens
|
||||
|
||||
- `accent`: #FFB6C1
|
||||
- `accentSoft`: #FFE5EC
|
||||
- `accent`: #3D5AFE
|
||||
- `accentSoft`: #E8ECFF
|
||||
- `blue10Dark`: hsl(209, 100%, 60.6%)
|
||||
- `blue10Light`: hsl(208, 100%, 47.3%)
|
||||
- `blue11Dark`: hsl(210, 100%, 66.1%)
|
||||
@@ -363,8 +363,8 @@ Tokens are design system values that can be referenced using the `$` prefix.
|
||||
- `blue8Light`: hsl(206, 81.9%, 65.3%)
|
||||
- `blue9Dark`: hsl(206, 100%, 50.0%)
|
||||
- `blue9Light`: hsl(206, 100%, 50.0%)
|
||||
- `border`: #F2E4DA
|
||||
- `danger`: #E04848
|
||||
- `border`: #F3D6C9
|
||||
- `danger`: #EF4444
|
||||
- `gray10Dark`: hsl(0, 0%, 49.4%)
|
||||
- `gray10Light`: hsl(0, 0%, 52.3%)
|
||||
- `gray11Dark`: hsl(0, 0%, 62.8%)
|
||||
@@ -413,7 +413,7 @@ Tokens are design system values that can be referenced using the `$` prefix.
|
||||
- `green8Light`: hsl(151, 40.2%, 54.1%)
|
||||
- `green9Dark`: hsl(151, 55.0%, 41.5%)
|
||||
- `green9Light`: hsl(151, 55.0%, 41.5%)
|
||||
- `muted`: #F4ECE8
|
||||
- `muted`: #FFF6F0
|
||||
- `orange10Dark`: hsl(24, 100%, 58.5%)
|
||||
- `orange10Light`: hsl(24, 100%, 46.5%)
|
||||
- `orange11Dark`: hsl(24, 100%, 62.2%)
|
||||
@@ -462,7 +462,7 @@ Tokens are design system values that can be referenced using the `$` prefix.
|
||||
- `pink8Light`: hsl(323, 60.3%, 72.4%)
|
||||
- `pink9Dark`: hsl(322, 65.0%, 54.5%)
|
||||
- `pink9Light`: hsl(322, 65.0%, 54.5%)
|
||||
- `primary`: #FF5A5F
|
||||
- `primary`: #FF5C5C
|
||||
- `purple10Dark`: hsl(273, 57.3%, 59.1%)
|
||||
- `purple10Light`: hsl(272, 46.8%, 50.3%)
|
||||
- `purple11Dark`: hsl(275, 80.0%, 71.0%)
|
||||
@@ -511,10 +511,10 @@ Tokens are design system values that can be referenced using the `$` prefix.
|
||||
- `red8Light`: hsl(359, 69.5%, 74.3%)
|
||||
- `red9Dark`: hsl(358, 75.0%, 59.0%)
|
||||
- `red9Light`: hsl(358, 75.0%, 59.0%)
|
||||
- `success`: #06D6A0
|
||||
- `success`: #22C55E
|
||||
- `surface`: #ffffff
|
||||
- `text`: #1F2937
|
||||
- `warning`: #F5C542
|
||||
- `text`: #0B132B
|
||||
- `warning`: #FBBF24
|
||||
- `yellow10Dark`: hsl(54, 100%, 68.0%)
|
||||
- `yellow10Light`: hsl(50, 100%, 48.5%)
|
||||
- `yellow11Dark`: hsl(48, 100%, 47.0%)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
868074
.tamagui/tamagui.config.json
868074
.tamagui/tamagui.config.json
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}, "...")
|
||||
|
||||
@@ -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);
|
||||
}, "...")
|
||||
|
||||
348
AGENTS.md
348
AGENTS.md
@@ -27,8 +27,8 @@ This repository hosts a multi-tenant event photo platform (Laravel 12, PHP 8.3,
|
||||
- Languages/Frameworks: PHP 8.2+ (Laravel 12), TypeScript/JavaScript (React 19/Vite 7/Tailwind 4), Filament 4.
|
||||
- Dev Commands: composer, npm, vite, artisan, PHPUnit, Pint/ESLint, Docker/Compose (for dev), Playwright, Vitest, TypeScript.
|
||||
|
||||
- Libraries: simplesoftwareio/simple-qrcode for server-side QR generation; Paddle API client (custom service) for payments; dompdf for PDF generation; spatie/laravel-translatable for i18n; minishlink/web-push for web push; firebase/php-jwt for JWT; Sentry (Laravel + Vite); Stripe (PHP + JS); Tamagui (design system); i18next (frontend i18n); vite-plugin-pwa for PWA builds.
|
||||
- Payment Systems: Paddle (subscriptions and one-time payments), RevenueCat (mobile app subscriptions), Stripe (legacy/integration use).
|
||||
- Libraries: simplesoftwareio/simple-qrcode for server-side QR generation; Lemon Squeezy API client (custom service) for payments; dompdf for PDF generation; spatie/laravel-translatable for i18n; minishlink/web-push for web push; firebase/php-jwt for JWT; Sentry (Laravel + Vite); Stripe (PHP + JS); Tamagui (design system); i18next (frontend i18n); vite-plugin-pwa for PWA builds.
|
||||
- Payment Systems: Lemon Squeezy (subscriptions and one-time payments), RevenueCat (mobile app subscriptions), Stripe (legacy/integration use).
|
||||
- PWA Technologies: React 19, Vite 7, Capacitor (iOS), Trusted Web Activity (Android), Service Workers, Background Sync.
|
||||
|
||||
## Repo Structure (high-level)
|
||||
@@ -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):
|
||||
@@ -58,7 +61,7 @@ This repository hosts a multi-tenant event photo platform (Laravel 12, PHP 8.3,
|
||||
#### Billing & Packages
|
||||
- package:check-status — check event package status.
|
||||
- packages:migrate-legacy — migrate legacy package purchases.
|
||||
- paddle:sync-packages — sync packages with Paddle (push/pull/queue/dry-run).
|
||||
- lemonsqueezy:sync-packages — sync packages with Lemon Squeezy (push/pull/queue/dry-run).
|
||||
- coupons:export — export coupon redemptions.
|
||||
- checkout:send-reminders — send abandoned checkout reminders (dry-run supported).
|
||||
|
||||
@@ -93,7 +96,7 @@ This repository hosts a multi-tenant event photo platform (Laravel 12, PHP 8.3,
|
||||
- metrics:package-limits — inspect/reset package limit metrics (routes/console.php).
|
||||
- inspire — inspiring quote (routes/console.php).
|
||||
- Public APIs for Guest PWA: stats/photos endpoints with ETag; likes; uploads; see docs/archive/prp/03-api.md.
|
||||
- Payment Integration: Paddle webhooks, RevenueCat mobile subscriptions.
|
||||
- Payment Integration: Lemon Squeezy webhooks, RevenueCat mobile subscriptions.
|
||||
|
||||
## PWA Architecture
|
||||
- Guest PWA: Offline-first photo sharing app for event attendees (installable, background sync, no account required).
|
||||
@@ -129,7 +132,7 @@ The Laravel Boost guidelines are specifically curated by Laravel maintainers for
|
||||
## Foundational Context
|
||||
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
|
||||
|
||||
- php - 8.3.24
|
||||
- php - 8.3.6
|
||||
- filament/filament (FILAMENT) - v4
|
||||
- inertiajs/inertia-laravel (INERTIA) - v2
|
||||
- laravel/framework (LARAVEL) - v12
|
||||
@@ -151,7 +154,7 @@ This application is a Laravel application and its main Laravel ecosystems packag
|
||||
- prettier (PRETTIER) - v3
|
||||
|
||||
## Conventions
|
||||
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming.
|
||||
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
|
||||
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
|
||||
- Check for existing components to reuse before writing a new one.
|
||||
|
||||
@@ -159,7 +162,7 @@ This application is a Laravel application and its main Laravel ecosystems packag
|
||||
- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important.
|
||||
|
||||
## Application Structure & Architecture
|
||||
- Stick to existing directory structure - don't create new base folders without approval.
|
||||
- Stick to existing directory structure; don't create new base folders without approval.
|
||||
- Do not change the application's dependencies without approval.
|
||||
|
||||
## Frontend Bundling
|
||||
@@ -171,17 +174,16 @@ This application is a Laravel application and its main Laravel ecosystems packag
|
||||
## Documentation Files
|
||||
- You must only create documentation files if explicitly requested by the user.
|
||||
|
||||
|
||||
=== boost rules ===
|
||||
|
||||
## Laravel Boost
|
||||
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
|
||||
|
||||
## Artisan
|
||||
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters.
|
||||
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters.
|
||||
|
||||
## URLs
|
||||
- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port.
|
||||
- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port.
|
||||
|
||||
## Tinker / Debugging
|
||||
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
|
||||
@@ -192,22 +194,21 @@ This application is a Laravel application and its main Laravel ecosystems packag
|
||||
- Only recent browser logs will be useful - ignore old logs.
|
||||
|
||||
## Searching Documentation (Critically Important)
|
||||
- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
|
||||
- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
|
||||
- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches.
|
||||
- Boost comes with a powerful `search-docs` tool you should use before any other approaches when dealing with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
|
||||
- The `search-docs` tool is perfect for all Laravel-related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
|
||||
- You must use this tool to search for Laravel ecosystem documentation before falling back to other approaches.
|
||||
- Search the documentation before making code changes to ensure we are taking the correct approach.
|
||||
- Use multiple, broad, simple, topic based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
|
||||
- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
|
||||
- Use multiple, broad, simple, topic-based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
|
||||
- Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
|
||||
|
||||
### Available Search Syntax
|
||||
- You can and should pass multiple queries at once. The most relevant results will be returned first.
|
||||
|
||||
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'
|
||||
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit"
|
||||
3. Quoted Phrases (Exact Position) - query="infinite scroll" - Words must be adjacent and in that order
|
||||
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit"
|
||||
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms
|
||||
|
||||
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'.
|
||||
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit".
|
||||
3. Quoted Phrases (Exact Position) - query="infinite scroll" - words must be adjacent and in that order.
|
||||
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit".
|
||||
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms.
|
||||
|
||||
=== php rules ===
|
||||
|
||||
@@ -218,7 +219,7 @@ This application is a Laravel application and its main Laravel ecosystems packag
|
||||
### Constructors
|
||||
- Use PHP 8 constructor property promotion in `__construct()`.
|
||||
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
|
||||
- Do not allow empty `__construct()` methods with zero parameters.
|
||||
- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private.
|
||||
|
||||
### Type Declarations
|
||||
- Always use explicit return type declarations for methods and functions.
|
||||
@@ -232,7 +233,7 @@ protected function isAccessible(User $user, ?string $path = null): bool
|
||||
</code-snippet>
|
||||
|
||||
## Comments
|
||||
- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on.
|
||||
- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless there is something very complex going on.
|
||||
|
||||
## PHPDoc Blocks
|
||||
- Add useful array shape type definitions for arrays when appropriate.
|
||||
@@ -240,32 +241,22 @@ protected function isAccessible(User $user, ?string $path = null): bool
|
||||
## Enums
|
||||
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
|
||||
|
||||
|
||||
=== herd rules ===
|
||||
|
||||
## Laravel Herd
|
||||
|
||||
- The application is served by Laravel Herd and will be available at: https?://[kebab-case-project-dir].test. Use the `get-absolute-url` tool to generate URLs for the user to ensure valid URLs.
|
||||
- You must not run any commands to make the site available via HTTP(s). It is _always_ available through Laravel Herd.
|
||||
|
||||
|
||||
=== tests rules ===
|
||||
|
||||
## Test Enforcement
|
||||
|
||||
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
|
||||
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test` with a specific filename or filter.
|
||||
|
||||
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter.
|
||||
|
||||
=== inertia-laravel/core rules ===
|
||||
|
||||
## Inertia Core
|
||||
## Inertia
|
||||
|
||||
- Inertia.js components should be placed in the `resources/js/pages` directory unless specified differently in the JS bundler (vite.config.js).
|
||||
- Inertia.js components should be placed in the `resources/js/Pages` directory unless specified differently in the JS bundler (`vite.config.js`).
|
||||
- Use `Inertia::render()` for server-side routing instead of traditional Blade views.
|
||||
- Use `search-docs` for accurate guidance on all things Inertia.
|
||||
- Use the `search-docs` tool for accurate guidance on all things Inertia.
|
||||
|
||||
<code-snippet lang="php" name="Inertia::render Example">
|
||||
<code-snippet name="Inertia Render Example" lang="php">
|
||||
// routes/web.php example
|
||||
Route::get('/users', function () {
|
||||
return Inertia::render('Users/Index', [
|
||||
@@ -274,28 +265,26 @@ Route::get('/users', function () {
|
||||
});
|
||||
</code-snippet>
|
||||
|
||||
|
||||
=== inertia-laravel/v2 rules ===
|
||||
|
||||
## Inertia v2
|
||||
|
||||
- Make use of all Inertia features from v1 & v2. Check the documentation before making any changes to ensure we are taking the correct approach.
|
||||
- Make use of all Inertia features from v1 and v2. Check the documentation before making any changes to ensure we are taking the correct approach.
|
||||
|
||||
### Inertia v2 New Features
|
||||
- Polling
|
||||
- Prefetching
|
||||
- Deferred props
|
||||
- Infinite scrolling using merging props and `WhenVisible`
|
||||
- Lazy loading data on scroll
|
||||
- Deferred props.
|
||||
- Infinite scrolling using merging props and `WhenVisible`.
|
||||
- Lazy loading data on scroll.
|
||||
- Polling.
|
||||
- Prefetching.
|
||||
|
||||
### Deferred Props & Empty States
|
||||
- When using deferred props on the frontend, you should add a nice empty state with pulsing / animated skeleton.
|
||||
- When using deferred props on the frontend, you should add a nice empty state with pulsing/animated skeleton.
|
||||
|
||||
### Inertia Form General Guidance
|
||||
- The recommended way to build forms when using Inertia is with the `<Form>` component - a useful example is below. Use `search-docs` with a query of `form component` for guidance.
|
||||
- Forms can also be built using the `useForm` helper for more programmatic control, or to follow existing conventions. Use `search-docs` with a query of `useForm helper` for guidance.
|
||||
- `resetOnError`, `resetOnSuccess`, and `setDefaultsOnSuccess` are available on the `<Form>` component. Use `search-docs` with a query of 'form component resetting' for guidance.
|
||||
|
||||
- The recommended way to build forms when using Inertia is with the `<Form>` component - a useful example is below. Use the `search-docs` tool with a query of `form component` for guidance.
|
||||
- Forms can also be built using the `useForm` helper for more programmatic control, or to follow existing conventions. Use the `search-docs` tool with a query of `useForm helper` for guidance.
|
||||
- `resetOnError`, `resetOnSuccess`, and `setDefaultsOnSuccess` are available on the `<Form>` component. Use the `search-docs` tool with a query of `form component resetting` for guidance.
|
||||
|
||||
=== laravel/core rules ===
|
||||
|
||||
@@ -307,7 +296,7 @@ Route::get('/users', function () {
|
||||
|
||||
### Database
|
||||
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
|
||||
- Use Eloquent models and relationships before suggesting raw database queries
|
||||
- Use Eloquent models and relationships before suggesting raw database queries.
|
||||
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
|
||||
- Generate code that prevents N+1 query problems by using eager loading.
|
||||
- Use Laravel's query builder for very complex database operations.
|
||||
@@ -342,52 +331,56 @@ Route::get('/users', function () {
|
||||
### Vite Error
|
||||
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
|
||||
|
||||
|
||||
=== laravel/v12 rules ===
|
||||
|
||||
## Laravel 12
|
||||
|
||||
- Use the `search-docs` tool to get version specific documentation.
|
||||
- Use the `search-docs` tool to get version-specific documentation.
|
||||
- This project upgraded from Laravel 10 without migrating to the new streamlined Laravel file structure.
|
||||
- This is **perfectly fine** and recommended by Laravel. Follow the existing structure from Laravel 10. We do not to need migrate to the new Laravel structure unless the user explicitly requests that.
|
||||
|
||||
- This is **perfectly fine** and recommended by Laravel. Follow the existing structure from Laravel 10. We do not need to migrate to the new Laravel structure unless the user explicitly requests it.
|
||||
|
||||
### Laravel 10 Structure
|
||||
- Middleware typically lives in `app/Http/Middleware/` and service providers in `app/Providers/`.
|
||||
- There is no `bootstrap/app.php` application configuration in a Laravel 10 structure:
|
||||
- Middleware registration happens in `app/Http/Kernel.php`
|
||||
- Exception handling is in `app/Exceptions/Handler.php`
|
||||
- Console commands and schedule register in `app/Console/Kernel.php`
|
||||
- Rate limits likely exist in `RouteServiceProvider` or `app/Http/Kernel.php`
|
||||
|
||||
### Database
|
||||
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
|
||||
- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
|
||||
- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
|
||||
|
||||
### Models
|
||||
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
|
||||
|
||||
|
||||
=== wayfinder/core rules ===
|
||||
|
||||
## Laravel Wayfinder
|
||||
|
||||
Wayfinder generates TypeScript functions and types for Laravel controllers and routes which you can import into your client side code. It provides type safety and automatic synchronization between backend routes and frontend code.
|
||||
Wayfinder generates TypeScript functions and types for Laravel controllers and routes which you can import into your client-side code. It provides type safety and automatic synchronization between backend routes and frontend code.
|
||||
|
||||
### Development Guidelines
|
||||
- Always use `search-docs` to check wayfinder correct usage before implementing any features.
|
||||
- Always Prefer named imports for tree-shaking (e.g., `import { show } from '@/actions/...'`)
|
||||
- Avoid default controller imports (prevents tree-shaking)
|
||||
- Run `php artisan wayfinder:generate` after route changes if Vite plugin isn't installed
|
||||
- Always use the `search-docs` tool to check Wayfinder correct usage before implementing any features.
|
||||
- Always prefer named imports for tree-shaking (e.g., `import { show } from '@/actions/...'`).
|
||||
- Avoid default controller imports (prevents tree-shaking).
|
||||
- Run `php artisan wayfinder:generate` after route changes if Vite plugin isn't installed.
|
||||
|
||||
### Feature Overview
|
||||
- Form Support: Use `.form()` with `--with-form` flag for HTML form attributes — `<form {...store.form()}>` → `action="/posts" method="post"`
|
||||
- HTTP Methods: Call `.get()`, `.post()`, `.patch()`, `.put()`, `.delete()` for specific methods — `show.head(1)` → `{ url: "/posts/1", method: "head" }`
|
||||
- Invokable Controllers: Import and invoke directly as functions. For example, `import StorePost from '@/actions/.../StorePostController'; StorePost()`
|
||||
- Named Routes: Import from `@/routes/` for non-controller routes. For example, `import { show } from '@/routes/post'; show(1)` for route name `post.show`
|
||||
- Parameter Binding: Detects route keys (e.g., `{post:slug}`) and accepts matching object properties — `show("my-post")` or `show({ slug: "my-post" })`
|
||||
- Query Merging: Use `mergeQuery` to merge with `window.location.search`, set values to `null` to remove — `show(1, { mergeQuery: { page: 2, sort: null } })`
|
||||
- Query Parameters: Pass `{ query: {...} }` in options to append params — `show(1, { query: { page: 1 } })` → `"/posts/1?page=1"`
|
||||
- Route Objects: Functions return `{ url, method }` shaped objects — `show(1)` → `{ url: "/posts/1", method: "get" }`
|
||||
- URL Extraction: Use `.url()` to get URL string — `show.url(1)` → `"/posts/1"`
|
||||
- Form Support: Use `.form()` with `--with-form` flag for HTML form attributes — `<form {...store.form()}>` → `action="/posts" method="post"`.
|
||||
- HTTP Methods: Call `.get()`, `.post()`, `.patch()`, `.put()`, `.delete()` for specific methods — `show.head(1)` → `{ url: "/posts/1", method: "head" }`.
|
||||
- Invokable Controllers: Import and invoke directly as functions. For example, `import StorePost from '@/actions/.../StorePostController'; StorePost()`.
|
||||
- Named Routes: Import from `@/routes/` for non-controller routes. For example, `import { show } from '@/routes/post'; show(1)` for route name `post.show`.
|
||||
- Parameter Binding: Detects route keys (e.g., `{post:slug}`) and accepts matching object properties — `show("my-post")` or `show({ slug: "my-post" })`.
|
||||
- Query Merging: Use `mergeQuery` to merge with `window.location.search`, set values to `null` to remove — `show(1, { mergeQuery: { page: 2, sort: null } })`.
|
||||
- Query Parameters: Pass `{ query: {...} }` in options to append params — `show(1, { query: { page: 1 } })` → `"/posts/1?page=1"`.
|
||||
- Route Objects: Functions return `{ url, method }` shaped objects — `show(1)` → `{ url: "/posts/1", method: "get" }`.
|
||||
- URL Extraction: Use `.url()` to get URL string — `show.url(1)` → `"/posts/1"`.
|
||||
|
||||
### Example Usage
|
||||
|
||||
<code-snippet name="Wayfinder Basic Usage" lang="typescript">
|
||||
// Import controller methods (tree-shakable)
|
||||
// Import controller methods (tree-shakable)...
|
||||
import { show, store, update } from '@/actions/App/Http/Controllers/PostController'
|
||||
|
||||
// Get route object with URL and method...
|
||||
@@ -405,7 +398,6 @@ Wayfinder generates TypeScript functions and types for Laravel controllers and r
|
||||
postShow(1) // { url: "/posts/1", method: "get" }
|
||||
</code-snippet>
|
||||
|
||||
|
||||
### Wayfinder + Inertia
|
||||
If your application uses the `<Form>` component from Inertia, you can use Wayfinder to generate form action and method automatically.
|
||||
<code-snippet name="Wayfinder Form Component (React)" lang="typescript">
|
||||
@@ -414,14 +406,14 @@ If your application uses the `<Form>` component from Inertia, you can use Wayfin
|
||||
|
||||
</code-snippet>
|
||||
|
||||
|
||||
=== livewire/core rules ===
|
||||
|
||||
## Livewire Core
|
||||
- Use the `search-docs` tool to find exact version specific documentation for how to write Livewire & Livewire tests.
|
||||
- Use the `php artisan make:livewire [Posts\CreatePost]` artisan command to create new components
|
||||
## Livewire
|
||||
|
||||
- Use the `search-docs` tool to find exact version-specific documentation for how to write Livewire and Livewire tests.
|
||||
- Use the `php artisan make:livewire [Posts\CreatePost]` Artisan command to create new components.
|
||||
- State should live on the server, with the UI reflecting it.
|
||||
- All Livewire requests hit the Laravel backend, they're like regular HTTP requests. Always validate form data, and run authorization checks in Livewire actions.
|
||||
- All Livewire requests hit the Laravel backend; they're like regular HTTP requests. Always validate form data and run authorization checks in Livewire actions.
|
||||
|
||||
## Livewire Best Practices
|
||||
- Livewire components require a single root element.
|
||||
@@ -438,15 +430,14 @@ If your application uses the `<Form>` component from Inertia, you can use Wayfin
|
||||
|
||||
- Prefer lifecycle hooks like `mount()`, `updatedFoo()` for initialization and reactive side effects:
|
||||
|
||||
<code-snippet name="Lifecycle hook examples" lang="php">
|
||||
<code-snippet name="Lifecycle Hook Examples" lang="php">
|
||||
public function mount(User $user) { $this->user = $user; }
|
||||
public function updatedSearch() { $this->resetPage(); }
|
||||
</code-snippet>
|
||||
|
||||
|
||||
## Testing Livewire
|
||||
|
||||
<code-snippet name="Example Livewire component test" lang="php">
|
||||
<code-snippet name="Example Livewire Component Test" lang="php">
|
||||
Livewire::test(Counter::class)
|
||||
->assertSet('count', 0)
|
||||
->call('increment')
|
||||
@@ -455,19 +446,17 @@ If your application uses the `<Form>` component from Inertia, you can use Wayfin
|
||||
->assertStatus(200);
|
||||
</code-snippet>
|
||||
|
||||
|
||||
<code-snippet name="Testing a Livewire component exists within a page" lang="php">
|
||||
$this->get('/posts/create')
|
||||
->assertSeeLivewire(CreatePost::class);
|
||||
</code-snippet>
|
||||
|
||||
<code-snippet name="Testing Livewire Component Exists on Page" lang="php">
|
||||
$this->get('/posts/create')
|
||||
->assertSeeLivewire(CreatePost::class);
|
||||
</code-snippet>
|
||||
|
||||
=== livewire/v3 rules ===
|
||||
|
||||
## Livewire 3
|
||||
|
||||
### Key Changes From Livewire 2
|
||||
- These things changed in Livewire 2, but may not have been updated in this application. Verify this application's setup to ensure you conform with application conventions.
|
||||
- These things changed in Livewire 3, but may not have been updated in this application. Verify this application's setup to ensure you conform with application conventions.
|
||||
- Use `wire:model.live` for real-time updates, `wire:model` is now deferred by default.
|
||||
- Components now use the `App\Livewire` namespace (not `App\Http\Livewire`).
|
||||
- Use `$this->dispatch()` to dispatch events (not `emit` or `dispatchBrowserEvent`).
|
||||
@@ -477,13 +466,13 @@ If your application uses the `<Form>` component from Inertia, you can use Wayfin
|
||||
- `wire:show`, `wire:transition`, `wire:cloak`, `wire:offline`, `wire:target` are available for use. Use the documentation to find usage examples.
|
||||
|
||||
### Alpine
|
||||
- Alpine is now included with Livewire, don't manually include Alpine.js.
|
||||
- Alpine is now included with Livewire; don't manually include Alpine.js.
|
||||
- Plugins included with Alpine: persist, intersect, collapse, and focus.
|
||||
|
||||
### Lifecycle Hooks
|
||||
- You can listen for `livewire:init` to hook into Livewire initialization, and `fail.status === 419` for the page expiring:
|
||||
|
||||
<code-snippet name="livewire:load example" lang="js">
|
||||
<code-snippet name="Livewire Init Hook Example" lang="js">
|
||||
document.addEventListener('livewire:init', function () {
|
||||
Livewire.hook('request', ({ fail }) => {
|
||||
if (fail && fail.status === 419) {
|
||||
@@ -497,7 +486,6 @@ document.addEventListener('livewire:init', function () {
|
||||
});
|
||||
</code-snippet>
|
||||
|
||||
|
||||
=== pint/core rules ===
|
||||
|
||||
## Laravel Pint Code Formatter
|
||||
@@ -505,24 +493,22 @@ document.addEventListener('livewire:init', function () {
|
||||
- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
|
||||
- Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues.
|
||||
|
||||
|
||||
=== phpunit/core rules ===
|
||||
|
||||
## PHPUnit Core
|
||||
## PHPUnit
|
||||
|
||||
- This application uses PHPUnit for testing. All tests must be written as PHPUnit classes. Use `php artisan make:test --phpunit {name}` to create a new test.
|
||||
- If you see a test using "Pest", convert it to PHPUnit.
|
||||
- Every time a test has been updated, run that singular test.
|
||||
- When the tests relating to your feature are passing, ask the user if they would like to also run the entire test suite to make sure everything is still passing.
|
||||
- Tests should test all of the happy paths, failure paths, and weird paths.
|
||||
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files, these are core to the application.
|
||||
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files; these are core to the application.
|
||||
|
||||
### Running Tests
|
||||
- Run the minimal number of tests, using an appropriate filter, before finalizing.
|
||||
- To run all tests: `php artisan test`.
|
||||
- To run all tests in a file: `php artisan test tests/Feature/ExampleTest.php`.
|
||||
- To filter on a particular test name: `php artisan test --filter=testName` (recommended after making a change to a related file).
|
||||
|
||||
- To run all tests: `php artisan test --compact`.
|
||||
- To run all tests in a file: `php artisan test --compact tests/Feature/ExampleTest.php`.
|
||||
- To filter on a particular test name: `php artisan test --compact --filter=testName` (recommended after making a change to a related file).
|
||||
|
||||
=== inertia-react/core rules ===
|
||||
|
||||
@@ -537,10 +523,9 @@ import { Link } from '@inertiajs/react'
|
||||
|
||||
</code-snippet>
|
||||
|
||||
|
||||
=== inertia-react/v2/forms rules ===
|
||||
|
||||
## Inertia + React Forms
|
||||
## Inertia v2 + React Forms
|
||||
|
||||
<code-snippet name="`<Form>` Component Example" lang="react">
|
||||
|
||||
@@ -575,39 +560,37 @@ export default () => (
|
||||
|
||||
</code-snippet>
|
||||
|
||||
|
||||
=== tailwindcss/core rules ===
|
||||
|
||||
## Tailwind Core
|
||||
## Tailwind CSS
|
||||
|
||||
- Use Tailwind CSS classes to style HTML, check and use existing tailwind conventions within the project before writing your own.
|
||||
- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc..)
|
||||
- Think through class placement, order, priority, and defaults - remove redundant classes, add classes to parent or child carefully to limit repetition, group elements logically
|
||||
- Use Tailwind CSS classes to style HTML; check and use existing Tailwind conventions within the project before writing your own.
|
||||
- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc.).
|
||||
- Think through class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child carefully to limit repetition, and group elements logically.
|
||||
- You can use the `search-docs` tool to get exact examples from the official documentation when needed.
|
||||
|
||||
### Spacing
|
||||
- When listing items, use gap utilities for spacing, don't use margins.
|
||||
|
||||
<code-snippet name="Valid Flex Gap Spacing Example" lang="html">
|
||||
<div class="flex gap-8">
|
||||
<div>Superior</div>
|
||||
<div>Michigan</div>
|
||||
<div>Erie</div>
|
||||
</div>
|
||||
</code-snippet>
|
||||
- When listing items, use gap utilities for spacing; don't use margins.
|
||||
|
||||
<code-snippet name="Valid Flex Gap Spacing Example" lang="html">
|
||||
<div class="flex gap-8">
|
||||
<div>Superior</div>
|
||||
<div>Michigan</div>
|
||||
<div>Erie</div>
|
||||
</div>
|
||||
</code-snippet>
|
||||
|
||||
### Dark Mode
|
||||
- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`.
|
||||
|
||||
|
||||
=== tailwindcss/v4 rules ===
|
||||
|
||||
## Tailwind 4
|
||||
## Tailwind CSS 4
|
||||
|
||||
- Always use Tailwind CSS v4 - do not use the deprecated utilities.
|
||||
- Always use Tailwind CSS v4; do not use the deprecated utilities.
|
||||
- `corePlugins` is not supported in Tailwind v4.
|
||||
- In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed.
|
||||
|
||||
<code-snippet name="Extending Theme in CSS" lang="css">
|
||||
@theme {
|
||||
--color-brand: oklch(0.72 0.11 178);
|
||||
@@ -623,9 +606,8 @@ export default () => (
|
||||
+ @import "tailwindcss";
|
||||
</code-snippet>
|
||||
|
||||
|
||||
### Replaced Utilities
|
||||
- Tailwind v4 removed deprecated utilities. Do not use the deprecated option - use the replacement.
|
||||
- Tailwind v4 removed deprecated utilities. Do not use the deprecated option; use the replacement.
|
||||
- Opacity values are still numeric.
|
||||
|
||||
| Deprecated | Replacement |
|
||||
@@ -641,6 +623,134 @@ export default () => (
|
||||
| overflow-ellipsis | text-ellipsis |
|
||||
| decoration-slice | box-decoration-slice |
|
||||
| decoration-clone | box-decoration-clone |
|
||||
|
||||
=== filament/filament rules ===
|
||||
|
||||
## Filament
|
||||
|
||||
- Filament is used by this application. Follow existing conventions for how and where it's implemented.
|
||||
- Filament is a Server-Driven UI (SDUI) framework for Laravel that lets you define user interfaces in PHP using structured configuration objects. Built on Livewire, Alpine.js, and Tailwind CSS.
|
||||
- Use the `search-docs` tool for official documentation on Artisan commands, code examples, testing, relationships, and idiomatic practices.
|
||||
|
||||
### Artisan
|
||||
|
||||
- Use Filament-specific Artisan commands to create files. Find them with `list-artisan-commands` or `php artisan --help`.
|
||||
- Inspect required options and always pass `--no-interaction`.
|
||||
|
||||
### Patterns
|
||||
|
||||
Use static `make()` methods to initialize components. Most configuration methods accept a `Closure` for dynamic values.
|
||||
|
||||
Use `Get $get` to read other form field values for conditional logic:
|
||||
|
||||
<code-snippet name="Conditional form field" lang="php">
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
|
||||
Select::make('type')
|
||||
->options(CompanyType::class)
|
||||
->required()
|
||||
->live(),
|
||||
|
||||
TextInput::make('company_name')
|
||||
->required()
|
||||
->visible(fn (Get $get): bool => $get('type') === 'business'),
|
||||
</code-snippet>
|
||||
|
||||
Use `state()` with a `Closure` to compute derived column values:
|
||||
|
||||
<code-snippet name="Computed table column" lang="php">
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
|
||||
TextColumn::make('full_name')
|
||||
->state(fn (User $record): string => "{$record->first_name} {$record->last_name}"),
|
||||
</code-snippet>
|
||||
|
||||
Actions encapsulate a button with optional modal form and logic:
|
||||
|
||||
<code-snippet name="Action with modal form" lang="php">
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
|
||||
Action::make('updateEmail')
|
||||
->form([
|
||||
TextInput::make('email')->email()->required(),
|
||||
])
|
||||
->action(fn (array $data, User $record): void => $record->update($data)),
|
||||
</code-snippet>
|
||||
|
||||
### Testing
|
||||
|
||||
Authenticate before testing panel functionality. Filament uses Livewire, so use `livewire()` or `Livewire::test()`:
|
||||
|
||||
<code-snippet name="Filament Table Test" lang="php">
|
||||
livewire(ListUsers::class)
|
||||
->assertCanSeeTableRecords($users)
|
||||
->searchTable($users->first()->name)
|
||||
->assertCanSeeTableRecords($users->take(1))
|
||||
->assertCanNotSeeTableRecords($users->skip(1));
|
||||
</code-snippet>
|
||||
|
||||
<code-snippet name="Filament Create Resource Test" lang="php">
|
||||
livewire(CreateUser::class)
|
||||
->fillForm([
|
||||
'name' => 'Test',
|
||||
'email' => 'test@example.com',
|
||||
])
|
||||
->call('create')
|
||||
->assertNotified()
|
||||
->assertRedirect();
|
||||
|
||||
assertDatabaseHas(User::class, [
|
||||
'name' => 'Test',
|
||||
'email' => 'test@example.com',
|
||||
]);
|
||||
</code-snippet>
|
||||
|
||||
<code-snippet name="Testing Validation" lang="php">
|
||||
livewire(CreateUser::class)
|
||||
->fillForm([
|
||||
'name' => null,
|
||||
'email' => 'invalid-email',
|
||||
])
|
||||
->call('create')
|
||||
->assertHasFormErrors([
|
||||
'name' => 'required',
|
||||
'email' => 'email',
|
||||
])
|
||||
->assertNotNotified();
|
||||
</code-snippet>
|
||||
|
||||
<code-snippet name="Calling Actions" lang="php">
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\Testing\TestAction;
|
||||
|
||||
livewire(EditUser::class, ['record' => $user->id])
|
||||
->callAction(DeleteAction::class)
|
||||
->assertNotified()
|
||||
->assertRedirect();
|
||||
|
||||
livewire(ListUsers::class)
|
||||
->callAction(TestAction::make('promote')->table($user), [
|
||||
'role' => 'admin',
|
||||
])
|
||||
->assertNotified();
|
||||
</code-snippet>
|
||||
|
||||
### Common Mistakes
|
||||
|
||||
**Commonly Incorrect Namespaces:**
|
||||
- Form fields (TextInput, Select, etc.): `Filament\Forms\Components\`
|
||||
- Infolist entries (for read-only views) (TextEntry, IconEntry, etc.): `Filament\Forms\Components\`
|
||||
- Layout components (Grid, Section, Fieldset, Tabs, Wizard, etc.): `Filament\Schemas\Components\`
|
||||
- Schema utilities (Get, Set, etc.): `Filament\Schemas\Components\Utilities\`
|
||||
- Actions: `Filament\Actions\` (no `Filament\Tables\Actions\` etc.)
|
||||
- Icons: `Filament\Support\Icons\Heroicon` enum (e.g., `Heroicon::PencilSquare`)
|
||||
|
||||
**Recent breaking changes to Filament:**
|
||||
- File visibility is `private` by default. Use `->visibility('public')` for public access.
|
||||
- `Grid`, `Section`, and `Fieldset` no longer span all columns by default.
|
||||
</laravel-boost-guidelines>
|
||||
|
||||
## Issue Tracking
|
||||
|
||||
@@ -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 \
|
||||
|
||||
144
app/Console/Commands/AiEditsBackfillStorageCommand.php
Normal file
144
app/Console/Commands/AiEditsBackfillStorageCommand.php
Normal file
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\AiEditOutput;
|
||||
use App\Models\AiEditRequest;
|
||||
use App\Services\AiEditing\AiEditOutputStorageService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class AiEditsBackfillStorageCommand extends Command
|
||||
{
|
||||
protected $signature = 'ai-edits:backfill-storage
|
||||
{--request-id= : Restrict backfill to one AI edit request id}
|
||||
{--limit=200 : Maximum outputs to process}
|
||||
{--pretend : Dry run without writing changes}';
|
||||
|
||||
protected $description = 'Backfill local storage paths for AI outputs that only have provider URLs.';
|
||||
|
||||
public function __construct(private readonly AiEditOutputStorageService $outputStorage)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$limit = max(1, (int) $this->option('limit'));
|
||||
$requestId = $this->normalizeRequestId($this->option('request-id'));
|
||||
$pretend = (bool) $this->option('pretend');
|
||||
|
||||
$query = AiEditOutput::query()
|
||||
->with('request')
|
||||
->whereNotNull('provider_url')
|
||||
->where(function (Builder $builder): void {
|
||||
$builder
|
||||
->whereNull('storage_path')
|
||||
->orWhere('storage_path', '');
|
||||
})
|
||||
->orderBy('id');
|
||||
|
||||
if ($requestId !== null) {
|
||||
$query->where('request_id', $requestId);
|
||||
}
|
||||
|
||||
$candidateCount = (clone $query)->count();
|
||||
$outputs = $query->limit($limit)->get();
|
||||
|
||||
if ($outputs->isEmpty()) {
|
||||
$this->info('No AI outputs require storage backfill.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->line(sprintf(
|
||||
'AI output backfill candidates: %d (processing up to %d).',
|
||||
$candidateCount,
|
||||
$limit
|
||||
));
|
||||
|
||||
if ($pretend) {
|
||||
$this->table(
|
||||
['Output ID', 'Request ID', 'Provider URL'],
|
||||
$outputs->map(static fn (AiEditOutput $output): array => [
|
||||
(string) $output->id,
|
||||
(string) $output->request_id,
|
||||
(string) $output->provider_url,
|
||||
])->all()
|
||||
);
|
||||
|
||||
$this->info('Pretend mode enabled. No records were changed.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$processed = 0;
|
||||
$stored = 0;
|
||||
$failed = 0;
|
||||
|
||||
foreach ($outputs as $output) {
|
||||
$processed++;
|
||||
|
||||
$request = $output->request;
|
||||
if (! $request instanceof AiEditRequest) {
|
||||
$failed++;
|
||||
$this->warn(sprintf('Output %d skipped: missing request relation.', $output->id));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$persisted = $this->outputStorage->persist($request, [
|
||||
'provider_url' => $output->provider_url,
|
||||
'provider_asset_id' => $output->provider_asset_id,
|
||||
'storage_disk' => $output->storage_disk,
|
||||
'storage_path' => $output->storage_path,
|
||||
'mime_type' => $output->mime_type,
|
||||
'width' => $output->width,
|
||||
'height' => $output->height,
|
||||
'bytes' => $output->bytes,
|
||||
'checksum' => $output->checksum,
|
||||
'metadata' => $output->metadata,
|
||||
]);
|
||||
|
||||
$output->forceFill([
|
||||
'provider_url' => $persisted['provider_url'] ?? $output->provider_url,
|
||||
'storage_disk' => $persisted['storage_disk'] ?? $output->storage_disk,
|
||||
'storage_path' => $persisted['storage_path'] ?? $output->storage_path,
|
||||
'mime_type' => $persisted['mime_type'] ?? $output->mime_type,
|
||||
'width' => array_key_exists('width', $persisted) ? $persisted['width'] : $output->width,
|
||||
'height' => array_key_exists('height', $persisted) ? $persisted['height'] : $output->height,
|
||||
'bytes' => array_key_exists('bytes', $persisted) ? $persisted['bytes'] : $output->bytes,
|
||||
'checksum' => $persisted['checksum'] ?? $output->checksum,
|
||||
'metadata' => is_array($persisted['metadata'] ?? null) ? $persisted['metadata'] : $output->metadata,
|
||||
])->save();
|
||||
|
||||
$storagePath = trim((string) ($output->storage_path ?? ''));
|
||||
if ($storagePath !== '') {
|
||||
$stored++;
|
||||
} else {
|
||||
$failed++;
|
||||
$this->warn(sprintf('Output %d could not be persisted locally.', $output->id));
|
||||
}
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'AI output backfill complete: processed=%d stored=%d failed=%d.',
|
||||
$processed,
|
||||
$stored,
|
||||
$failed
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function normalizeRequestId(mixed $value): ?int
|
||||
{
|
||||
if (! is_numeric($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$requestId = (int) $value;
|
||||
|
||||
return $requestId > 0 ? $requestId : null;
|
||||
}
|
||||
}
|
||||
69
app/Console/Commands/AiEditsPruneCommand.php
Normal file
69
app/Console/Commands/AiEditsPruneCommand.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\AiEditRequest;
|
||||
use App\Models\AiUsageLedger;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class AiEditsPruneCommand extends Command
|
||||
{
|
||||
protected $signature = 'ai-edits:prune
|
||||
{--request-days= : Override AI request retention days}
|
||||
{--ledger-days= : Override usage ledger retention days}
|
||||
{--pretend : Report counts without deleting data}';
|
||||
|
||||
protected $description = 'Prune stale AI edit requests and usage ledgers based on retention settings.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$requestRetentionDays = max(1, (int) ($this->option('request-days') ?: config('ai-editing.retention.request_days', 90)));
|
||||
$ledgerRetentionDays = max(1, (int) ($this->option('ledger-days') ?: config('ai-editing.retention.usage_ledger_days', 365)));
|
||||
$pretend = (bool) $this->option('pretend');
|
||||
|
||||
$requestCutoff = now()->subDays($requestRetentionDays);
|
||||
$ledgerCutoff = now()->subDays($ledgerRetentionDays);
|
||||
|
||||
$requestQuery = AiEditRequest::query()
|
||||
->where(function (Builder $query) use ($requestCutoff): void {
|
||||
$query->where(function (Builder $completedQuery) use ($requestCutoff): void {
|
||||
$completedQuery->whereNotNull('completed_at')
|
||||
->where('completed_at', '<=', $requestCutoff);
|
||||
})->orWhere(function (Builder $expiredQuery): void {
|
||||
$expiredQuery->whereNotNull('expires_at')
|
||||
->where('expires_at', '<=', now());
|
||||
});
|
||||
});
|
||||
$ledgerQuery = AiUsageLedger::query()
|
||||
->where('recorded_at', '<=', $ledgerCutoff);
|
||||
|
||||
$requestCount = (clone $requestQuery)->count();
|
||||
$ledgerCount = (clone $ledgerQuery)->count();
|
||||
|
||||
$this->line(sprintf(
|
||||
'AI prune candidates -> requests: %d (<= %s), ledgers: %d (<= %s)',
|
||||
$requestCount,
|
||||
$requestCutoff->toDateString(),
|
||||
$ledgerCount,
|
||||
$ledgerCutoff->toDateString()
|
||||
));
|
||||
|
||||
if ($pretend) {
|
||||
$this->info('Pretend mode enabled. No records were deleted.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$deletedRequests = $requestQuery->delete();
|
||||
$deletedLedgers = $ledgerQuery->delete();
|
||||
|
||||
$this->info(sprintf(
|
||||
'Pruned AI data -> requests: %d, ledgers: %d.',
|
||||
$deletedRequests,
|
||||
$deletedLedgers
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
179
app/Console/Commands/AiEditsRecoverStuckCommand.php
Normal file
179
app/Console/Commands/AiEditsRecoverStuckCommand.php
Normal file
@@ -0,0 +1,179 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\PollAiEditRequest;
|
||||
use App\Jobs\ProcessAiEditRequest;
|
||||
use App\Models\AiEditRequest;
|
||||
use App\Services\AiEditing\AiEditingRuntimeConfig;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class AiEditsRecoverStuckCommand extends Command
|
||||
{
|
||||
protected $signature = 'ai-edits:recover-stuck
|
||||
{--minutes=30 : Minimum age in minutes for queued/processing requests}
|
||||
{--requeue : Re-dispatch stuck requests back to the queue}
|
||||
{--fail : Mark stuck requests as failed}';
|
||||
|
||||
protected $description = 'Inspect stuck AI edit requests and optionally recover them by requeueing or failing.';
|
||||
|
||||
public function __construct(private readonly AiEditingRuntimeConfig $runtimeConfig)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$minutes = max(1, (int) $this->option('minutes'));
|
||||
$shouldRequeue = (bool) $this->option('requeue');
|
||||
$shouldFail = (bool) $this->option('fail');
|
||||
|
||||
if ($shouldRequeue && $shouldFail) {
|
||||
$this->error('Use either --requeue or --fail, not both.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$cutoff = now()->subMinutes($minutes);
|
||||
$requests = AiEditRequest::query()
|
||||
->with([
|
||||
'event:id,slug,name',
|
||||
'providerRuns' => function (HasMany $query): void {
|
||||
$query->select(['id', 'request_id', 'provider_task_id', 'attempt'])
|
||||
->orderByDesc('attempt');
|
||||
},
|
||||
])
|
||||
->whereIn('status', [AiEditRequest::STATUS_QUEUED, AiEditRequest::STATUS_PROCESSING])
|
||||
->where(function (Builder $query) use ($cutoff): void {
|
||||
$query
|
||||
->where(function (Builder $queuedQuery) use ($cutoff): void {
|
||||
$queuedQuery->whereNull('started_at')
|
||||
->whereNotNull('queued_at')
|
||||
->where('queued_at', '<=', $cutoff);
|
||||
})
|
||||
->orWhere(function (Builder $processingQuery) use ($cutoff): void {
|
||||
$processingQuery->whereNotNull('started_at')
|
||||
->where('started_at', '<=', $cutoff);
|
||||
})
|
||||
->orWhere(function (Builder $fallbackQuery) use ($cutoff): void {
|
||||
$fallbackQuery->whereNull('queued_at')
|
||||
->whereNull('started_at')
|
||||
->where('updated_at', '<=', $cutoff);
|
||||
});
|
||||
})
|
||||
->orderBy('updated_at')
|
||||
->get();
|
||||
|
||||
if ($requests->isEmpty()) {
|
||||
$this->info(sprintf('No stuck AI edit requests older than %d minute(s).', $minutes));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->table(
|
||||
['ID', 'Event', 'Status', 'Queued/Started', 'Latest task'],
|
||||
$requests->map(function (AiEditRequest $request): array {
|
||||
$latestTaskId = $this->latestProviderTaskId($request) ?? '-';
|
||||
$eventLabel = (string) ($request->event?->name ?: $request->event?->slug ?: $request->event_id);
|
||||
$ageSource = $request->started_at ?: $request->queued_at ?: $request->updated_at;
|
||||
|
||||
return [
|
||||
(string) $request->id,
|
||||
$eventLabel,
|
||||
$request->status,
|
||||
$ageSource?->toIso8601String() ?? '-',
|
||||
$latestTaskId,
|
||||
];
|
||||
})->all()
|
||||
);
|
||||
|
||||
if (! $shouldRequeue && ! $shouldFail) {
|
||||
$this->info('Dry-run only. Use --requeue to dispatch recovery jobs or --fail to terminate stuck requests.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if ($shouldFail) {
|
||||
$count = $this->markAsFailed($requests);
|
||||
$this->info(sprintf('Marked %d AI edit request(s) as failed.', $count));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
[$processDispatches, $pollDispatches] = $this->requeueRequests($requests);
|
||||
$this->info(sprintf(
|
||||
'Recovered %d stuck AI edit request(s): %d process dispatch(es), %d poll dispatch(es).',
|
||||
$processDispatches + $pollDispatches,
|
||||
$processDispatches,
|
||||
$pollDispatches
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0:int,1:int}
|
||||
*/
|
||||
private function requeueRequests(Collection $requests): array
|
||||
{
|
||||
$queueName = $this->runtimeConfig->queueName();
|
||||
$processDispatches = 0;
|
||||
$pollDispatches = 0;
|
||||
|
||||
foreach ($requests as $request) {
|
||||
if ($request->status === AiEditRequest::STATUS_QUEUED) {
|
||||
ProcessAiEditRequest::dispatch($request->id)->onQueue($queueName);
|
||||
$processDispatches++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$providerTaskId = $this->latestProviderTaskId($request);
|
||||
if ($providerTaskId !== null) {
|
||||
PollAiEditRequest::dispatch($request->id, $providerTaskId, 1)->onQueue($queueName);
|
||||
$pollDispatches++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
ProcessAiEditRequest::dispatch($request->id)->onQueue($queueName);
|
||||
$processDispatches++;
|
||||
}
|
||||
|
||||
return [$processDispatches, $pollDispatches];
|
||||
}
|
||||
|
||||
private function markAsFailed(Collection $requests): int
|
||||
{
|
||||
$updated = 0;
|
||||
$now = now();
|
||||
|
||||
foreach ($requests as $request) {
|
||||
$request->forceFill([
|
||||
'status' => AiEditRequest::STATUS_FAILED,
|
||||
'failure_code' => 'operator_recovery_marked_failed',
|
||||
'failure_message' => 'Marked as failed by ai-edits:recover-stuck.',
|
||||
'completed_at' => $now,
|
||||
])->save();
|
||||
|
||||
$updated++;
|
||||
}
|
||||
|
||||
return $updated;
|
||||
}
|
||||
|
||||
private function latestProviderTaskId(AiEditRequest $request): ?string
|
||||
{
|
||||
foreach ($request->providerRuns as $run) {
|
||||
$taskId = trim((string) ($run->provider_task_id ?? ''));
|
||||
if ($taskId !== '') {
|
||||
return $taskId;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Attributes\AsCommand;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
#[AsCommand(name: 'tenant:attach-demo-event')]
|
||||
class AttachDemoEvent extends Command
|
||||
@@ -25,10 +24,12 @@ class AttachDemoEvent extends Command
|
||||
{
|
||||
if (! \Illuminate\Support\Facades\Schema::hasTable('events')) {
|
||||
$this->error("Table 'events' does not exist. Run: php artisan migrate");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
if (! \Illuminate\Support\Facades\Schema::hasColumn('events', 'tenant_id')) {
|
||||
$this->error("Column 'events.tenant_id' does not exist. Add it and rerun. Suggested: create a migration to add a nullable foreignId to tenants.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
$tenant = null;
|
||||
@@ -45,6 +46,7 @@ class AttachDemoEvent extends Command
|
||||
}
|
||||
if (! $tenant) {
|
||||
$this->error('Tenant not found. Provide --tenant-slug or a user with tenant_id via --tenant-email.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
@@ -67,12 +69,14 @@ class AttachDemoEvent extends Command
|
||||
|
||||
if (! $event) {
|
||||
$this->error('Event not found. Provide --event-id or --event-slug.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
// Idempotent update
|
||||
if ((int) $event->tenant_id === (int) $tenant->id) {
|
||||
$this->info("Event #{$event->id} already attached to tenant #{$tenant->id} ({$tenant->slug}).");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
@@ -80,6 +84,7 @@ class AttachDemoEvent extends Command
|
||||
$event->save();
|
||||
|
||||
$this->info("Attached event #{$event->id} ({$event->slug}) to tenant #{$tenant->id} ({$tenant->slug}).");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,22 +10,27 @@ use Illuminate\Support\Facades\Storage;
|
||||
class BackfillThumbnails extends Command
|
||||
{
|
||||
protected $signature = 'media:backfill-thumbnails {--limit=500}';
|
||||
|
||||
protected $description = 'Generate thumbnails for photos missing thumbnail_path or where thumbnail equals original.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$limit = (int) $this->option('limit');
|
||||
$rows = DB::table('photos')
|
||||
->select(['id','event_id','file_path','thumbnail_path'])
|
||||
->select(['id', 'event_id', 'file_path', 'thumbnail_path'])
|
||||
->orderBy('id')
|
||||
->limit($limit)
|
||||
->get();
|
||||
$count = 0;
|
||||
foreach ($rows as $r) {
|
||||
$orig = $this->relativeFromUrl((string)$r->file_path);
|
||||
$thumb = (string)($r->thumbnail_path ?? '');
|
||||
if ($thumb && $thumb !== $r->file_path) continue; // already set to different thumb
|
||||
if (! $orig) continue;
|
||||
$orig = $this->relativeFromUrl((string) $r->file_path);
|
||||
$thumb = (string) ($r->thumbnail_path ?? '');
|
||||
if ($thumb && $thumb !== $r->file_path) {
|
||||
continue;
|
||||
} // already set to different thumb
|
||||
if (! $orig) {
|
||||
continue;
|
||||
}
|
||||
$baseName = pathinfo($orig, PATHINFO_FILENAME);
|
||||
$destRel = "events/{$r->event_id}/photos/thumbs/{$baseName}_thumb.jpg";
|
||||
$made = ImageHelper::makeThumbnailOnDisk('public', $orig, $destRel, 640, 82);
|
||||
@@ -39,6 +44,7 @@ class BackfillThumbnails extends Command
|
||||
}
|
||||
}
|
||||
$this->info("Done. Thumbnails generated: {$count}");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
@@ -49,6 +55,7 @@ class BackfillThumbnails extends Command
|
||||
if (str_starts_with($p, '/storage/')) {
|
||||
return substr($p, strlen('/storage/'));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Console\Commands;
|
||||
use App\Console\Concerns\InteractsWithCacheLocks;
|
||||
use App\Jobs\ArchiveEventMediaAssets;
|
||||
use App\Models\Event;
|
||||
use App\Services\Compliance\RetentionOverrideService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Contracts\Cache\Lock;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -37,6 +38,7 @@ class DispatchStorageArchiveCommand extends Command
|
||||
$maxDispatch = max(1, (int) config('storage-monitor.archive.max_dispatch', 100));
|
||||
$eventId = $this->option('event');
|
||||
$dispatched = 0;
|
||||
$overrides = app(RetentionOverrideService::class);
|
||||
|
||||
try {
|
||||
$query = Event::query()
|
||||
@@ -57,12 +59,16 @@ class DispatchStorageArchiveCommand extends Command
|
||||
});
|
||||
}
|
||||
|
||||
$query->chunkById($chunkSize, function ($events) use (&$dispatched, $maxDispatch, $eventLockTtl) {
|
||||
$query->chunkById($chunkSize, function ($events) use (&$dispatched, $maxDispatch, $eventLockTtl, $overrides) {
|
||||
foreach ($events as $event) {
|
||||
if ($dispatched >= $maxDispatch) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($overrides->eventOnHold($event)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$eventLock = $this->acquireCommandLock('storage:archive-event-'.$event->id, $eventLockTtl);
|
||||
if ($eventLock === false) {
|
||||
Log::channel('storage-jobs')->info('Archive dispatch skipped due to in-flight lock', [
|
||||
|
||||
130
app/Console/Commands/LemonSqueezyRegisterWebhooks.php
Normal file
130
app/Console/Commands/LemonSqueezyRegisterWebhooks.php
Normal file
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\LemonSqueezy\LemonSqueezyClient;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class LemonSqueezyRegisterWebhooks extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'lemonsqueezy:webhooks:register
|
||||
{--url= : Destination URL for Lemon Squeezy webhooks}
|
||||
{--events=* : Override event types to subscribe}
|
||||
{--secret= : Override the webhook signing secret}
|
||||
{--test-mode : Register the webhook in test mode}
|
||||
{--dry-run : Output payload without creating the destination}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Register Lemon Squeezy webhook notification settings.';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(LemonSqueezyClient $client): int
|
||||
{
|
||||
$destination = (string) ($this->option('url') ?: $this->defaultWebhookUrl());
|
||||
|
||||
if ($destination === '') {
|
||||
$this->error('Webhook destination URL is required. Use --url=...');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$events = collect((array) $this->option('events'))
|
||||
->filter()
|
||||
->map(fn ($event) => trim((string) $event))
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if ($events === []) {
|
||||
$events = config('lemonsqueezy.webhook_events', []);
|
||||
}
|
||||
|
||||
if ($events === [] || ! is_array($events)) {
|
||||
$this->error('No webhook events configured. Set config(lemonsqueezy.webhook_events) or pass --events.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$secret = (string) ($this->option('secret') ?: config('lemonsqueezy.webhook_secret'));
|
||||
if ($secret === '') {
|
||||
$this->error('Webhook signing secret is required. Set LEMONSQUEEZY_WEBHOOK_SECRET or pass --secret.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$storeId = (string) config('lemonsqueezy.store_id');
|
||||
if ($storeId === '') {
|
||||
$this->error('Lemon Squeezy store id is required. Set LEMONSQUEEZY_STORE_ID.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$testMode = (bool) $this->option('test-mode') || (bool) config('lemonsqueezy.test_mode', false);
|
||||
|
||||
$attributes = array_filter([
|
||||
'url' => $destination,
|
||||
'events' => $events,
|
||||
'secret' => $secret,
|
||||
'test_mode' => $testMode ? true : null,
|
||||
], static fn ($value) => $value !== null && $value !== '');
|
||||
|
||||
$payload = [
|
||||
'data' => [
|
||||
'type' => 'webhooks',
|
||||
'attributes' => $attributes,
|
||||
'relationships' => [
|
||||
'store' => [
|
||||
'data' => [
|
||||
'type' => 'stores',
|
||||
'id' => $storeId,
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
if ((bool) $this->option('dry-run')) {
|
||||
$this->line(json_encode($payload, JSON_PRETTY_PRINT));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$response = $client->post('/webhooks', $payload);
|
||||
$data = Arr::get($response, 'data', $response);
|
||||
$id = Arr::get($data, 'id');
|
||||
|
||||
Log::channel('lemonsqueezy-sync')->info('Lemon Squeezy webhook registered', [
|
||||
'webhook_id' => $id,
|
||||
'destination' => $destination,
|
||||
'test_mode' => $testMode,
|
||||
]);
|
||||
|
||||
$this->info('Lemon Squeezy webhook registered.');
|
||||
|
||||
if ($id) {
|
||||
$this->line('ID: '.$id);
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
protected function defaultWebhookUrl(): string
|
||||
{
|
||||
$base = rtrim((string) config('app.url'), '/');
|
||||
|
||||
return $base !== '' ? $base.'/lemonsqueezy/webhook' : '';
|
||||
}
|
||||
}
|
||||
@@ -2,22 +2,23 @@
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\PullPackageFromPaddle;
|
||||
use App\Jobs\SyncPackageToPaddle;
|
||||
use App\Jobs\PullPackageFromLemonSqueezy;
|
||||
use App\Jobs\SyncPackageToLemonSqueezy;
|
||||
use App\Models\Package;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class PaddleSyncPackages extends Command
|
||||
class LemonSqueezySyncPackages extends Command
|
||||
{
|
||||
protected $signature = 'paddle:sync-packages
|
||||
protected $signature = 'lemonsqueezy:sync-packages
|
||||
{--package=* : Limit sync to the given package IDs or slugs}
|
||||
{--dry-run : Generate payload snapshots without calling Paddle}
|
||||
{--pull : Fetch remote Paddle state instead of pushing local changes}
|
||||
{--dry-run : Generate payload snapshots without calling Lemon Squeezy}
|
||||
{--pull : Fetch remote Lemon Squeezy state instead of pushing local changes}
|
||||
{--allow-unmapped : Allow sync when packages are missing Lemon Squeezy product/variant IDs}
|
||||
{--queue : Dispatch jobs onto the queue instead of running synchronously}';
|
||||
|
||||
protected $description = 'Synchronise local packages with Paddle products and prices.';
|
||||
protected $description = 'Synchronise local packages with Lemon Squeezy products and variants.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
@@ -32,6 +33,13 @@ class PaddleSyncPackages extends Command
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$pull = (bool) $this->option('pull');
|
||||
$queue = (bool) $this->option('queue');
|
||||
$allowUnmapped = (bool) $this->option('allow-unmapped');
|
||||
|
||||
if (! $pull && ! $allowUnmapped && ! $this->hasPackageFilter()) {
|
||||
if (! $this->guardUnmappedPackages($packages)) {
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
$packages->each(function (Package $package) use ($dryRun, $pull, $queue) {
|
||||
if ($pull) {
|
||||
@@ -44,7 +52,7 @@ class PaddleSyncPackages extends Command
|
||||
});
|
||||
|
||||
$this->info(sprintf(
|
||||
'Queued %d package %s for Paddle %s.',
|
||||
'Queued %d package %s for Lemon Squeezy %s.',
|
||||
$packages->count(),
|
||||
Str::plural('entry', $packages->count()),
|
||||
$pull ? 'pull' : 'sync'
|
||||
@@ -82,6 +90,42 @@ class PaddleSyncPackages extends Command
|
||||
return $query->orderByDesc('id')->get();
|
||||
}
|
||||
|
||||
protected function hasPackageFilter(): bool
|
||||
{
|
||||
return collect((array) $this->option('package'))->filter()->isNotEmpty();
|
||||
}
|
||||
|
||||
protected function guardUnmappedPackages(Collection $packages): bool
|
||||
{
|
||||
$unmapped = $packages->filter(fn (Package $package) => blank($package->lemonsqueezy_product_id) || blank($package->lemonsqueezy_variant_id));
|
||||
|
||||
if ($unmapped->isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$this->error('Unmapped Lemon Squeezy package IDs detected. Resolve mappings or pass --allow-unmapped.');
|
||||
$this->table(
|
||||
['ID', 'Slug', 'Missing'],
|
||||
$unmapped->map(function (Package $package): array {
|
||||
$missing = [];
|
||||
if (blank($package->lemonsqueezy_product_id)) {
|
||||
$missing[] = 'product_id';
|
||||
}
|
||||
if (blank($package->lemonsqueezy_variant_id)) {
|
||||
$missing[] = 'variant_id';
|
||||
}
|
||||
|
||||
return [
|
||||
$package->id,
|
||||
$package->slug,
|
||||
implode(', ', $missing),
|
||||
];
|
||||
})->values()->all()
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
protected function dispatchSyncJob(Package $package, bool $dryRun, bool $queue): void
|
||||
{
|
||||
$context = [
|
||||
@@ -89,26 +133,26 @@ class PaddleSyncPackages extends Command
|
||||
];
|
||||
|
||||
if ($queue) {
|
||||
SyncPackageToPaddle::dispatch($package->id, $context);
|
||||
SyncPackageToLemonSqueezy::dispatch($package->id, $context);
|
||||
$this->line(sprintf('> queued sync for package #%d (%s)', $package->id, $package->slug));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
SyncPackageToPaddle::dispatchSync($package->id, $context);
|
||||
SyncPackageToLemonSqueezy::dispatchSync($package->id, $context);
|
||||
$this->line(sprintf('> synced package #%d (%s)', $package->id, $package->slug));
|
||||
}
|
||||
|
||||
protected function dispatchPullJob(Package $package, bool $queue): void
|
||||
{
|
||||
if ($queue) {
|
||||
PullPackageFromPaddle::dispatch($package->id);
|
||||
PullPackageFromLemonSqueezy::dispatch($package->id);
|
||||
$this->line(sprintf('> queued pull for package #%d (%s)', $package->id, $package->slug));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
PullPackageFromPaddle::dispatchSync($package->id);
|
||||
PullPackageFromLemonSqueezy::dispatchSync($package->id);
|
||||
$this->line(sprintf('> pulled package #%d (%s)', $package->id, $package->slug));
|
||||
}
|
||||
}
|
||||
@@ -4,15 +4,15 @@ namespace App\Console\Commands;
|
||||
|
||||
use App\Models\PackagePurchase;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\TenantPackage;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class MigrateLegacyPurchases extends Command
|
||||
{
|
||||
protected $signature = 'packages:migrate-legacy';
|
||||
|
||||
protected $description = 'Migrate legacy purchases to new system with temp tenants';
|
||||
|
||||
public function handle()
|
||||
@@ -21,19 +21,20 @@ class MigrateLegacyPurchases extends Command
|
||||
|
||||
if ($legacyPurchases->isEmpty()) {
|
||||
$this->info('No legacy purchases found.');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->info("Found {$legacyPurchases->count()} legacy purchases.");
|
||||
|
||||
foreach ($legacyPurchases as $purchase) {
|
||||
if (!$purchase->user_id) {
|
||||
if (! $purchase->user_id) {
|
||||
// Create temp user if no user
|
||||
$tempUser = User::create([
|
||||
'name' => 'Legacy User ' . $purchase->id,
|
||||
'email' => 'legacy' . $purchase->id . '@fotospiel.local',
|
||||
'name' => 'Legacy User '.$purchase->id,
|
||||
'email' => 'legacy'.$purchase->id.'@fotospiel.local',
|
||||
'password' => Hash::make('legacy'),
|
||||
'username' => 'legacy' . $purchase->id,
|
||||
'username' => 'legacy'.$purchase->id,
|
||||
'first_name' => 'Legacy',
|
||||
'last_name' => 'User',
|
||||
'address' => 'Legacy Address',
|
||||
@@ -43,7 +44,7 @@ class MigrateLegacyPurchases extends Command
|
||||
|
||||
$tempTenant = Tenant::create([
|
||||
'user_id' => $tempUser->id,
|
||||
'name' => 'Legacy Tenant ' . $purchase->id,
|
||||
'name' => 'Legacy Tenant '.$purchase->id,
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
@@ -73,6 +74,7 @@ class MigrateLegacyPurchases extends Command
|
||||
}
|
||||
|
||||
$this->info('Legacy migration completed.');
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Console\Commands;
|
||||
use App\Jobs\AnonymizeAccount;
|
||||
use App\Models\Tenant;
|
||||
use App\Notifications\InactiveTenantDeletionWarning;
|
||||
use App\Services\Compliance\RetentionOverrideService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
@@ -27,7 +28,13 @@ class ProcessTenantRetention extends Command
|
||||
->withMax('purchases as last_purchase_activity', 'purchased_at')
|
||||
->withMax('photos as last_photo_activity', 'created_at')
|
||||
->chunkById(100, function ($tenants) use ($warningThreshold, $deletionThreshold) {
|
||||
$overrides = app(RetentionOverrideService::class);
|
||||
|
||||
foreach ($tenants as $tenant) {
|
||||
if ($overrides->tenantOnHold($tenant)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$lastActivity = $this->determineLastActivity($tenant);
|
||||
|
||||
if (! $lastActivity) {
|
||||
|
||||
@@ -26,7 +26,7 @@ class SeedDemoSwitcherTenants extends Command
|
||||
{
|
||||
protected $signature = 'demo:seed-switcher {--with-photos : Download sample photos from Pexels} {--photos-per-event=18 : Target photos per event when downloading} {--cleanup : Remove demo switcher tenants/events/photos instead of seeding}';
|
||||
|
||||
protected $description = 'Seeds demo tenants used by the DevTenantSwitcher (endcustomer + reseller profiles)';
|
||||
protected $description = 'Seeds demo tenants used by the DevTenantSwitcher (endcustomer + partner profiles)';
|
||||
|
||||
public function __construct(private EventStorageManager $eventStorageManager)
|
||||
{
|
||||
@@ -52,7 +52,7 @@ class SeedDemoSwitcherTenants extends Command
|
||||
|
||||
DB::transaction(function () use ($packages, $eventTypes) {
|
||||
$this->seedCustomerStandardEmpty($packages, $eventTypes);
|
||||
$this->seedCustomerStarterWedding($packages, $eventTypes);
|
||||
$this->seedCustomerStandardWedding($packages, $eventTypes);
|
||||
$this->seedResellerActive($packages, $eventTypes);
|
||||
$this->seedResellerFull($packages, $eventTypes);
|
||||
});
|
||||
@@ -129,7 +129,7 @@ class SeedDemoSwitcherTenants extends Command
|
||||
$slugs = [
|
||||
'starter' => 'Starter',
|
||||
'standard' => 'Standard',
|
||||
's-small-reseller' => 'Reseller S',
|
||||
's-small-reseller' => 'Partner Start',
|
||||
];
|
||||
|
||||
$packages = [];
|
||||
@@ -165,10 +165,10 @@ class SeedDemoSwitcherTenants extends Command
|
||||
{
|
||||
$tenant = $this->upsertTenant(
|
||||
slug: 'demo-standard-empty',
|
||||
name: 'Demo Standard (ohne Event)',
|
||||
name: 'Demo Starter (ohne Event)',
|
||||
contactEmail: 'standard-empty@demo.fotospiel',
|
||||
attributes: [
|
||||
'subscription_tier' => 'standard',
|
||||
'subscription_tier' => 'starter',
|
||||
'subscription_status' => 'active',
|
||||
],
|
||||
);
|
||||
@@ -176,9 +176,9 @@ class SeedDemoSwitcherTenants extends Command
|
||||
$this->upsertAdmin($tenant, 'standard-empty@demo.fotospiel');
|
||||
|
||||
TenantPackage::updateOrCreate(
|
||||
['tenant_id' => $tenant->id, 'package_id' => $packages['standard']->id],
|
||||
['tenant_id' => $tenant->id, 'package_id' => $packages['starter']->id],
|
||||
[
|
||||
'price' => $packages['standard']->price,
|
||||
'price' => $packages['starter']->price,
|
||||
'purchased_at' => Carbon::now()->subDays(1),
|
||||
'expires_at' => Carbon::now()->addMonths(12),
|
||||
'used_events' => 0,
|
||||
@@ -186,26 +186,37 @@ class SeedDemoSwitcherTenants extends Command
|
||||
]
|
||||
);
|
||||
|
||||
$this->comment('Seeded Standard tenant without events.');
|
||||
$this->comment('Seeded Starter tenant without events.');
|
||||
}
|
||||
|
||||
private function seedCustomerStarterWedding(array $packages, array $eventTypes): void
|
||||
private function seedCustomerStandardWedding(array $packages, array $eventTypes): void
|
||||
{
|
||||
$tenant = $this->upsertTenant(
|
||||
slug: 'demo-starter-wedding',
|
||||
name: 'Demo Starter Wedding',
|
||||
name: 'Demo Standard Wedding',
|
||||
contactEmail: 'starter-wedding@demo.fotospiel',
|
||||
attributes: [
|
||||
'subscription_tier' => 'starter',
|
||||
'subscription_tier' => 'standard',
|
||||
'subscription_status' => 'active',
|
||||
],
|
||||
);
|
||||
|
||||
$this->upsertAdmin($tenant, 'starter-wedding@demo.fotospiel');
|
||||
|
||||
TenantPackage::updateOrCreate(
|
||||
['tenant_id' => $tenant->id, 'package_id' => $packages['standard']->id],
|
||||
[
|
||||
'price' => $packages['standard']->price,
|
||||
'purchased_at' => Carbon::now()->subDays(1),
|
||||
'expires_at' => Carbon::now()->addMonths(12),
|
||||
'used_events' => 1,
|
||||
'active' => true,
|
||||
]
|
||||
);
|
||||
|
||||
$event = $this->upsertEvent(
|
||||
tenant: $tenant,
|
||||
package: $packages['starter'],
|
||||
package: $packages['standard'],
|
||||
eventType: $eventTypes['wedding'] ?? null,
|
||||
attributes: [
|
||||
'name' => ['de' => 'Hochzeit Mia & Jonas', 'en' => 'Wedding Mia & Jonas'],
|
||||
@@ -221,17 +232,18 @@ class SeedDemoSwitcherTenants extends Command
|
||||
|
||||
private function seedResellerActive(array $packages, array $eventTypes): void
|
||||
{
|
||||
$eventPackage = $this->resolveIncludedPackage($packages['s-small-reseller'], $packages);
|
||||
$tenant = $this->upsertTenant(
|
||||
slug: 'demo-reseller-active',
|
||||
name: 'Demo Reseller Active',
|
||||
contactEmail: 'reseller-active@demo.fotospiel',
|
||||
name: 'Demo Partner Active',
|
||||
contactEmail: 'partner-active@demo.fotospiel',
|
||||
attributes: [
|
||||
'subscription_tier' => 'reseller',
|
||||
'subscription_status' => 'active',
|
||||
],
|
||||
);
|
||||
|
||||
$this->upsertAdmin($tenant, 'reseller-active@demo.fotospiel');
|
||||
$this->upsertAdmin($tenant, 'partner-active@demo.fotospiel');
|
||||
|
||||
TenantPackage::updateOrCreate(
|
||||
['tenant_id' => $tenant->id, 'package_id' => $packages['s-small-reseller']->id],
|
||||
@@ -268,7 +280,7 @@ class SeedDemoSwitcherTenants extends Command
|
||||
foreach ($events as $index => $config) {
|
||||
$event = $this->upsertEvent(
|
||||
tenant: $tenant,
|
||||
package: $packages['standard'],
|
||||
package: $eventPackage,
|
||||
eventType: $config['type'],
|
||||
attributes: [
|
||||
'name' => $config['name'],
|
||||
@@ -285,17 +297,18 @@ class SeedDemoSwitcherTenants extends Command
|
||||
|
||||
private function seedResellerFull(array $packages, array $eventTypes): void
|
||||
{
|
||||
$eventPackage = $this->resolveIncludedPackage($packages['s-small-reseller'], $packages);
|
||||
$tenant = $this->upsertTenant(
|
||||
slug: 'demo-reseller-full',
|
||||
name: 'Demo Reseller Voll',
|
||||
contactEmail: 'reseller-full@demo.fotospiel',
|
||||
name: 'Demo Partner Voll',
|
||||
contactEmail: 'partner-full@demo.fotospiel',
|
||||
attributes: [
|
||||
'subscription_tier' => 'reseller',
|
||||
'subscription_status' => 'active',
|
||||
],
|
||||
);
|
||||
|
||||
$this->upsertAdmin($tenant, 'reseller-full@demo.fotospiel');
|
||||
$this->upsertAdmin($tenant, 'partner-full@demo.fotospiel');
|
||||
|
||||
TenantPackage::updateOrCreate(
|
||||
['tenant_id' => $tenant->id, 'package_id' => $packages['s-small-reseller']->id],
|
||||
@@ -319,7 +332,7 @@ class SeedDemoSwitcherTenants extends Command
|
||||
foreach ($eventConfigs as $index => $config) {
|
||||
$event = $this->upsertEvent(
|
||||
tenant: $tenant,
|
||||
package: $packages['standard'],
|
||||
package: $eventPackage,
|
||||
eventType: $config['type'],
|
||||
attributes: [
|
||||
'name' => $config['name'],
|
||||
@@ -346,8 +359,8 @@ class SeedDemoSwitcherTenants extends Command
|
||||
'settings' => [
|
||||
'branding' => [
|
||||
'logo_url' => null,
|
||||
'primary_color' => '#1D4ED8',
|
||||
'secondary_color' => '#0F172A',
|
||||
'primary_color' => '#FF5A5F',
|
||||
'secondary_color' => '#FFF8F5',
|
||||
'font_family' => 'Inter, sans-serif',
|
||||
],
|
||||
'features' => [
|
||||
@@ -424,6 +437,19 @@ class SeedDemoSwitcherTenants extends Command
|
||||
return $event;
|
||||
}
|
||||
|
||||
private function resolveIncludedPackage(Package $resellerPackage, array $packages): Package
|
||||
{
|
||||
$includedSlug = $resellerPackage->included_package_slug;
|
||||
|
||||
if ($includedSlug && isset($packages[$includedSlug])) {
|
||||
return $packages[$includedSlug];
|
||||
}
|
||||
|
||||
$fallback = $packages['starter'] ?? $packages['standard'] ?? null;
|
||||
|
||||
return $fallback ?? $resellerPackage;
|
||||
}
|
||||
|
||||
private function fallbackEventType(): ?EventType
|
||||
{
|
||||
$fallback = EventType::first();
|
||||
|
||||
@@ -62,7 +62,7 @@ class SendAbandonedCheckoutReminders extends Command
|
||||
if ($this->shouldSendReminder($checkout, $stage)) {
|
||||
$resumeUrl = $this->generateResumeUrl($checkout);
|
||||
|
||||
if (!$isDryRun) {
|
||||
if (! $isDryRun) {
|
||||
$mailLocale = $checkout->user->preferred_locale ?? config('app.locale');
|
||||
|
||||
Mail::to($checkout->user)
|
||||
@@ -86,8 +86,8 @@ class SendAbandonedCheckoutReminders extends Command
|
||||
$totalProcessed++;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
Log::error("Failed to send {$stage} reminder for checkout {$checkout->id}: " . $e->getMessage());
|
||||
$this->error(" ❌ Failed to process checkout {$checkout->id}: " . $e->getMessage());
|
||||
Log::error("Failed to send {$stage} reminder for checkout {$checkout->id}: ".$e->getMessage());
|
||||
$this->error(" ❌ Failed to process checkout {$checkout->id}: ".$e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -98,7 +98,7 @@ class SendAbandonedCheckoutReminders extends Command
|
||||
->count();
|
||||
|
||||
if ($oldCheckouts > 0) {
|
||||
if (!$isDryRun) {
|
||||
if (! $isDryRun) {
|
||||
AbandonedCheckoutModel::where('abandoned_at', '<', now()->subDays(30))
|
||||
->where('converted', false)
|
||||
->delete();
|
||||
@@ -108,10 +108,10 @@ class SendAbandonedCheckoutReminders extends Command
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("✅ Reminder process completed!");
|
||||
$this->info('✅ Reminder process completed!');
|
||||
$this->info(" Processed: {$totalProcessed} checkouts");
|
||||
|
||||
if (!$isDryRun) {
|
||||
if (! $isDryRun) {
|
||||
$this->info(" Sent: {$totalSent} reminder emails");
|
||||
} else {
|
||||
$this->info(" Would send: {$totalSent} reminder emails");
|
||||
@@ -131,12 +131,12 @@ class SendAbandonedCheckoutReminders extends Command
|
||||
}
|
||||
|
||||
// User existiert noch?
|
||||
if (!$checkout->user) {
|
||||
if (! $checkout->user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Package existiert noch?
|
||||
if (!$checkout->package) {
|
||||
if (! $checkout->package) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
*/
|
||||
|
||||
19
app/Enums/DataExportScope.php
Normal file
19
app/Enums/DataExportScope.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum DataExportScope: string
|
||||
{
|
||||
case USER = 'user';
|
||||
case TENANT = 'tenant';
|
||||
case EVENT = 'event';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::USER => __('User'),
|
||||
self::TENANT => __('Tenant'),
|
||||
self::EVENT => __('Event'),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -6,4 +6,4 @@ enum PackageType: string
|
||||
{
|
||||
case ENDCUSTOMER = 'endcustomer';
|
||||
case RESELLER = 'reseller';
|
||||
}
|
||||
}
|
||||
|
||||
12
app/Enums/PhotoLiveStatus.php
Normal file
12
app/Enums/PhotoLiveStatus.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum PhotoLiveStatus: string
|
||||
{
|
||||
case NONE = 'none';
|
||||
case PENDING = 'pending';
|
||||
case APPROVED = 'approved';
|
||||
case REJECTED = 'rejected';
|
||||
case EXPIRED = 'expired';
|
||||
}
|
||||
17
app/Enums/RetentionOverrideScope.php
Normal file
17
app/Enums/RetentionOverrideScope.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum RetentionOverrideScope: string
|
||||
{
|
||||
case TENANT = 'tenant';
|
||||
case EVENT = 'event';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::TENANT => __('Tenant'),
|
||||
self::EVENT => __('Event'),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,6 @@ namespace App\Exports;
|
||||
use App\Models\EventPurchase;
|
||||
use Filament\Actions\Exports\Exporter;
|
||||
use Filament\Actions\Exports\Models\Export;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class EventPurchaseExporter extends Exporter
|
||||
{
|
||||
@@ -28,11 +26,10 @@ class EventPurchaseExporter extends Exporter
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
public static function getCompletedNotificationBody(Export $export): string
|
||||
{
|
||||
$body = "Your Event Purchases export has completed and is ready for download. {$export->successful_rows} purchases were exported.";
|
||||
|
||||
return $body;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,4 +16,4 @@ class ListCategories extends ListRecords
|
||||
Actions\CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -16,4 +16,4 @@ class ListPosts extends ListRecords
|
||||
Actions\CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,4 +8,4 @@ use Filament\Resources\Pages\ViewRecord;
|
||||
class ViewPost extends ViewRecord
|
||||
{
|
||||
protected static string $resource = PostResource::class;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,4 +26,4 @@ trait HasContentEditor
|
||||
'h3',
|
||||
]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Clusters\DailyOps\Pages;
|
||||
|
||||
use App\Filament\Clusters\DailyOps\DailyOpsCluster;
|
||||
use App\Filament\Widgets\JoinTokenOverviewWidget;
|
||||
use App\Filament\Widgets\JoinTokenTopTokensWidget;
|
||||
use App\Filament\Widgets\JoinTokenTrendWidget;
|
||||
use App\Models\Event;
|
||||
use BackedEnum;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Pages\Dashboard;
|
||||
use Filament\Pages\Dashboard\Concerns\HasFiltersForm;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use UnitEnum;
|
||||
|
||||
class JoinTokenAnalyticsDashboard extends Dashboard
|
||||
{
|
||||
use HasFiltersForm;
|
||||
|
||||
protected static ?string $cluster = DailyOpsCluster::class;
|
||||
|
||||
protected static string $routePath = 'join-token-analytics';
|
||||
|
||||
protected static null|string|BackedEnum $navigationIcon = 'heroicon-o-chart-bar';
|
||||
|
||||
protected static null|string|UnitEnum $navigationGroup = null;
|
||||
|
||||
protected static ?int $navigationSort = 12;
|
||||
|
||||
public static function getNavigationGroup(): UnitEnum|string|null
|
||||
{
|
||||
return __('admin.nav.security');
|
||||
}
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return __('admin.join_token_analytics.navigation.label');
|
||||
}
|
||||
|
||||
public function getHeading(): string
|
||||
{
|
||||
return __('admin.join_token_analytics.heading');
|
||||
}
|
||||
|
||||
public function getSubheading(): ?string
|
||||
{
|
||||
return __('admin.join_token_analytics.subheading');
|
||||
}
|
||||
|
||||
public function getColumns(): int|array
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
public function getWidgets(): array
|
||||
{
|
||||
return [
|
||||
JoinTokenOverviewWidget::class,
|
||||
JoinTokenTrendWidget::class,
|
||||
JoinTokenTopTokensWidget::class,
|
||||
];
|
||||
}
|
||||
|
||||
public function filtersForm(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Section::make()
|
||||
->schema([
|
||||
Select::make('range')
|
||||
->label(__('admin.join_token_analytics.filters.range'))
|
||||
->options(trans('admin.join_token_analytics.filters.range_options'))
|
||||
->default('24h')
|
||||
->native(false),
|
||||
Select::make('event_id')
|
||||
->label(__('admin.join_token_analytics.filters.event'))
|
||||
->placeholder(__('admin.join_token_analytics.filters.event_placeholder'))
|
||||
->searchable()
|
||||
->getSearchResultsUsing(fn (string $search): array => $this->searchEvents($search))
|
||||
->getOptionLabelUsing(fn ($value): ?string => $this->resolveEventLabel($value))
|
||||
->native(false),
|
||||
])
|
||||
->columns(2),
|
||||
]);
|
||||
}
|
||||
|
||||
private function searchEvents(string $search): array
|
||||
{
|
||||
return Event::query()
|
||||
->with('tenant')
|
||||
->when($search !== '', function ($query) use ($search) {
|
||||
$query->where('slug', 'like', "%{$search}%")
|
||||
->orWhere('name->de', 'like', "%{$search}%")
|
||||
->orWhere('name->en', 'like', "%{$search}%");
|
||||
})
|
||||
->orderByDesc('date')
|
||||
->limit(25)
|
||||
->get()
|
||||
->mapWithKeys(fn (Event $event) => [$event->id => $this->formatEventLabel($event)])
|
||||
->all();
|
||||
}
|
||||
|
||||
private function resolveEventLabel(mixed $value): ?string
|
||||
{
|
||||
if (! is_numeric($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$event = Event::query()
|
||||
->with('tenant')
|
||||
->find((int) $value);
|
||||
|
||||
return $event ? $this->formatEventLabel($event) : null;
|
||||
}
|
||||
|
||||
private function formatEventLabel(Event $event): string
|
||||
{
|
||||
$locale = app()->getLocale();
|
||||
$name = $event->name[$locale] ?? $event->name['de'] ?? $event->name['en'] ?? $event->slug ?? __('admin.common.unnamed');
|
||||
$tenant = $event->tenant?->name ?? __('admin.common.unnamed');
|
||||
$date = $event->date?->format('Y-m-d');
|
||||
|
||||
return $date ? "{$name} ({$tenant}) {$date}" : "{$name} ({$tenant})";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Clusters\DailyOps\Resources\TenantCheckoutHealths\Pages;
|
||||
|
||||
use App\Filament\Clusters\DailyOps\Resources\TenantCheckoutHealths\TenantCheckoutHealthResource;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListTenantCheckoutHealths extends ListRecords
|
||||
{
|
||||
protected static string $resource = TenantCheckoutHealthResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Clusters\DailyOps\Resources\TenantCheckoutHealths\Tables;
|
||||
|
||||
use App\Filament\Clusters\DailyOps\Resources\TenantCheckoutHealths\TenantCheckoutHealthResource;
|
||||
use App\Models\CheckoutSession;
|
||||
use App\Models\Tenant;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\Filter;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class TenantCheckoutHealthTable
|
||||
{
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->label(__('admin.common.tenant'))
|
||||
->searchable()
|
||||
->sortable(),
|
||||
TextColumn::make('slug')
|
||||
->label(__('admin.common.slug'))
|
||||
->searchable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('contact_email')
|
||||
->label(__('admin.tenants.fields.contact_email'))
|
||||
->searchable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('subscription_status')
|
||||
->label('Subscription')
|
||||
->badge()
|
||||
->color(fn (?string $state) => match ($state) {
|
||||
'active' => 'success',
|
||||
'suspended' => 'warning',
|
||||
'expired' => 'danger',
|
||||
'free' => 'gray',
|
||||
default => 'gray',
|
||||
}),
|
||||
TextColumn::make('active_reseller_package')
|
||||
->label('Active package')
|
||||
->getStateUsing(fn (Tenant $record) => $record->activeResellerPackage?->package?->name ?? '—')
|
||||
->badge()
|
||||
->color(fn (string $state) => $state === '—' ? 'gray' : 'success')
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
IconColumn::make('status_mismatch')
|
||||
->label('Status mismatch')
|
||||
->boolean()
|
||||
->getStateUsing(fn (Tenant $record) => self::hasStatusMismatch($record)),
|
||||
TextColumn::make('last_checkout_transaction_at')
|
||||
->label('Last transaction')
|
||||
->badge()
|
||||
->color(fn (?Carbon $state) => self::transactionAgeColor($state))
|
||||
->getStateUsing(fn (Tenant $record) => $record->last_checkout_transaction_at
|
||||
? Carbon::parse($record->last_checkout_transaction_at)
|
||||
: null)
|
||||
->formatStateUsing(fn (?Carbon $state) => $state?->diffForHumans() ?? '—')
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('checkout_transaction_count_window')
|
||||
->label('Transactions (30d)')
|
||||
->default('0')
|
||||
->sortable()
|
||||
->toggleable(),
|
||||
TextColumn::make('checkout_transaction_total_window')
|
||||
->label('Total (30d)')
|
||||
->default(0)
|
||||
->money('EUR')
|
||||
->sortable()
|
||||
->toggleable(),
|
||||
TextColumn::make('checkout_refund_count_window')
|
||||
->label('Refunds (30d)')
|
||||
->badge()
|
||||
->color(fn (?int $state) => $state && $state > 0 ? 'danger' : 'gray')
|
||||
->default('0')
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('checkout_refund_total_window')
|
||||
->label('Refund total (30d)')
|
||||
->default(0)
|
||||
->money('EUR')
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('checkout_requires_action_count')
|
||||
->label('Checkout action required')
|
||||
->badge()
|
||||
->color(fn (?int $state) => $state && $state > 0 ? 'warning' : 'gray')
|
||||
->default('0')
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('checkout_processing_count')
|
||||
->label('Checkout processing')
|
||||
->badge()
|
||||
->color(fn (?int $state) => $state && $state > 0 ? 'warning' : 'gray')
|
||||
->default('0')
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('checkout_expired_count')
|
||||
->label('Checkout expired')
|
||||
->badge()
|
||||
->color(fn (?int $state) => $state && $state > 0 ? 'danger' : 'gray')
|
||||
->default('0')
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('checkout_transaction_count')
|
||||
->label('Transactions (all)')
|
||||
->default('0')
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('checkout_transaction_total')
|
||||
->label('Total (all)')
|
||||
->default(0)
|
||||
->money('EUR')
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->filters([
|
||||
Filter::make('status_mismatch')
|
||||
->label('Status mismatch')
|
||||
->indicator('Status mismatch')
|
||||
->query(fn (Builder $query) => self::applyStatusMismatchFilter($query)),
|
||||
Filter::make('active_package')
|
||||
->label('Active package')
|
||||
->indicator('Active package')
|
||||
->query(fn (Builder $query) => $query->whereHas('activeResellerPackage', function (Builder $query) {
|
||||
$query->where('active', true)
|
||||
->where(function (Builder $query) {
|
||||
$query->whereNull('expires_at')
|
||||
->orWhere('expires_at', '>=', now());
|
||||
});
|
||||
})),
|
||||
Filter::make('not_suspended_or_deleted')
|
||||
->label('Not suspended/deleted')
|
||||
->indicator('Not suspended/deleted')
|
||||
->query(fn (Builder $query) => $query
|
||||
->where('is_suspended', false)
|
||||
->whereNull('pending_deletion_at')
|
||||
->whereNull('anonymized_at')),
|
||||
Filter::make('checkout_transaction_stale')
|
||||
->label('Stale transactions')
|
||||
->indicator('Stale transactions')
|
||||
->query(function (Builder $query): Builder {
|
||||
$cutoff = now()->subDays(TenantCheckoutHealthResource::TRANSACTION_WINDOW_DAYS);
|
||||
$provider = TenantCheckoutHealthResource::provider();
|
||||
|
||||
return $query
|
||||
->whereHas('purchases', fn (Builder $query) => $query->where('provider', $provider))
|
||||
->whereDoesntHave('purchases', fn (Builder $query) => $query
|
||||
->where('provider', $provider)
|
||||
->where('purchased_at', '>=', $cutoff));
|
||||
}),
|
||||
Filter::make('checkout_attention')
|
||||
->label('Checkout attention')
|
||||
->indicator('Checkout attention')
|
||||
->query(fn (Builder $query) => $query->whereHas('checkoutSessions', function (Builder $query) {
|
||||
$query->where('provider', TenantCheckoutHealthResource::provider())
|
||||
->where(function (Builder $query) {
|
||||
$query->whereIn('status', [
|
||||
CheckoutSession::STATUS_REQUIRES_CUSTOMER_ACTION,
|
||||
CheckoutSession::STATUS_PROCESSING,
|
||||
])
|
||||
->orWhere(function (Builder $query) {
|
||||
$query->whereNotIn('status', [
|
||||
CheckoutSession::STATUS_COMPLETED,
|
||||
CheckoutSession::STATUS_CANCELLED,
|
||||
])
|
||||
->whereNotNull('expires_at')
|
||||
->where('expires_at', '<', now());
|
||||
});
|
||||
});
|
||||
})),
|
||||
Filter::make('refund_spike')
|
||||
->label('Refund spike (30d)')
|
||||
->form([
|
||||
TextInput::make('min_refunds')
|
||||
->label('Minimum refunds')
|
||||
->numeric()
|
||||
->default(1)
|
||||
->minValue(1),
|
||||
])
|
||||
->indicateUsing(function (array $data): ?string {
|
||||
$min = (int) ($data['min_refunds'] ?? 0);
|
||||
|
||||
return $min > 0 ? "Refunds >= {$min} (30d)" : null;
|
||||
})
|
||||
->query(function (Builder $query, array $data): Builder {
|
||||
$min = (int) ($data['min_refunds'] ?? 0);
|
||||
|
||||
if ($min < 1) {
|
||||
return $query;
|
||||
}
|
||||
|
||||
$cutoff = now()->subDays(TenantCheckoutHealthResource::TRANSACTION_WINDOW_DAYS);
|
||||
$provider = TenantCheckoutHealthResource::provider();
|
||||
|
||||
return $query->whereHas('purchases', fn (Builder $query) => $query
|
||||
->where('provider', $provider)
|
||||
->where('refunded', true)
|
||||
->where('purchased_at', '>=', $cutoff), '>=', $min);
|
||||
}),
|
||||
SelectFilter::make('subscription_status')
|
||||
->label('Subscription')
|
||||
->options([
|
||||
'active' => 'Active',
|
||||
'suspended' => 'Suspended',
|
||||
'expired' => 'Expired',
|
||||
'free' => 'Free',
|
||||
]),
|
||||
])
|
||||
->actions([]);
|
||||
}
|
||||
|
||||
private static function hasStatusMismatch(Tenant $record): bool
|
||||
{
|
||||
$hasActivePackage = (bool) ($record->has_active_reseller_package ?? $record->activeResellerPackage);
|
||||
$status = (string) ($record->subscription_status ?? '');
|
||||
$expiresAt = $record->subscription_expires_at;
|
||||
|
||||
if ($status === 'active' && ! $hasActivePackage) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($status !== 'active' && $hasActivePackage) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($status === 'active' && $expiresAt && $expiresAt->isPast()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static function applyStatusMismatchFilter(Builder $query): Builder
|
||||
{
|
||||
return $query->where(function (Builder $query) {
|
||||
$query->where(function (Builder $query) {
|
||||
$query->where('subscription_status', 'active')
|
||||
->whereDoesntHave('activeResellerPackage');
|
||||
})->orWhere(function (Builder $query) {
|
||||
$query->where('subscription_status', '!=', 'active')
|
||||
->whereHas('activeResellerPackage');
|
||||
})->orWhere(function (Builder $query) {
|
||||
$query->where('subscription_status', 'active')
|
||||
->whereNotNull('subscription_expires_at')
|
||||
->where('subscription_expires_at', '<', now());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private static function transactionAgeColor(?Carbon $state): string
|
||||
{
|
||||
if (! $state) {
|
||||
return 'gray';
|
||||
}
|
||||
|
||||
if ($state->lt(now()->subDays(TenantCheckoutHealthResource::TRANSACTION_WINDOW_DAYS))) {
|
||||
return 'danger';
|
||||
}
|
||||
|
||||
return 'success';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Clusters\DailyOps\Resources\TenantCheckoutHealths;
|
||||
|
||||
use App\Filament\Clusters\DailyOps\DailyOpsCluster;
|
||||
use App\Filament\Clusters\DailyOps\Resources\TenantCheckoutHealths\Pages\ListTenantCheckoutHealths;
|
||||
use App\Filament\Clusters\DailyOps\Resources\TenantCheckoutHealths\Tables\TenantCheckoutHealthTable;
|
||||
use App\Models\CheckoutSession;
|
||||
use App\Models\Tenant;
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use UnitEnum;
|
||||
|
||||
class TenantCheckoutHealthResource extends Resource
|
||||
{
|
||||
public const TRANSACTION_WINDOW_DAYS = 30;
|
||||
|
||||
public const DEFAULT_PROVIDER = CheckoutSession::PROVIDER_PAYPAL;
|
||||
|
||||
protected static ?string $model = Tenant::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-credit-card';
|
||||
|
||||
protected static ?string $cluster = DailyOpsCluster::class;
|
||||
|
||||
protected static ?string $slug = 'checkout-health';
|
||||
|
||||
protected static ?int $navigationSort = 20;
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return TenantCheckoutHealthTable::configure($table);
|
||||
}
|
||||
|
||||
public static function canCreate(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return __('admin.checkout_health.navigation.label');
|
||||
}
|
||||
|
||||
public static function getNavigationGroup(): UnitEnum|string|null
|
||||
{
|
||||
return __('admin.nav.billing');
|
||||
}
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
$provider = static::provider();
|
||||
$windowStart = now()->subDays(self::TRANSACTION_WINDOW_DAYS);
|
||||
|
||||
return parent::getEloquentQuery()
|
||||
->with(['activeResellerPackage.package'])
|
||||
->withExists('activeResellerPackage as has_active_reseller_package')
|
||||
->withCount([
|
||||
'purchases as checkout_transaction_count' => fn (Builder $query) => $query
|
||||
->where('provider', $provider)
|
||||
->where('refunded', false),
|
||||
'purchases as checkout_transaction_count_window' => fn (Builder $query) => $query
|
||||
->where('provider', $provider)
|
||||
->where('refunded', false)
|
||||
->where('purchased_at', '>=', $windowStart),
|
||||
'purchases as checkout_refund_count_window' => fn (Builder $query) => $query
|
||||
->where('provider', $provider)
|
||||
->where('refunded', true)
|
||||
->where('purchased_at', '>=', $windowStart),
|
||||
'checkoutSessions as checkout_requires_action_count' => fn (Builder $query) => $query
|
||||
->where('provider', $provider)
|
||||
->where('status', CheckoutSession::STATUS_REQUIRES_CUSTOMER_ACTION),
|
||||
'checkoutSessions as checkout_processing_count' => fn (Builder $query) => $query
|
||||
->where('provider', $provider)
|
||||
->where('status', CheckoutSession::STATUS_PROCESSING),
|
||||
'checkoutSessions as checkout_expired_count' => fn (Builder $query) => $query
|
||||
->where('provider', $provider)
|
||||
->whereNotIn('status', [
|
||||
CheckoutSession::STATUS_COMPLETED,
|
||||
CheckoutSession::STATUS_CANCELLED,
|
||||
])
|
||||
->whereNotNull('expires_at')
|
||||
->where('expires_at', '<', now()),
|
||||
])
|
||||
->withSum([
|
||||
'purchases as checkout_transaction_total' => fn (Builder $query) => $query
|
||||
->where('provider', $provider)
|
||||
->where('refunded', false),
|
||||
], 'price')
|
||||
->withSum([
|
||||
'purchases as checkout_transaction_total_window' => fn (Builder $query) => $query
|
||||
->where('provider', $provider)
|
||||
->where('refunded', false)
|
||||
->where('purchased_at', '>=', $windowStart),
|
||||
], 'price')
|
||||
->withSum([
|
||||
'purchases as checkout_refund_total_window' => fn (Builder $query) => $query
|
||||
->where('provider', $provider)
|
||||
->where('refunded', true)
|
||||
->where('purchased_at', '>=', $windowStart),
|
||||
], 'price')
|
||||
->withMax([
|
||||
'purchases as last_checkout_transaction_at' => fn (Builder $query) => $query
|
||||
->where('provider', $provider),
|
||||
], 'purchased_at');
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListTenantCheckoutHealths::route('/'),
|
||||
];
|
||||
}
|
||||
|
||||
public static function provider(): string
|
||||
{
|
||||
return (string) config('checkout.default_provider', self::DEFAULT_PROVIDER);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Clusters\WeeklyOps\Resources\TaskCollections\Pages;
|
||||
|
||||
use App\Filament\Clusters\WeeklyOps\Resources\TaskCollections\TaskCollectionResource;
|
||||
use App\Filament\Resources\Pages\AuditedCreateRecord;
|
||||
|
||||
class CreateTaskCollection extends AuditedCreateRecord
|
||||
{
|
||||
protected static string $resource = TaskCollectionResource::class;
|
||||
|
||||
protected function mutateFormDataBeforeCreate(array $data): array
|
||||
{
|
||||
return TaskCollectionResource::normalizeData($data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Clusters\WeeklyOps\Resources\TaskCollections\Pages;
|
||||
|
||||
use App\Filament\Clusters\WeeklyOps\Resources\TaskCollections\TaskCollectionResource;
|
||||
use App\Filament\Resources\Pages\AuditedEditRecord;
|
||||
use App\Services\Audit\SuperAdminAuditLogger;
|
||||
use Filament\Actions;
|
||||
|
||||
class EditTaskCollection extends AuditedEditRecord
|
||||
{
|
||||
protected static string $resource = TaskCollectionResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\DeleteAction::make()
|
||||
->after(fn ($record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||
'deleted',
|
||||
$record,
|
||||
source: static::class
|
||||
)),
|
||||
];
|
||||
}
|
||||
|
||||
protected function mutateFormDataBeforeSave(array $data): array
|
||||
{
|
||||
return TaskCollectionResource::normalizeData($data, $this->record);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Clusters\WeeklyOps\Resources\TaskCollections\Pages;
|
||||
|
||||
use App\Filament\Clusters\WeeklyOps\Resources\TaskCollections\TaskCollectionResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListTaskCollections extends ListRecords
|
||||
{
|
||||
protected static string $resource = TaskCollectionResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Clusters\WeeklyOps\Resources\TaskCollections\RelationManagers;
|
||||
|
||||
use App\Models\Task;
|
||||
use Filament\Actions\AttachAction;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DetachAction;
|
||||
use Filament\Actions\DetachBulkAction;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class TasksRelationManager extends RelationManager
|
||||
{
|
||||
protected static string $relationship = 'tasks';
|
||||
|
||||
protected static ?string $inverseRelationship = 'taskCollections';
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('title')
|
||||
->label(__('admin.tasks.table.title'))
|
||||
->getStateUsing(fn (Task $record) => $this->formatTaskTitle($record->title))
|
||||
->searchable(['title->de', 'title->en'])
|
||||
->limit(60),
|
||||
TextColumn::make('emotion.name')
|
||||
->label(__('admin.tasks.fields.emotion'))
|
||||
->getStateUsing(function (Task $record) {
|
||||
$value = optional($record->emotion)->name;
|
||||
if (is_array($value)) {
|
||||
$locale = app()->getLocale();
|
||||
|
||||
return $value[$locale] ?? ($value['de'] ?? ($value['en'] ?? ''));
|
||||
}
|
||||
|
||||
return (string) ($value ?? '');
|
||||
})
|
||||
->sortable(),
|
||||
TextColumn::make('difficulty')
|
||||
->label(__('admin.tasks.fields.difficulty.label'))
|
||||
->badge(),
|
||||
IconColumn::make('is_active')
|
||||
->label(__('admin.tasks.table.is_active'))
|
||||
->boolean(),
|
||||
TextColumn::make('sort_order')
|
||||
->label(__('admin.tasks.table.sort_order'))
|
||||
->sortable(),
|
||||
])
|
||||
->headerActions([
|
||||
AttachAction::make()
|
||||
->recordTitle(fn (Task $record) => $this->formatTaskTitle($record->title))
|
||||
->recordSelectOptionsQuery(fn (Builder $query): Builder => $query->whereNull('tenant_id'))
|
||||
->multiple()
|
||||
->after(function (array $data): void {
|
||||
$collection = $this->getOwnerRecord();
|
||||
$recordIds = Arr::wrap($data['recordId'] ?? []);
|
||||
|
||||
if ($recordIds === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$collection->reassignTasks($recordIds);
|
||||
}),
|
||||
])
|
||||
->recordActions([
|
||||
DetachAction::make()
|
||||
->after(function (?Task $record): void {
|
||||
if (! $record) {
|
||||
return;
|
||||
}
|
||||
|
||||
$collectionId = $this->getOwnerRecord()->getKey();
|
||||
|
||||
if ($record->collection_id === $collectionId) {
|
||||
$record->update(['collection_id' => null]);
|
||||
}
|
||||
}),
|
||||
])
|
||||
->toolbarActions([
|
||||
BulkActionGroup::make([
|
||||
DetachBulkAction::make()
|
||||
->after(function (Collection $records): void {
|
||||
$collectionId = $this->getOwnerRecord()->getKey();
|
||||
|
||||
$ids = $records
|
||||
->filter(fn (Task $record) => $record->collection_id === $collectionId)
|
||||
->pluck('id')
|
||||
->all();
|
||||
|
||||
if ($ids === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
Task::query()
|
||||
->whereIn('id', $ids)
|
||||
->update(['collection_id' => null]);
|
||||
}),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string>|string|null $value
|
||||
*/
|
||||
protected function formatTaskTitle(array|string|null $value): string
|
||||
{
|
||||
if (is_array($value)) {
|
||||
$locale = app()->getLocale();
|
||||
|
||||
return $value[$locale]
|
||||
?? ($value['de'] ?? ($value['en'] ?? Arr::first($value) ?? ''));
|
||||
}
|
||||
|
||||
if (is_string($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Clusters\WeeklyOps\Resources\TaskCollections;
|
||||
|
||||
use App\Filament\Clusters\WeeklyOps\Resources\TaskCollections\Pages\CreateTaskCollection;
|
||||
use App\Filament\Clusters\WeeklyOps\Resources\TaskCollections\Pages\EditTaskCollection;
|
||||
use App\Filament\Clusters\WeeklyOps\Resources\TaskCollections\Pages\ListTaskCollections;
|
||||
use App\Filament\Clusters\WeeklyOps\Resources\TaskCollections\RelationManagers\TasksRelationManager;
|
||||
use App\Filament\Clusters\WeeklyOps\WeeklyOpsCluster;
|
||||
use App\Models\EventType;
|
||||
use App\Models\TaskCollection;
|
||||
use App\Services\Audit\SuperAdminAuditLogger;
|
||||
use BackedEnum;
|
||||
use Filament\Actions;
|
||||
use Filament\Forms\Components\MarkdownEditor;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Tabs as SchemaTabs;
|
||||
use Filament\Schemas\Components\Tabs\Tab as SchemaTab;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Str;
|
||||
use UnitEnum;
|
||||
|
||||
class TaskCollectionResource extends Resource
|
||||
{
|
||||
protected static ?string $model = TaskCollection::class;
|
||||
|
||||
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-rectangle-stack';
|
||||
|
||||
protected static ?string $cluster = WeeklyOpsCluster::class;
|
||||
|
||||
protected static ?string $recordTitleAttribute = 'name';
|
||||
|
||||
protected static ?int $navigationSort = 31;
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->schema([
|
||||
TextInput::make('slug')
|
||||
->label(__('admin.common.slug'))
|
||||
->maxLength(255)
|
||||
->unique(ignoreRecord: true)
|
||||
->required(),
|
||||
Select::make('event_type_id')
|
||||
->relationship('eventType', 'name')
|
||||
->getOptionLabelFromRecordUsing(fn (EventType $record) => is_array($record->name) ? ($record->name['de'] ?? $record->name['en'] ?? __('admin.common.unnamed')) : $record->name)
|
||||
->searchable()
|
||||
->preload()
|
||||
->label(__('admin.task_collections.fields.event_type_optional')),
|
||||
SchemaTabs::make('content_tabs')
|
||||
->label(__('admin.task_collections.fields.content_localization'))
|
||||
->tabs([
|
||||
SchemaTab::make(__('admin.common.german'))
|
||||
->icon('heroicon-o-language')
|
||||
->schema([
|
||||
TextInput::make('name_translations.de')
|
||||
->label(__('admin.task_collections.fields.name_de'))
|
||||
->required(),
|
||||
MarkdownEditor::make('description_translations.de')
|
||||
->label(__('admin.task_collections.fields.description_de'))
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
SchemaTab::make(__('admin.common.english'))
|
||||
->icon('heroicon-o-language')
|
||||
->schema([
|
||||
TextInput::make('name_translations.en')
|
||||
->label(__('admin.task_collections.fields.name_en'))
|
||||
->required(),
|
||||
MarkdownEditor::make('description_translations.en')
|
||||
->label(__('admin.task_collections.fields.description_en'))
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
Toggle::make('is_default')
|
||||
->label(__('admin.task_collections.fields.is_default'))
|
||||
->default(false),
|
||||
TextInput::make('position')
|
||||
->label(__('admin.task_collections.fields.position'))
|
||||
->numeric()
|
||||
->default(0),
|
||||
])
|
||||
->columns(2);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('id')
|
||||
->label('#')
|
||||
->sortable(),
|
||||
TextColumn::make('name')
|
||||
->label(__('admin.task_collections.table.name'))
|
||||
->getStateUsing(fn (TaskCollection $record) => static::formatTranslation($record->name_translations))
|
||||
->searchable(['name_translations->de', 'name_translations->en'])
|
||||
->limit(60),
|
||||
TextColumn::make('eventType.name')
|
||||
->label(__('admin.task_collections.table.event_type'))
|
||||
->getStateUsing(function (TaskCollection $record) {
|
||||
$value = optional($record->eventType)->name;
|
||||
|
||||
if (is_array($value)) {
|
||||
$locale = app()->getLocale();
|
||||
|
||||
return $value[$locale] ?? ($value['de'] ?? ($value['en'] ?? ''));
|
||||
}
|
||||
|
||||
return (string) ($value ?? '');
|
||||
})
|
||||
->toggleable(),
|
||||
TextColumn::make('slug')
|
||||
->label(__('admin.task_collections.table.slug'))
|
||||
->toggleable()
|
||||
->searchable(),
|
||||
IconColumn::make('is_default')
|
||||
->label(__('admin.task_collections.table.is_default'))
|
||||
->boolean(),
|
||||
TextColumn::make('position')
|
||||
->label(__('admin.task_collections.table.position'))
|
||||
->sortable(),
|
||||
TextColumn::make('tasks_count')
|
||||
->label(__('admin.task_collections.table.tasks'))
|
||||
->sortable(),
|
||||
TextColumn::make('events_count')
|
||||
->label(__('admin.task_collections.table.events'))
|
||||
->sortable(),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('event_type_id')
|
||||
->label(__('admin.task_collections.table.event_type'))
|
||||
->relationship(
|
||||
'eventType',
|
||||
'name',
|
||||
fn (Builder $query): Builder => $query->orderBy('name->de')
|
||||
)
|
||||
->getOptionLabelFromRecordUsing(fn (EventType $record) => is_array($record->name) ? ($record->name['de'] ?? $record->name['en'] ?? __('admin.common.unnamed')) : $record->name),
|
||||
SelectFilter::make('is_default')
|
||||
->label(__('admin.task_collections.table.is_default'))
|
||||
->options([
|
||||
'1' => __('admin.common.yes'),
|
||||
'0' => __('admin.common.no'),
|
||||
]),
|
||||
])
|
||||
->recordActions([
|
||||
Actions\EditAction::make()
|
||||
->mutateDataUsing(fn (array $data, TaskCollection $record): array => static::normalizeData($data, $record))
|
||||
->after(fn (array $data, TaskCollection $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||
'updated',
|
||||
$record,
|
||||
SuperAdminAuditLogger::fieldsMetadata($data),
|
||||
static::class
|
||||
)),
|
||||
Actions\DeleteAction::make()
|
||||
->after(fn (TaskCollection $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||
'deleted',
|
||||
$record,
|
||||
source: static::class
|
||||
)),
|
||||
])
|
||||
->bulkActions([
|
||||
Actions\DeleteBulkAction::make()
|
||||
->after(function (Collection $records): void {
|
||||
$logger = app(SuperAdminAuditLogger::class);
|
||||
|
||||
foreach ($records as $record) {
|
||||
$logger->recordModelMutation(
|
||||
'deleted',
|
||||
$record,
|
||||
source: static::class
|
||||
);
|
||||
}
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return __('admin.task_collections.menu');
|
||||
}
|
||||
|
||||
public static function getNavigationGroup(): UnitEnum|string|null
|
||||
{
|
||||
return __('admin.nav.curation');
|
||||
}
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
return parent::getEloquentQuery()
|
||||
->whereNull('tenant_id')
|
||||
->with('eventType')
|
||||
->withCount(['tasks', 'events']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public static function normalizeData(array $data, ?TaskCollection $record = null): array
|
||||
{
|
||||
$data['tenant_id'] = null;
|
||||
$data['slug'] = static::resolveSlug($data, $record);
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
protected static function resolveSlug(array $data, ?TaskCollection $record = null): string
|
||||
{
|
||||
$rawSlug = trim((string) ($data['slug'] ?? ''));
|
||||
$translations = Arr::wrap($data['name_translations'] ?? []);
|
||||
$fallbackName = (string) ($translations['en'] ?? $translations['de'] ?? '');
|
||||
|
||||
$base = $rawSlug !== '' ? $rawSlug : $fallbackName;
|
||||
$slugBase = Str::slug($base) ?: 'collection';
|
||||
|
||||
$query = TaskCollection::query()->where('slug', $slugBase);
|
||||
|
||||
if ($record) {
|
||||
$query->whereKeyNot($record->getKey());
|
||||
}
|
||||
|
||||
if (! $query->exists()) {
|
||||
return $slugBase;
|
||||
}
|
||||
|
||||
do {
|
||||
$candidate = $slugBase.'-'.Str::random(4);
|
||||
$candidateQuery = TaskCollection::query()->where('slug', $candidate);
|
||||
|
||||
if ($record) {
|
||||
$candidateQuery->whereKeyNot($record->getKey());
|
||||
}
|
||||
} while ($candidateQuery->exists());
|
||||
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string>|null $translations
|
||||
*/
|
||||
protected static function formatTranslation(?array $translations): string
|
||||
{
|
||||
if (! is_array($translations)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$locale = app()->getLocale();
|
||||
|
||||
return $translations[$locale]
|
||||
?? ($translations['de'] ?? ($translations['en'] ?? Arr::first($translations) ?? ''));
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListTaskCollections::route('/'),
|
||||
'create' => CreateTaskCollection::route('/create'),
|
||||
'edit' => EditTaskCollection::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
TasksRelationManager::class,
|
||||
];
|
||||
}
|
||||
}
|
||||
305
app/Filament/Resources/AiStyles/AiStyleResource.php
Normal file
305
app/Filament/Resources/AiStyles/AiStyleResource.php
Normal file
@@ -0,0 +1,305 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\AiStyles;
|
||||
|
||||
use App\Filament\Clusters\RareAdmin\RareAdminCluster;
|
||||
use App\Filament\Resources\AiStyles\Pages\ManageAiStyles;
|
||||
use App\Models\AiStyle;
|
||||
use App\Services\AiEditing\RunwareModelSearchService;
|
||||
use App\Services\Audit\SuperAdminAuditLogger;
|
||||
use BackedEnum;
|
||||
use Filament\Actions;
|
||||
use Filament\Forms\Components\KeyValue;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
use Filament\Schemas\Components\Utilities\Set;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use UnitEnum;
|
||||
|
||||
class AiStyleResource extends Resource
|
||||
{
|
||||
protected static ?string $model = AiStyle::class;
|
||||
|
||||
protected static ?string $cluster = RareAdminCluster::class;
|
||||
|
||||
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-paint-brush';
|
||||
|
||||
protected static UnitEnum|string|null $navigationGroup = null;
|
||||
|
||||
protected static ?int $navigationSort = 31;
|
||||
|
||||
public static function getNavigationGroup(): UnitEnum|string|null
|
||||
{
|
||||
return __('admin.nav.platform');
|
||||
}
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return 'AI Styles';
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema->schema([
|
||||
Section::make('Style Basics')
|
||||
->schema([
|
||||
TextInput::make('key')
|
||||
->required()
|
||||
->maxLength(120)
|
||||
->unique(ignoreRecord: true),
|
||||
TextInput::make('name')
|
||||
->required()
|
||||
->maxLength(120),
|
||||
TextInput::make('version')
|
||||
->numeric()
|
||||
->default(1)
|
||||
->disabled()
|
||||
->dehydrated(false)
|
||||
->helperText('Auto-increments when core style configuration changes.'),
|
||||
TextInput::make('category')
|
||||
->maxLength(50),
|
||||
TextInput::make('sort')
|
||||
->numeric()
|
||||
->default(0)
|
||||
->required(),
|
||||
Toggle::make('is_active')
|
||||
->default(true),
|
||||
Toggle::make('is_premium')
|
||||
->default(false),
|
||||
Toggle::make('requires_source_image')
|
||||
->default(true),
|
||||
])
|
||||
->columns(3),
|
||||
Section::make('Provider Binding')
|
||||
->schema([
|
||||
Select::make('provider')
|
||||
->options([
|
||||
'runware' => 'runware.ai',
|
||||
])
|
||||
->required()
|
||||
->default('runware'),
|
||||
Select::make('provider_model')
|
||||
->label('Runware model (AIR)')
|
||||
->searchable()
|
||||
->getSearchResultsUsing(static fn (string $search): array => app(RunwareModelSearchService::class)->searchOptions($search))
|
||||
->getOptionLabelUsing(static fn (mixed $value): ?string => app(RunwareModelSearchService::class)->labelForModel($value))
|
||||
->helperText('Start typing to search models from runware.ai.')
|
||||
->native(false)
|
||||
->live()
|
||||
->afterStateUpdated(static function (Set $set, ?string $state): void {
|
||||
self::applySelectedRunwareModel($set, $state);
|
||||
}),
|
||||
])
|
||||
->columns(2),
|
||||
Section::make('Runware Generation')
|
||||
->schema([
|
||||
TextInput::make('metadata.runware.generation.width')
|
||||
->label('Width')
|
||||
->numeric()
|
||||
->minValue(64)
|
||||
->maxValue(4096)
|
||||
->step(64)
|
||||
->helperText(static fn (Get $get): ?string => self::dimensionConstraintHint($get, 'width')),
|
||||
TextInput::make('metadata.runware.generation.height')
|
||||
->label('Height')
|
||||
->numeric()
|
||||
->minValue(64)
|
||||
->maxValue(4096)
|
||||
->step(64)
|
||||
->helperText(static fn (Get $get): ?string => self::dimensionConstraintHint($get, 'height')),
|
||||
TextInput::make('metadata.runware.generation.steps')
|
||||
->label('Steps')
|
||||
->numeric()
|
||||
->minValue(1)
|
||||
->maxValue(150)
|
||||
->helperText(static fn (Get $get): ?string => self::rangeConstraintHint($get, 'steps')),
|
||||
TextInput::make('metadata.runware.generation.cfg_scale')
|
||||
->label('CFG Scale')
|
||||
->numeric()
|
||||
->minValue(0)
|
||||
->maxValue(30)
|
||||
->step(0.1)
|
||||
->helperText(static fn (Get $get): ?string => self::rangeConstraintHint($get, 'cfg_scale')),
|
||||
TextInput::make('metadata.runware.generation.strength')
|
||||
->label('Strength')
|
||||
->numeric()
|
||||
->minValue(0)
|
||||
->maxValue(1)
|
||||
->step(0.01)
|
||||
->helperText(static fn (Get $get): ?string => self::rangeConstraintHint($get, 'strength')),
|
||||
Select::make('metadata.runware.generation.output_format')
|
||||
->label('Output format')
|
||||
->options([
|
||||
'JPG' => 'JPG',
|
||||
'PNG' => 'PNG',
|
||||
'WEBP' => 'WEBP',
|
||||
])
|
||||
->default('JPG')
|
||||
->native(false),
|
||||
Select::make('metadata.runware.generation.delivery_method')
|
||||
->label('Delivery method')
|
||||
->options([
|
||||
'async' => 'async (queue + poll)',
|
||||
'sync' => 'sync',
|
||||
])
|
||||
->default('async')
|
||||
->native(false),
|
||||
])
|
||||
->columns(3),
|
||||
Section::make('Prompts')
|
||||
->schema([
|
||||
Textarea::make('description')
|
||||
->rows(2),
|
||||
Textarea::make('prompt_template')
|
||||
->rows(5),
|
||||
Textarea::make('negative_prompt_template')
|
||||
->rows(4),
|
||||
]),
|
||||
Section::make('Metadata')
|
||||
->schema([
|
||||
KeyValue::make('metadata')
|
||||
->nullable(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('sort')
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('key')
|
||||
->searchable()
|
||||
->copyable(),
|
||||
Tables\Columns\TextColumn::make('name')
|
||||
->searchable(),
|
||||
Tables\Columns\TextColumn::make('version')
|
||||
->sortable()
|
||||
->toggleable(),
|
||||
Tables\Columns\TextColumn::make('provider')
|
||||
->badge(),
|
||||
Tables\Columns\TextColumn::make('provider_model')
|
||||
->toggleable(),
|
||||
Tables\Columns\IconColumn::make('is_active')
|
||||
->boolean(),
|
||||
Tables\Columns\IconColumn::make('is_premium')
|
||||
->boolean(),
|
||||
Tables\Columns\TextColumn::make('sort')
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('updated_at')
|
||||
->since()
|
||||
->toggleable(),
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\TernaryFilter::make('is_active'),
|
||||
Tables\Filters\TernaryFilter::make('is_premium'),
|
||||
])
|
||||
->actions([
|
||||
Actions\EditAction::make()
|
||||
->after(fn (array $data, AiStyle $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||
'updated',
|
||||
$record,
|
||||
SuperAdminAuditLogger::fieldsMetadata(array_keys($data)),
|
||||
static::class
|
||||
)),
|
||||
Actions\DeleteAction::make()
|
||||
->after(fn (AiStyle $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||
'deleted',
|
||||
$record,
|
||||
source: static::class
|
||||
)),
|
||||
])
|
||||
->bulkActions([
|
||||
Actions\DeleteBulkAction::make(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ManageAiStyles::route('/'),
|
||||
];
|
||||
}
|
||||
|
||||
private static function applySelectedRunwareModel(Set $set, ?string $air): void
|
||||
{
|
||||
if (! is_string($air) || trim($air) === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$model = app(RunwareModelSearchService::class)->findByAir($air);
|
||||
if (! is_array($model)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$set('metadata.runware.model.air', $model['air']);
|
||||
$set('metadata.runware.model.name', $model['name']);
|
||||
$set('metadata.runware.model.architecture', $model['architecture']);
|
||||
$set('metadata.runware.model.category', $model['category']);
|
||||
|
||||
foreach ((array) ($model['constraints'] ?? []) as $key => $value) {
|
||||
$set("metadata.runware.constraints.{$key}", $value);
|
||||
}
|
||||
|
||||
self::setIfNumeric($set, 'metadata.runware.generation.width', $model['defaults']['width'] ?? null);
|
||||
self::setIfNumeric($set, 'metadata.runware.generation.height', $model['defaults']['height'] ?? null);
|
||||
self::setIfNumeric($set, 'metadata.runware.generation.steps', $model['defaults']['steps'] ?? null);
|
||||
self::setIfNumeric($set, 'metadata.runware.generation.cfg_scale', $model['defaults']['cfg_scale'] ?? null);
|
||||
}
|
||||
|
||||
private static function setIfNumeric(Set $set, string $path, mixed $value): void
|
||||
{
|
||||
if (is_numeric($value)) {
|
||||
$set($path, $value);
|
||||
}
|
||||
}
|
||||
|
||||
private static function dimensionConstraintHint(Get $get, string $dimension): ?string
|
||||
{
|
||||
$min = $get("metadata.runware.constraints.min_{$dimension}");
|
||||
$max = $get("metadata.runware.constraints.max_{$dimension}");
|
||||
$step = $get("metadata.runware.constraints.{$dimension}_step");
|
||||
|
||||
if (! is_numeric($min) && ! is_numeric($max) && ! is_numeric($step)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$parts = [];
|
||||
if (is_numeric($min) || is_numeric($max)) {
|
||||
$parts[] = sprintf(
|
||||
'Model range: %s - %s',
|
||||
is_numeric($min) ? (string) (int) $min : '?',
|
||||
is_numeric($max) ? (string) (int) $max : '?'
|
||||
);
|
||||
}
|
||||
|
||||
if (is_numeric($step) && (int) $step > 0) {
|
||||
$parts[] = sprintf('Step: %d', (int) $step);
|
||||
}
|
||||
|
||||
return $parts !== [] ? implode(' | ', $parts) : null;
|
||||
}
|
||||
|
||||
private static function rangeConstraintHint(Get $get, string $field): ?string
|
||||
{
|
||||
$min = $get("metadata.runware.constraints.min_{$field}");
|
||||
$max = $get("metadata.runware.constraints.max_{$field}");
|
||||
|
||||
if (! is_numeric($min) && ! is_numeric($max)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return sprintf(
|
||||
'Model range: %s - %s',
|
||||
is_numeric($min) ? trim((string) $min) : '?',
|
||||
is_numeric($max) ? trim((string) $max) : '?'
|
||||
);
|
||||
}
|
||||
}
|
||||
26
app/Filament/Resources/AiStyles/Pages/ManageAiStyles.php
Normal file
26
app/Filament/Resources/AiStyles/Pages/ManageAiStyles.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\AiStyles\Pages;
|
||||
|
||||
use App\Filament\Resources\AiStyles\AiStyleResource;
|
||||
use App\Services\Audit\SuperAdminAuditLogger;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ManageRecords;
|
||||
|
||||
class ManageAiStyles extends ManageRecords
|
||||
{
|
||||
protected static string $resource = AiStyleResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\CreateAction::make()
|
||||
->after(fn (array $data, $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||
'created',
|
||||
$record,
|
||||
SuperAdminAuditLogger::fieldsMetadata(array_keys($data)),
|
||||
static::class
|
||||
)),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ namespace App\Filament\Resources\Coupons\Pages;
|
||||
|
||||
use App\Filament\Resources\Coupons\CouponResource;
|
||||
use App\Filament\Resources\Pages\AuditedCreateRecord;
|
||||
use App\Jobs\SyncCouponToPaddle;
|
||||
use App\Jobs\SyncCouponToLemonSqueezy;
|
||||
|
||||
class CreateCoupon extends AuditedCreateRecord
|
||||
{
|
||||
@@ -14,6 +14,6 @@ class CreateCoupon extends AuditedCreateRecord
|
||||
{
|
||||
parent::afterCreate();
|
||||
|
||||
SyncCouponToPaddle::dispatch($this->record);
|
||||
SyncCouponToLemonSqueezy::dispatch($this->record);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ namespace App\Filament\Resources\Coupons\Pages;
|
||||
|
||||
use App\Filament\Resources\Coupons\CouponResource;
|
||||
use App\Filament\Resources\Pages\AuditedEditRecord;
|
||||
use App\Jobs\SyncCouponToPaddle;
|
||||
use App\Jobs\SyncCouponToLemonSqueezy;
|
||||
use App\Services\Audit\SuperAdminAuditLogger;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\ForceDeleteAction;
|
||||
@@ -27,7 +27,7 @@ class EditCoupon extends AuditedEditRecord
|
||||
source: static::class
|
||||
);
|
||||
|
||||
SyncCouponToPaddle::dispatch($record, true);
|
||||
SyncCouponToLemonSqueezy::dispatch($record, true);
|
||||
}),
|
||||
ForceDeleteAction::make()
|
||||
->after(fn ($record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||
@@ -48,6 +48,6 @@ class EditCoupon extends AuditedEditRecord
|
||||
{
|
||||
parent::afterSave();
|
||||
|
||||
SyncCouponToPaddle::dispatch($this->record);
|
||||
SyncCouponToLemonSqueezy::dispatch($this->record);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class RedemptionsRelationManager extends RelationManager
|
||||
{
|
||||
@@ -20,11 +21,35 @@ class RedemptionsRelationManager extends RelationManager
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->recordTitleAttribute('paddle_transaction_id')
|
||||
->recordTitleAttribute('lemonsqueezy_order_id')
|
||||
->columns([
|
||||
TextColumn::make('tenant.name')
|
||||
->label(__('Tenant'))
|
||||
->searchable(),
|
||||
TextColumn::make('ip_address')
|
||||
->label(__('IP'))
|
||||
->copyable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('device_id')
|
||||
->label(__('Device'))
|
||||
->copyable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('user_agent')
|
||||
->label(__('User agent'))
|
||||
->toggleable(isToggledHiddenByDefault: true)
|
||||
->wrap(),
|
||||
TextColumn::make('fraud_ip')
|
||||
->label(__('IP reputation'))
|
||||
->badge()
|
||||
->formatStateUsing(fn ($state, $record) => self::formatReputation(data_get($record->metadata, 'fraud.ip')))
|
||||
->color(fn ($state, $record) => self::riskColor(data_get($record->metadata, 'fraud.ip.risk')))
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('fraud_device')
|
||||
->label(__('Device reputation'))
|
||||
->badge()
|
||||
->formatStateUsing(fn ($state, $record) => self::formatReputation(data_get($record->metadata, 'fraud.device')))
|
||||
->color(fn ($state, $record) => self::riskColor(data_get($record->metadata, 'fraud.device.risk')))
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('user.name')
|
||||
->label(__('User'))
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
@@ -40,7 +65,7 @@ class RedemptionsRelationManager extends RelationManager
|
||||
'failed' => 'danger',
|
||||
default => 'warning',
|
||||
}),
|
||||
TextColumn::make('paddle_transaction_id')
|
||||
TextColumn::make('lemonsqueezy_order_id')
|
||||
->label(__('Transaction'))
|
||||
->copyable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
@@ -69,4 +94,30 @@ class RedemptionsRelationManager extends RelationManager
|
||||
->recordActions([])
|
||||
->toolbarActions([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{risk?: string, recent_failed?: int, recent_total?: int}|null $snapshot
|
||||
*/
|
||||
private static function formatReputation(?array $snapshot): string
|
||||
{
|
||||
if (! $snapshot) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
$risk = Str::headline($snapshot['risk'] ?? 'unknown');
|
||||
$failed = (int) ($snapshot['recent_failed'] ?? 0);
|
||||
$total = (int) ($snapshot['recent_total'] ?? 0);
|
||||
|
||||
return sprintf('%s (%d/%d)', $risk, $failed, $total);
|
||||
}
|
||||
|
||||
private static function riskColor(?string $risk): string
|
||||
{
|
||||
return match ($risk) {
|
||||
'high' => 'danger',
|
||||
'medium' => 'warning',
|
||||
'low' => 'success',
|
||||
default => 'gray',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,22 +123,22 @@ class CouponForm
|
||||
->nullable()
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
Section::make(__('Paddle sync'))
|
||||
Section::make(__('Lemon Squeezy sync'))
|
||||
->columns(2)
|
||||
->schema([
|
||||
Select::make('paddle_mode')
|
||||
->label(__('Paddle mode'))
|
||||
Select::make('lemonsqueezy_mode')
|
||||
->label(__('Lemon Squeezy mode'))
|
||||
->options([
|
||||
'standard' => __('Standard'),
|
||||
'custom' => __('Custom (one-off)'),
|
||||
])
|
||||
->default('standard'),
|
||||
Placeholder::make('paddle_discount_id')
|
||||
->label(__('Paddle Discount ID'))
|
||||
->content(fn ($record) => $record?->paddle_discount_id ?? '—'),
|
||||
Placeholder::make('paddle_last_synced_at')
|
||||
Placeholder::make('lemonsqueezy_discount_id')
|
||||
->label(__('Lemon Squeezy Discount ID'))
|
||||
->content(fn ($record) => $record?->lemonsqueezy_discount_id ?? '—'),
|
||||
Placeholder::make('lemonsqueezy_last_synced_at')
|
||||
->label(__('Last synced'))
|
||||
->content(fn ($record) => $record?->paddle_last_synced_at?->diffForHumans() ?? '—'),
|
||||
->content(fn ($record) => $record?->lemonsqueezy_last_synced_at?->diffForHumans() ?? '—'),
|
||||
Placeholder::make('redemptions_count')
|
||||
->label(__('Total redemptions'))
|
||||
->content(fn ($record) => number_format($record?->redemptions_count ?? 0)),
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
namespace App\Filament\Resources\Coupons\Schemas;
|
||||
|
||||
use App\Enums\CouponStatus;
|
||||
use App\Enums\CouponType;
|
||||
use Filament\Infolists\Components\KeyValueEntry;
|
||||
use Filament\Infolists\Components\Section;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
@@ -22,11 +24,11 @@ class CouponInfolist
|
||||
TextEntry::make('status')
|
||||
->label(__('Status'))
|
||||
->badge()
|
||||
->formatStateUsing(fn ($state) => Str::headline($state)),
|
||||
->formatStateUsing(fn ($state) => static::formatEnumState($state)),
|
||||
TextEntry::make('type')
|
||||
->label(__('Discount type'))
|
||||
->badge()
|
||||
->formatStateUsing(fn ($state) => Str::headline($state)),
|
||||
->formatStateUsing(fn ($state) => static::formatEnumState($state)),
|
||||
TextEntry::make('amount')
|
||||
->label(__('Amount'))
|
||||
->formatStateUsing(fn ($state, $record) => $record?->type?->value === 'percentage'
|
||||
@@ -61,21 +63,38 @@ class CouponInfolist
|
||||
TextEntry::make('description')->label(__('Description'))->columnSpanFull(),
|
||||
KeyValueEntry::make('metadata')->label(__('Metadata'))->columnSpanFull(),
|
||||
]),
|
||||
Section::make(__('Paddle'))
|
||||
Section::make(__('Lemon Squeezy'))
|
||||
->columns(3)
|
||||
->schema([
|
||||
TextEntry::make('paddle_discount_id')
|
||||
TextEntry::make('lemonsqueezy_discount_id')
|
||||
->label(__('Discount ID'))
|
||||
->copyable()
|
||||
->placeholder('—'),
|
||||
TextEntry::make('paddle_last_synced_at')
|
||||
TextEntry::make('lemonsqueezy_last_synced_at')
|
||||
->label(__('Last synced'))
|
||||
->state(fn ($record) => $record?->paddle_last_synced_at?->diffForHumans() ?? '—'),
|
||||
TextEntry::make('paddle_mode')
|
||||
->state(fn ($record) => $record?->lemonsqueezy_last_synced_at?->diffForHumans() ?? '—'),
|
||||
TextEntry::make('lemonsqueezy_mode')
|
||||
->label(__('Mode'))
|
||||
->badge()
|
||||
->placeholder('standard'),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function formatEnumState(mixed $state): string
|
||||
{
|
||||
if ($state instanceof CouponType || $state instanceof CouponStatus) {
|
||||
return $state->label();
|
||||
}
|
||||
|
||||
if ($state instanceof \BackedEnum) {
|
||||
return Str::headline($state->value);
|
||||
}
|
||||
|
||||
if (is_string($state)) {
|
||||
return Str::headline($state);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ namespace App\Filament\Resources\Coupons\Tables;
|
||||
|
||||
use App\Enums\CouponStatus;
|
||||
use App\Enums\CouponType;
|
||||
use App\Jobs\SyncCouponToPaddle;
|
||||
use App\Jobs\SyncCouponToLemonSqueezy;
|
||||
use App\Services\Audit\SuperAdminAuditLogger;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
@@ -40,7 +40,7 @@ class CouponsTable
|
||||
TextColumn::make('type')
|
||||
->label(__('Type'))
|
||||
->badge()
|
||||
->formatStateUsing(fn ($state) => Str::headline($state))
|
||||
->formatStateUsing(fn ($state) => static::formatEnumState($state))
|
||||
->sortable(),
|
||||
TextColumn::make('amount')
|
||||
->label(__('Amount'))
|
||||
@@ -59,7 +59,7 @@ class CouponsTable
|
||||
->label(__('Status'))
|
||||
->badge()
|
||||
->sortable()
|
||||
->formatStateUsing(fn ($state) => Str::headline($state)),
|
||||
->formatStateUsing(fn ($state) => static::formatEnumState($state)),
|
||||
TextColumn::make('starts_at')
|
||||
->label(__('Starts'))
|
||||
->date()
|
||||
@@ -105,9 +105,9 @@ class CouponsTable
|
||||
static::class
|
||||
)),
|
||||
Action::make('sync')
|
||||
->label(__('Sync to Paddle'))
|
||||
->label(__('Sync to Lemon Squeezy'))
|
||||
->icon('heroicon-m-arrow-path')
|
||||
->action(fn ($record) => SyncCouponToPaddle::dispatch($record))
|
||||
->action(fn ($record) => SyncCouponToLemonSqueezy::dispatch($record))
|
||||
->requiresConfirmation(),
|
||||
])
|
||||
->toolbarActions([
|
||||
@@ -151,4 +151,21 @@ class CouponsTable
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function formatEnumState(mixed $state): string
|
||||
{
|
||||
if ($state instanceof CouponType || $state instanceof CouponStatus) {
|
||||
return $state->label();
|
||||
}
|
||||
|
||||
if ($state instanceof \BackedEnum) {
|
||||
return Str::headline($state->value);
|
||||
}
|
||||
|
||||
if (is_string($state)) {
|
||||
return Str::headline($state);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
78
app/Filament/Resources/DataExportResource.php
Normal file
78
app/Filament/Resources/DataExportResource.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Clusters\RareAdmin\RareAdminCluster;
|
||||
use App\Filament\Resources\DataExportResource\Pages\CreateDataExport;
|
||||
use App\Filament\Resources\DataExportResource\Pages\ListDataExports;
|
||||
use App\Filament\Resources\DataExportResource\Schemas\DataExportForm;
|
||||
use App\Filament\Resources\DataExportResource\Tables\DataExportTable;
|
||||
use App\Models\DataExport;
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use UnitEnum;
|
||||
|
||||
class DataExportResource extends Resource
|
||||
{
|
||||
protected static ?string $model = DataExport::class;
|
||||
|
||||
protected static ?string $cluster = RareAdminCluster::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedArrowDownTray;
|
||||
|
||||
protected static UnitEnum|string|null $navigationGroup = null;
|
||||
|
||||
protected static ?int $navigationSort = 50;
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return DataExportForm::configure($schema);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return DataExportTable::configure($table);
|
||||
}
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return __('admin.data_exports.navigation.label');
|
||||
}
|
||||
|
||||
public static function getNavigationGroup(): UnitEnum|string|null
|
||||
{
|
||||
return __('admin.nav.platform');
|
||||
}
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
return parent::getEloquentQuery()->with(['tenant', 'event', 'user']);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListDataExports::route('/'),
|
||||
'create' => CreateDataExport::route('/create'),
|
||||
];
|
||||
}
|
||||
|
||||
public static function canEdit($record): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function canDelete($record): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function canDeleteAny(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\DataExportResource\Pages;
|
||||
|
||||
use App\Enums\DataExportScope;
|
||||
use App\Filament\Resources\DataExportResource;
|
||||
use App\Filament\Resources\Pages\AuditedCreateRecord;
|
||||
use App\Jobs\GenerateDataExport;
|
||||
use App\Models\DataExport;
|
||||
use Filament\Facades\Filament;
|
||||
|
||||
class CreateDataExport extends AuditedCreateRecord
|
||||
{
|
||||
protected static string $resource = DataExportResource::class;
|
||||
|
||||
protected function mutateFormDataBeforeCreate(array $data): array
|
||||
{
|
||||
$data['user_id'] = Filament::auth()->id();
|
||||
$data['status'] = DataExport::STATUS_PENDING;
|
||||
|
||||
if (($data['scope'] ?? null) !== DataExportScope::EVENT->value) {
|
||||
$data['event_id'] = null;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
protected function afterCreate(): void
|
||||
{
|
||||
parent::afterCreate();
|
||||
|
||||
GenerateDataExport::dispatch($this->record->id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\DataExportResource\Pages;
|
||||
|
||||
use App\Filament\Resources\DataExportResource;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListDataExports extends ListRecords
|
||||
{
|
||||
protected static string $resource = DataExportResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make()
|
||||
->label(__('admin.data_exports.actions.request')),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\DataExportResource\Schemas;
|
||||
|
||||
use App\Enums\DataExportScope;
|
||||
use App\Models\Event;
|
||||
use App\Models\Tenant;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Forms\Get;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
class DataExportForm
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema->schema([
|
||||
Section::make(__('admin.data_exports.sections.request'))
|
||||
->schema([
|
||||
Select::make('scope')
|
||||
->label(__('admin.data_exports.fields.scope'))
|
||||
->options([
|
||||
DataExportScope::TENANT->value => __('admin.data_exports.scope.tenant'),
|
||||
DataExportScope::EVENT->value => __('admin.data_exports.scope.event'),
|
||||
])
|
||||
->default(DataExportScope::TENANT->value)
|
||||
->live()
|
||||
->required(),
|
||||
Select::make('tenant_id')
|
||||
->label(__('admin.data_exports.fields.tenant'))
|
||||
->options(Tenant::query()->orderBy('name')->pluck('name', 'id'))
|
||||
->searchable()
|
||||
->preload()
|
||||
->required()
|
||||
->live(),
|
||||
Select::make('event_id')
|
||||
->label(__('admin.data_exports.fields.event'))
|
||||
->options(function (Get $get): array {
|
||||
$tenantId = $get('tenant_id');
|
||||
if (! $tenantId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Event::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->orderByDesc('date')
|
||||
->get()
|
||||
->mapWithKeys(function (Event $event): array {
|
||||
$name = $event->name['de'] ?? $event->name['en'] ?? $event->slug;
|
||||
|
||||
return [$event->id => $name];
|
||||
})
|
||||
->all();
|
||||
})
|
||||
->searchable()
|
||||
->preload()
|
||||
->visible(fn (Get $get): bool => $get('scope') === DataExportScope::EVENT->value)
|
||||
->required(fn (Get $get): bool => $get('scope') === DataExportScope::EVENT->value)
|
||||
->dehydrated(fn (Get $get): bool => $get('scope') === DataExportScope::EVENT->value),
|
||||
Toggle::make('include_media')
|
||||
->label(__('admin.data_exports.fields.include_media'))
|
||||
->helperText(__('admin.data_exports.help.include_media')),
|
||||
])
|
||||
->columns(2),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\DataExportResource\Tables;
|
||||
|
||||
use App\Enums\DataExportScope;
|
||||
use App\Jobs\GenerateDataExport;
|
||||
use App\Models\DataExport;
|
||||
use App\Services\Audit\SuperAdminAuditLogger;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Support\Number;
|
||||
|
||||
class DataExportTable
|
||||
{
|
||||
public static function formatScope(DataExportScope|string|null $state): string
|
||||
{
|
||||
if ($state instanceof DataExportScope) {
|
||||
$state = $state->value;
|
||||
}
|
||||
|
||||
return $state ? __('admin.data_exports.scope.'.$state) : '—';
|
||||
}
|
||||
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('id')
|
||||
->label(__('admin.data_exports.fields.id'))
|
||||
->sortable(),
|
||||
TextColumn::make('tenant.name')
|
||||
->label(__('admin.data_exports.fields.tenant'))
|
||||
->searchable(),
|
||||
TextColumn::make('event.slug')
|
||||
->label(__('admin.data_exports.fields.event'))
|
||||
->toggleable()
|
||||
->placeholder('—'),
|
||||
TextColumn::make('scope')
|
||||
->label(__('admin.data_exports.fields.scope'))
|
||||
->badge()
|
||||
->formatStateUsing(fn (DataExportScope|string|null $state): string => self::formatScope($state)),
|
||||
TextColumn::make('status')
|
||||
->label(__('admin.data_exports.fields.status'))
|
||||
->badge()
|
||||
->formatStateUsing(fn (string $state) => __('admin.data_exports.status.'.$state))
|
||||
->color(fn (string $state) => match ($state) {
|
||||
DataExport::STATUS_READY => 'success',
|
||||
DataExport::STATUS_FAILED => 'danger',
|
||||
DataExport::STATUS_PROCESSING => 'warning',
|
||||
DataExport::STATUS_CANCELED => 'gray',
|
||||
default => 'gray',
|
||||
}),
|
||||
IconColumn::make('include_media')
|
||||
->label(__('admin.data_exports.fields.include_media'))
|
||||
->boolean(),
|
||||
TextColumn::make('size_bytes')
|
||||
->label(__('admin.data_exports.fields.size'))
|
||||
->formatStateUsing(fn (?int $state) => $state ? Number::fileSize($state) : '—')
|
||||
->toggleable(),
|
||||
TextColumn::make('created_at')
|
||||
->label(__('admin.data_exports.fields.created_at'))
|
||||
->since()
|
||||
->sortable(),
|
||||
TextColumn::make('expires_at')
|
||||
->label(__('admin.data_exports.fields.expires_at'))
|
||||
->since()
|
||||
->toggleable(),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('scope')
|
||||
->label(__('admin.data_exports.fields.scope'))
|
||||
->options([
|
||||
'tenant' => __('admin.data_exports.scope.tenant'),
|
||||
'event' => __('admin.data_exports.scope.event'),
|
||||
'user' => __('admin.data_exports.scope.user'),
|
||||
]),
|
||||
SelectFilter::make('status')
|
||||
->label(__('admin.data_exports.fields.status'))
|
||||
->options([
|
||||
DataExport::STATUS_PENDING => __('admin.data_exports.status.pending'),
|
||||
DataExport::STATUS_PROCESSING => __('admin.data_exports.status.processing'),
|
||||
DataExport::STATUS_READY => __('admin.data_exports.status.ready'),
|
||||
DataExport::STATUS_FAILED => __('admin.data_exports.status.failed'),
|
||||
DataExport::STATUS_CANCELED => __('admin.data_exports.status.canceled'),
|
||||
]),
|
||||
])
|
||||
->actions([
|
||||
Action::make('download')
|
||||
->label(__('admin.data_exports.actions.download'))
|
||||
->icon('heroicon-o-arrow-down-tray')
|
||||
->url(fn (DataExport $record) => route('superadmin.data-exports.download', $record))
|
||||
->openUrlInNewTab()
|
||||
->visible(fn (DataExport $record): bool => $record->isReady() && ! $record->hasExpired()),
|
||||
Action::make('retry')
|
||||
->label(__('admin.data_exports.actions.retry'))
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('warning')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (DataExport $record): bool => $record->canRetry())
|
||||
->action(function (DataExport $record): void {
|
||||
if (! $record->canRetry()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$record->resetForRetry();
|
||||
GenerateDataExport::dispatch($record->id);
|
||||
|
||||
app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||
'updated',
|
||||
$record,
|
||||
source: self::class
|
||||
);
|
||||
}),
|
||||
Action::make('cancel')
|
||||
->label(__('admin.data_exports.actions.cancel'))
|
||||
->icon('heroicon-o-x-circle')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (DataExport $record): bool => $record->canCancel())
|
||||
->action(function (DataExport $record): void {
|
||||
if (! $record->canCancel()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$record->markCanceled();
|
||||
|
||||
app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||
'updated',
|
||||
$record,
|
||||
source: self::class
|
||||
);
|
||||
}),
|
||||
])
|
||||
->bulkActions([]);
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,9 @@ use Illuminate\Support\Facades\Storage;
|
||||
class ImportEmotions extends Page
|
||||
{
|
||||
protected static string $resource = EmotionResource::class;
|
||||
|
||||
protected string $view = 'filament.resources.emotion-resource.pages.import-emotions';
|
||||
|
||||
protected ?string $heading = null;
|
||||
|
||||
public ?string $file = null;
|
||||
@@ -36,6 +38,7 @@ class ImportEmotions extends Page
|
||||
$path = $this->form->getState()['file'] ?? null;
|
||||
if (! $path || ! Storage::disk('public')->exists($path)) {
|
||||
Notification::make()->danger()->title(__('admin.notifications.file_not_found'))->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,4 +16,4 @@ class ListEventPurchases extends ListRecords
|
||||
Actions\CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,24 +6,29 @@ use App\Filament\Clusters\DailyOps\DailyOpsCluster;
|
||||
use App\Filament\Resources\EventResource\Pages;
|
||||
use App\Filament\Resources\EventResource\RelationManagers\EventPackagesRelationManager;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventJoinToken;
|
||||
use App\Models\EventJoinTokenEvent;
|
||||
use App\Models\EventType;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Audit\SuperAdminAuditLogger;
|
||||
use App\Services\EventJoinTokenService;
|
||||
use App\Support\JoinTokenLayoutRegistry;
|
||||
use BackedEnum;
|
||||
use Carbon\Carbon;
|
||||
use Filament\Actions;
|
||||
use Filament\Forms\Components\DatePicker;
|
||||
use Filament\Forms\Components\DateTimePicker;
|
||||
use Filament\Forms\Components\KeyValue;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Arr;
|
||||
use UnitEnum;
|
||||
|
||||
class EventResource extends Resource
|
||||
@@ -60,19 +65,32 @@ class EventResource extends Resource
|
||||
->required()
|
||||
->unique(ignoreRecord: true)
|
||||
->maxLength(255),
|
||||
TextInput::make('join_link_display')
|
||||
->label(__('admin.events.fields.join_link'))
|
||||
->afterStateHydrated(function (TextInput $component, ?Event $record) {
|
||||
if (! $record) {
|
||||
return;
|
||||
}
|
||||
$token = $record->joinTokens()->latest()->first();
|
||||
$component->state($token ? url('/e/'.$token->token) : '-');
|
||||
})
|
||||
->readOnly()
|
||||
->dehydrated(false)
|
||||
->visibleOn('edit'),
|
||||
DatePicker::make('date')
|
||||
->label(__('admin.events.fields.date'))
|
||||
->required(),
|
||||
Select::make('event_type_id')
|
||||
->label(__('admin.events.fields.type'))
|
||||
->options(EventType::query()->pluck('name', 'id'))
|
||||
->options(fn () => EventType::all()->pluck('name.de', 'id'))
|
||||
->searchable(),
|
||||
Select::make('package_id')
|
||||
->label(__('admin.events.fields.package'))
|
||||
->options(\App\Models\Package::query()->where('type', 'endcustomer')->pluck('name', 'id'))
|
||||
->searchable()
|
||||
->preload()
|
||||
->required(),
|
||||
->required()
|
||||
->visibleOn('create'),
|
||||
TextInput::make('default_locale')
|
||||
->label(__('admin.events.fields.default_locale'))
|
||||
->default('de')
|
||||
@@ -80,6 +98,10 @@ class EventResource extends Resource
|
||||
Toggle::make('is_active')
|
||||
->label(__('admin.events.fields.is_active'))
|
||||
->default(true),
|
||||
Toggle::make('settings.marketing_demo')
|
||||
->label(__('admin.events.fields.marketing_demo'))
|
||||
->helperText(__('admin.events.fields.marketing_demo_help'))
|
||||
->default(false),
|
||||
KeyValue::make('settings')
|
||||
->label(__('admin.events.fields.settings'))
|
||||
->keyLabel(__('admin.common.key'))
|
||||
@@ -96,13 +118,13 @@ 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(),
|
||||
Tables\Columns\IconColumn::make('is_active')->boolean(),
|
||||
Tables\Columns\TextColumn::make('default_locale'),
|
||||
Tables\Columns\TextColumn::make('eventPackage.package.name')
|
||||
->label(__('admin.events.table.package'))
|
||||
->badge()
|
||||
@@ -115,22 +137,6 @@ class EventResource extends Resource
|
||||
->badge()
|
||||
->color(fn ($state) => $state < 1 ? 'danger' : 'success')
|
||||
->getStateUsing(fn ($record) => $record->eventPackage?->remaining_photos ?? 0),
|
||||
Tables\Columns\TextColumn::make('primary_join_token')
|
||||
->label(__('admin.events.table.join'))
|
||||
->getStateUsing(function ($record) {
|
||||
$token = $record->joinTokens()->latest()->first();
|
||||
|
||||
return $token ? url('/e/'.$token->token) : __('admin.events.table.no_join_tokens');
|
||||
})
|
||||
->description(function ($record) {
|
||||
$total = $record->joinTokens()->count();
|
||||
|
||||
return $total > 0
|
||||
? __('admin.events.table.join_tokens_total', ['count' => $total])
|
||||
: __('admin.events.table.join_tokens_missing');
|
||||
})
|
||||
->copyable()
|
||||
->copyMessage(__('admin.events.messages.join_link_copied')),
|
||||
Tables\Columns\TextColumn::make('created_at')->since(),
|
||||
])
|
||||
->filters([])
|
||||
@@ -167,7 +173,161 @@ class EventResource extends Resource
|
||||
->modalHeading(__('admin.events.modal.join_link_heading'))
|
||||
->modalSubmitActionLabel(__('admin.common.close'))
|
||||
->modalWidth('xl')
|
||||
->modalContent(function ($record) {
|
||||
->registerModalActions([
|
||||
Actions\Action::make('extend_join_token_expiry')
|
||||
->label(__('admin.events.join_link.extend_expiry'))
|
||||
->icon('heroicon-o-clock')
|
||||
->color('warning')
|
||||
->size('xs')
|
||||
->modalHeading(function (Actions\Action $action, Event $record): string {
|
||||
$token = static::resolveJoinTokenFromAction($record, $action);
|
||||
|
||||
return $token
|
||||
? __('admin.events.join_link.extend_expiry_heading', [
|
||||
'label' => $token->label ?: __('admin.events.join_link.token_default', ['id' => $token->id]),
|
||||
])
|
||||
: __('admin.events.join_link.extend_expiry_heading_fallback');
|
||||
})
|
||||
->schema(function (Event $record): array {
|
||||
$minimumExpiry = app(EventJoinTokenService::class)->minimumExpiryForEvent($record);
|
||||
$rules = [
|
||||
'date',
|
||||
'after:now',
|
||||
];
|
||||
|
||||
if ($minimumExpiry) {
|
||||
$rules[] = 'after_or_equal:'.$minimumExpiry->toDateTimeString();
|
||||
}
|
||||
|
||||
return [
|
||||
DateTimePicker::make('expires_at')
|
||||
->label(__('admin.events.join_link.extend_expiry_label'))
|
||||
->required()
|
||||
->seconds(false)
|
||||
->rules($rules)
|
||||
->helperText($minimumExpiry
|
||||
? __('admin.events.join_link.extend_expiry_min', [
|
||||
'date' => $minimumExpiry->isoFormat('LLL'),
|
||||
])
|
||||
: null),
|
||||
];
|
||||
})
|
||||
->fillForm(function (Actions\Action $action, Event $record): array {
|
||||
$token = static::resolveJoinTokenFromAction($record, $action);
|
||||
|
||||
if (! $token) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
'expires_at' => $token->expires_at,
|
||||
];
|
||||
})
|
||||
->action(function (array $data, Actions\Action $action, Event $record): void {
|
||||
$token = static::resolveJoinTokenFromAction($record, $action);
|
||||
|
||||
if (! $token) {
|
||||
Notification::make()
|
||||
->title(__('admin.events.join_link.extend_expiry_missing'))
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$expiresAt = $data['expires_at'] ?? null;
|
||||
|
||||
if (! $expiresAt) {
|
||||
Notification::make()
|
||||
->title(__('admin.events.join_link.extend_expiry_missing_date'))
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$resolvedExpiry = $expiresAt instanceof Carbon
|
||||
? $expiresAt
|
||||
: Carbon::parse($expiresAt);
|
||||
|
||||
$token->forceFill([
|
||||
'expires_at' => $resolvedExpiry,
|
||||
])->save();
|
||||
|
||||
app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||
'updated',
|
||||
$token,
|
||||
source: static::class
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->title(__('admin.events.join_link.extend_expiry_success'))
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
Actions\Action::make('set_demo_read_only')
|
||||
->label(__('admin.events.join_link.demo_read_only_action'))
|
||||
->icon('heroicon-o-lock-closed')
|
||||
->color('gray')
|
||||
->size('xs')
|
||||
->modalHeading(function (Actions\Action $action, Event $record): string {
|
||||
$token = static::resolveJoinTokenFromAction($record, $action);
|
||||
|
||||
return $token
|
||||
? __('admin.events.join_link.demo_read_only_heading', [
|
||||
'label' => $token->label ?: __('admin.events.join_link.token_default', ['id' => $token->id]),
|
||||
])
|
||||
: __('admin.events.join_link.demo_read_only_heading_fallback');
|
||||
})
|
||||
->schema([
|
||||
Toggle::make('demo_read_only')
|
||||
->label(__('admin.events.join_link.demo_read_only_label'))
|
||||
->helperText(__('admin.events.join_link.demo_read_only_help')),
|
||||
])
|
||||
->fillForm(function (Actions\Action $action, Event $record): array {
|
||||
$token = static::resolveJoinTokenFromAction($record, $action);
|
||||
|
||||
return [
|
||||
'demo_read_only' => (bool) Arr::get($token?->metadata ?? [], 'demo_read_only', false),
|
||||
];
|
||||
})
|
||||
->action(function (array $data, Actions\Action $action, Event $record): void {
|
||||
$token = static::resolveJoinTokenFromAction($record, $action);
|
||||
|
||||
if (! $token) {
|
||||
Notification::make()
|
||||
->title(__('admin.events.join_link.demo_read_only_missing'))
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$metadata = is_array($token->metadata) ? $token->metadata : [];
|
||||
$enabled = (bool) ($data['demo_read_only'] ?? false);
|
||||
|
||||
if ($enabled) {
|
||||
$metadata['demo_read_only'] = true;
|
||||
} else {
|
||||
unset($metadata['demo_read_only']);
|
||||
}
|
||||
|
||||
$token->metadata = empty($metadata) ? null : $metadata;
|
||||
$token->save();
|
||||
|
||||
app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||
'updated',
|
||||
$token,
|
||||
source: static::class
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->title(__('admin.events.join_link.demo_read_only_success'))
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
])
|
||||
->modalContent(function (Actions\Action $action, $record) {
|
||||
$tokens = $record->joinTokens()
|
||||
->orderByDesc('created_at')
|
||||
->get();
|
||||
@@ -237,6 +397,7 @@ class EventResource extends Resource
|
||||
'expires_at' => optional($token->expires_at)->toIso8601String(),
|
||||
'revoked_at' => optional($token->revoked_at)->toIso8601String(),
|
||||
'is_active' => $token->isActive(),
|
||||
'demo_read_only' => (bool) Arr::get($token->metadata ?? [], 'demo_read_only', false),
|
||||
'created_at' => optional($token->created_at)->toIso8601String(),
|
||||
'layouts' => $layouts,
|
||||
'layouts_url' => route('api.v1.tenant.events.join-tokens.layouts.index', [
|
||||
@@ -256,6 +417,7 @@ class EventResource extends Resource
|
||||
return view('filament.events.join-link', [
|
||||
'event' => $record,
|
||||
'tokens' => $tokens,
|
||||
'action' => $action,
|
||||
]);
|
||||
}),
|
||||
])
|
||||
@@ -282,6 +444,43 @@ 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 : '';
|
||||
}
|
||||
|
||||
private static function resolveJoinTokenFromAction(Event $record, Actions\Action $action): ?EventJoinToken
|
||||
{
|
||||
$tokenId = $action->getArguments()['token_id'] ?? null;
|
||||
|
||||
if (! $tokenId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $record->joinTokens()
|
||||
->whereKey($tokenId)
|
||||
->first();
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
|
||||
@@ -8,4 +8,25 @@ use App\Filament\Resources\Pages\AuditedCreateRecord;
|
||||
class CreateEvent extends AuditedCreateRecord
|
||||
{
|
||||
protected static string $resource = EventResource::class;
|
||||
|
||||
public ?int $packageId = null;
|
||||
|
||||
protected function mutateFormDataBeforeCreate(array $data): array
|
||||
{
|
||||
$this->packageId = $data['package_id'] ?? null;
|
||||
unset($data['package_id']);
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
protected function afterCreate(): void
|
||||
{
|
||||
if ($this->packageId) {
|
||||
$this->record->eventPackages()->create([
|
||||
'package_id' => $this->packageId,
|
||||
]);
|
||||
}
|
||||
|
||||
parent::afterCreate();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
|
||||
class EventPackagesRelationManager extends RelationManager
|
||||
{
|
||||
@@ -59,6 +58,7 @@ class EventPackagesRelationManager extends RelationManager
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->modifyQueryUsing(fn (Builder $query) => $query->with('package'))
|
||||
->recordTitleAttribute('package.name')
|
||||
->columns([
|
||||
TextColumn::make('package.name')
|
||||
@@ -147,9 +147,4 @@ class EventPackagesRelationManager extends RelationManager
|
||||
{
|
||||
return __('admin.events.relation_managers.event_packages.title');
|
||||
}
|
||||
|
||||
public function getTableQuery(): Builder|Relation
|
||||
{
|
||||
return parent::getTableQuery()->with('package');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,18 +113,64 @@ class EventTypeResource extends Resource
|
||||
SuperAdminAuditLogger::fieldsMetadata($data),
|
||||
static::class
|
||||
)),
|
||||
Actions\DeleteAction::make()
|
||||
->action(function (EventType $record, Actions\DeleteAction $action) {
|
||||
try {
|
||||
$record->delete();
|
||||
} catch (\Exception $e) {
|
||||
$isConstraint = ($e instanceof \Illuminate\Database\QueryException && ($e->getCode() == 23000 || ($e->errorInfo[0] ?? '') == 23000));
|
||||
|
||||
if ($isConstraint) {
|
||||
\Filament\Notifications\Notification::make()
|
||||
->title(__('admin.common.error'))
|
||||
->body(__('admin.event_types.messages.delete_constraint_error'))
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
$action->halt();
|
||||
}
|
||||
|
||||
throw $e;
|
||||
}
|
||||
})
|
||||
->after(fn (EventType $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||
'deleted',
|
||||
$record,
|
||||
source: static::class
|
||||
)),
|
||||
])
|
||||
->bulkActions([
|
||||
Actions\DeleteBulkAction::make()
|
||||
->after(function (Collection $records): void {
|
||||
->action(function (Collection $records, Actions\DeleteBulkAction $action) {
|
||||
$logger = app(SuperAdminAuditLogger::class);
|
||||
$deletedCount = 0;
|
||||
$failedCount = 0;
|
||||
|
||||
foreach ($records as $record) {
|
||||
$logger->recordModelMutation(
|
||||
'deleted',
|
||||
$record,
|
||||
source: static::class
|
||||
);
|
||||
try {
|
||||
$record->delete();
|
||||
$logger->recordModelMutation('deleted', $record, source: static::class);
|
||||
$deletedCount++;
|
||||
} catch (\Exception $e) {
|
||||
$isConstraint = ($e instanceof \Illuminate\Database\QueryException && ($e->getCode() == 23000 || ($e->errorInfo[0] ?? '') == 23000));
|
||||
if ($isConstraint) {
|
||||
$failedCount++;
|
||||
} else {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($failedCount > 0) {
|
||||
\Filament\Notifications\Notification::make()
|
||||
->title(__('admin.common.error'))
|
||||
->body(__('admin.event_types.messages.delete_constraint_error')." ($failedCount failed, $deletedCount deleted)")
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
if ($deletedCount === 0) {
|
||||
$action->halt();
|
||||
}
|
||||
}
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -63,8 +63,8 @@ class GiftVoucherResource extends Resource
|
||||
->label('Empfänger')
|
||||
->toggleable()
|
||||
->searchable(),
|
||||
TextColumn::make('paddle_transaction_id')
|
||||
->label('Paddle Tx')
|
||||
TextColumn::make('lemonsqueezy_order_id')
|
||||
->label('Lemon Squeezy Order')
|
||||
->toggleable()
|
||||
->copyable()
|
||||
->wrap(),
|
||||
|
||||
@@ -46,24 +46,27 @@ class ListGiftVouchers extends ListRecords
|
||||
])
|
||||
->action(function (array $data, GiftVoucherService $service): void {
|
||||
$payload = [
|
||||
'id' => null,
|
||||
'metadata' => [
|
||||
'type' => 'gift_voucher',
|
||||
'purchaser_email' => $data['purchaser_email'],
|
||||
'recipient_email' => $data['recipient_email'] ?? null,
|
||||
'recipient_name' => $data['recipient_name'] ?? null,
|
||||
'message' => $data['message'] ?? null,
|
||||
'gift_code' => $data['code'] ?? null,
|
||||
'meta' => [
|
||||
'custom_data' => [
|
||||
'type' => 'gift_voucher',
|
||||
'purchaser_email' => $data['purchaser_email'],
|
||||
'recipient_email' => $data['recipient_email'] ?? null,
|
||||
'recipient_name' => $data['recipient_name'] ?? null,
|
||||
'message' => $data['message'] ?? null,
|
||||
'gift_code' => $data['code'] ?? null,
|
||||
],
|
||||
],
|
||||
'currency_code' => $data['currency'] ?? 'EUR',
|
||||
'totals' => [
|
||||
'grand_total' => [
|
||||
'amount' => (float) $data['amount'],
|
||||
'data' => [
|
||||
'id' => 'manual_'.Str::uuid(),
|
||||
'attributes' => [
|
||||
'currency' => $data['currency'] ?? 'EUR',
|
||||
'total' => (float) $data['amount'] * 100,
|
||||
'user_email' => $data['purchaser_email'],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$voucher = $service->issueFromPaddle($payload);
|
||||
$voucher = $service->issueFromLemonSqueezy($payload);
|
||||
|
||||
app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||
'issued',
|
||||
|
||||
@@ -17,4 +17,3 @@ class ListMediaStorageTargets extends ListRecords
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,15 +4,21 @@ namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Clusters\WeeklyOps\WeeklyOpsCluster;
|
||||
use App\Filament\Resources\PackageAddonResource\Pages;
|
||||
use App\Jobs\SyncPackageAddonToPaddle;
|
||||
use App\Jobs\SyncPackageAddonToLemonSqueezy;
|
||||
use App\Models\CheckoutSession;
|
||||
use App\Models\PackageAddon;
|
||||
use App\Services\Audit\SuperAdminAuditLogger;
|
||||
use Filament\Actions;
|
||||
use Filament\Forms\Components\DateTimePicker;
|
||||
use Filament\Forms\Components\Placeholder;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TagsInput;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Columns\BadgeColumn;
|
||||
@@ -50,10 +56,18 @@ class PackageAddonResource extends Resource
|
||||
->required()
|
||||
->unique(ignoreRecord: true)
|
||||
->maxLength(191),
|
||||
TextInput::make('price_id')
|
||||
->label('Paddle Preis-ID')
|
||||
->helperText('Paddle Billing Preis-ID für dieses Add-on')
|
||||
TextInput::make('variant_id')
|
||||
->label('Lemon Squeezy Variant-ID')
|
||||
->helperText('Variant-ID aus Lemon Squeezy für dieses Add-on')
|
||||
->required(fn (Get $get): bool => (bool) $get('active') && ! is_numeric($get('metadata.price_eur')))
|
||||
->maxLength(191),
|
||||
TextInput::make('metadata.price_eur')
|
||||
->label('PayPal Preis (EUR)')
|
||||
->helperText('Für PayPal-Checkout erforderlich (z. B. 9.90).')
|
||||
->numeric()
|
||||
->step(0.01)
|
||||
->minValue(0.01)
|
||||
->required(fn (Get $get): bool => (bool) $get('active') && blank($get('variant_id'))),
|
||||
TextInput::make('sort')
|
||||
->label('Sortierung')
|
||||
->numeric()
|
||||
@@ -61,6 +75,23 @@ class PackageAddonResource extends Resource
|
||||
Toggle::make('active')
|
||||
->label('Aktiv')
|
||||
->default(true),
|
||||
Placeholder::make('sellable_state')
|
||||
->label('Verfügbarkeits-Check')
|
||||
->content(function (Get $get): string {
|
||||
$isActive = (bool) $get('active');
|
||||
$hasVariant = filled($get('variant_id'));
|
||||
$hasPayPalPrice = is_numeric($get('metadata.price_eur'));
|
||||
|
||||
if (! $isActive) {
|
||||
return 'Inaktiv';
|
||||
}
|
||||
|
||||
if (! $hasVariant && ! $hasPayPalPrice) {
|
||||
return 'Nicht verkäuflich: Variant-ID oder PayPal Preis fehlt.';
|
||||
}
|
||||
|
||||
return 'Verkäuflich';
|
||||
}),
|
||||
]),
|
||||
Section::make('Limits-Inkremente')
|
||||
->columns(3)
|
||||
@@ -81,6 +112,30 @@ class PackageAddonResource extends Resource
|
||||
->minValue(0)
|
||||
->default(0),
|
||||
]),
|
||||
Section::make('Feature-Entitlements')
|
||||
->columns(2)
|
||||
->schema([
|
||||
Select::make('metadata.scope')
|
||||
->label('Scope')
|
||||
->options([
|
||||
'photos' => 'Fotos',
|
||||
'guests' => 'Gäste',
|
||||
'gallery' => 'Galerie',
|
||||
'feature' => 'Feature',
|
||||
'bundle' => 'Bundle',
|
||||
])
|
||||
->native(false)
|
||||
->searchable(),
|
||||
TagsInput::make('metadata.entitlements.features')
|
||||
->label('Freigeschaltete Features')
|
||||
->helperText('Feature-Keys für Freischaltungen, z. B. ai_styling')
|
||||
->placeholder('z. B. ai_styling')
|
||||
->columnSpanFull(),
|
||||
DateTimePicker::make('metadata.entitlements.expires_at')
|
||||
->label('Entitlement gültig bis')
|
||||
->seconds(false)
|
||||
->nullable(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -96,10 +151,33 @@ class PackageAddonResource extends Resource
|
||||
->label('Schlüssel')
|
||||
->copyable()
|
||||
->sortable(),
|
||||
TextColumn::make('price_id')
|
||||
->label('Paddle Preis-ID')
|
||||
TextColumn::make('variant_id')
|
||||
->label('Lemon Squeezy Variant-ID')
|
||||
->toggleable()
|
||||
->copyable(),
|
||||
TextColumn::make('metadata.price_eur')
|
||||
->label('PayPal Preis (EUR)')
|
||||
->formatStateUsing(fn (mixed $state): string => is_numeric($state) ? number_format((float) $state, 2, ',', '.').' €' : '—')
|
||||
->toggleable(),
|
||||
TextColumn::make('metadata.scope')
|
||||
->label('Scope')
|
||||
->badge()
|
||||
->toggleable(),
|
||||
TextColumn::make('metadata.entitlements.features')
|
||||
->label('Features')
|
||||
->formatStateUsing(function (mixed $state): string {
|
||||
if (! is_array($state)) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
$features = array_values(array_filter(array_map(
|
||||
static fn (mixed $feature): string => trim((string) $feature),
|
||||
$state,
|
||||
)));
|
||||
|
||||
return $features === [] ? '—' : implode(', ', $features);
|
||||
})
|
||||
->toggleable(),
|
||||
TextColumn::make('extra_photos')->label('Fotos +'),
|
||||
TextColumn::make('extra_guests')->label('Gäste +'),
|
||||
TextColumn::make('extra_gallery_days')->label('Galerietage +'),
|
||||
@@ -110,6 +188,14 @@ class PackageAddonResource extends Resource
|
||||
'danger' => false,
|
||||
])
|
||||
->formatStateUsing(fn (bool $state) => $state ? 'Aktiv' : 'Inaktiv'),
|
||||
BadgeColumn::make('sellability')
|
||||
->label('Checkout')
|
||||
->state(fn (PackageAddon $record): string => static::sellabilityLabel($record))
|
||||
->colors([
|
||||
'success' => fn (string $state): bool => $state === 'Verkäuflich',
|
||||
'warning' => fn (string $state): bool => $state === 'Unvollständig',
|
||||
'gray' => fn (string $state): bool => $state === 'Inaktiv',
|
||||
]),
|
||||
TextColumn::make('sort')
|
||||
->label('Sort')
|
||||
->sortable()
|
||||
@@ -120,16 +206,16 @@ class PackageAddonResource extends Resource
|
||||
->label('Aktiv'),
|
||||
])
|
||||
->actions([
|
||||
Actions\Action::make('syncPaddle')
|
||||
->label('Mit Paddle synchronisieren')
|
||||
Actions\Action::make('syncLemonSqueezy')
|
||||
->label('Mit Lemon Squeezy synchronisieren')
|
||||
->icon('heroicon-o-cloud-arrow-up')
|
||||
->action(function (PackageAddon $record) {
|
||||
SyncPackageAddonToPaddle::dispatch($record->id);
|
||||
SyncPackageAddonToLemonSqueezy::dispatch($record->id);
|
||||
|
||||
Notification::make()
|
||||
->success()
|
||||
->title('Paddle-Sync gestartet')
|
||||
->body('Das Add-on wird im Hintergrund mit Paddle abgeglichen.')
|
||||
->title('Lemon Squeezy-Sync gestartet')
|
||||
->body('Das Add-on wird im Hintergrund mit Lemon Squeezy abgeglichen.')
|
||||
->send();
|
||||
}),
|
||||
Actions\EditAction::make()
|
||||
@@ -166,4 +252,21 @@ class PackageAddonResource extends Resource
|
||||
'edit' => Pages\EditPackageAddon::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
|
||||
protected static function sellabilityLabel(PackageAddon $record): string
|
||||
{
|
||||
if (! $record->active) {
|
||||
return 'Inaktiv';
|
||||
}
|
||||
|
||||
return $record->isSellableForProvider(static::addonProvider()) ? 'Verkäuflich' : 'Unvollständig';
|
||||
}
|
||||
|
||||
protected static function addonProvider(): string
|
||||
{
|
||||
return (string) (
|
||||
config('package-addons.provider')
|
||||
?? config('checkout.default_provider', CheckoutSession::PROVIDER_PAYPAL)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,9 @@ namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Clusters\WeeklyOps\WeeklyOpsCluster;
|
||||
use App\Filament\Resources\PackageResource\Pages;
|
||||
use App\Jobs\PullPackageFromPaddle;
|
||||
use App\Jobs\SyncPackageToPaddle;
|
||||
use App\Models\Package;
|
||||
use App\Services\Audit\SuperAdminAuditLogger;
|
||||
use BackedEnum;
|
||||
use Filament\Actions;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
@@ -26,7 +23,6 @@ use Filament\Forms\Components\Repeater;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Components\Tabs as SchemaTabs;
|
||||
@@ -143,7 +139,7 @@ class PackageResource extends Resource
|
||||
->nullable()
|
||||
->visible(fn ($get) => $get('type') === 'reseller'),
|
||||
Toggle::make('watermark_allowed')
|
||||
->label('Wasserzeichen erlaubt')
|
||||
->label('Eigenes Wasserzeichen erlaubt')
|
||||
->default(true),
|
||||
Toggle::make('branding_allowed')
|
||||
->label('Eigenes Branding erlaubt')
|
||||
@@ -172,26 +168,31 @@ class PackageResource extends Resource
|
||||
->columnSpanFull()
|
||||
->default([]),
|
||||
]),
|
||||
Section::make('Paddle Billing')
|
||||
Section::make('Lemon Squeezy Billing')
|
||||
->columns(2)
|
||||
->schema([
|
||||
TextInput::make('paddle_product_id')
|
||||
->label('Paddle Produkt-ID')
|
||||
TextInput::make('lemonsqueezy_product_id')
|
||||
->label('Lemon Squeezy Produkt-ID')
|
||||
->maxLength(191)
|
||||
->helperText('Produkt aus Paddle Billing. Leer lassen, wenn noch nicht synchronisiert.')
|
||||
->helperText('Produkt aus Lemon Squeezy. Leer lassen, wenn noch nicht synchronisiert.')
|
||||
->placeholder('nicht verknüpft'),
|
||||
TextInput::make('paddle_price_id')
|
||||
->label('Paddle Preis-ID')
|
||||
TextInput::make('lemonsqueezy_variant_id')
|
||||
->label('Lemon Squeezy Variant-ID')
|
||||
->maxLength(191)
|
||||
->helperText('Preis-ID aus Paddle Billing, verknüpft mit diesem Paket.')
|
||||
->helperText('Variant-ID aus Lemon Squeezy, verknüpft mit diesem Paket.')
|
||||
->placeholder('nicht verknüpft'),
|
||||
Placeholder::make('paddle_sync_status')
|
||||
Placeholder::make('lemonsqueezy_sync_status')
|
||||
->label('Sync-Status')
|
||||
->content(fn (?Package $record) => $record?->paddle_sync_status ? Str::headline($record->paddle_sync_status) : '–')
|
||||
->content(fn (?Package $record) => $record?->lemonsqueezy_sync_status ? Str::headline($record->lemonsqueezy_sync_status) : '–')
|
||||
->columnSpanFull(),
|
||||
Placeholder::make('paddle_synced_at')
|
||||
Placeholder::make('lemonsqueezy_synced_at')
|
||||
->label('Zuletzt synchronisiert')
|
||||
->content(fn (?Package $record) => $record?->paddle_synced_at ? $record->paddle_synced_at->diffForHumans() : '–')
|
||||
->content(fn (?Package $record) => $record?->lemonsqueezy_synced_at ? $record->lemonsqueezy_synced_at->diffForHumans() : '–')
|
||||
->columnSpanFull(),
|
||||
Placeholder::make('lemonsqueezy_sync_error')
|
||||
->label('Letzter Fehler')
|
||||
->content(fn (?Package $record) => $record?->lemonsqueezy_sync_error_message ?? '–')
|
||||
->visible(fn (?Package $record) => filled($record?->lemonsqueezy_sync_error_message))
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
]);
|
||||
@@ -258,28 +259,33 @@ class PackageResource extends Resource
|
||||
->label('Features')
|
||||
->wrap()
|
||||
->formatStateUsing(fn ($state) => static::formatFeaturesForDisplay($state)),
|
||||
TextColumn::make('paddle_product_id')
|
||||
->label('Paddle Produkt')
|
||||
TextColumn::make('lemonsqueezy_product_id')
|
||||
->label('Lemon Squeezy Produkt')
|
||||
->toggleable(isToggledHiddenByDefault: true)
|
||||
->formatStateUsing(fn ($state) => $state ?: '-'),
|
||||
TextColumn::make('paddle_price_id')
|
||||
->label('Paddle Preis')
|
||||
TextColumn::make('lemonsqueezy_variant_id')
|
||||
->label('Lemon Squeezy Variant')
|
||||
->toggleable(isToggledHiddenByDefault: true)
|
||||
->formatStateUsing(fn ($state) => $state ?: '-'),
|
||||
BadgeColumn::make('paddle_sync_status')
|
||||
BadgeColumn::make('lemonsqueezy_sync_status')
|
||||
->label('Sync-Status')
|
||||
->colors([
|
||||
'success' => 'synced',
|
||||
'warning' => 'syncing',
|
||||
'info' => 'dry-run',
|
||||
'info' => ['dry-run', 'linked', 'pulled'],
|
||||
'danger' => ['failed', 'pull-failed'],
|
||||
])
|
||||
->formatStateUsing(fn ($state) => $state ? Str::headline($state) : null)
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('paddle_synced_at')
|
||||
TextColumn::make('lemonsqueezy_synced_at')
|
||||
->label('Sync am')
|
||||
->dateTime()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('lemonsqueezy_sync_error_message')
|
||||
->label('Sync-Fehler')
|
||||
->getStateUsing(fn (Package $record) => $record->lemonsqueezy_sync_error_message)
|
||||
->wrap()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\SelectFilter::make('type')
|
||||
@@ -291,35 +297,6 @@ class PackageResource extends Resource
|
||||
TrashedFilter::make(),
|
||||
])
|
||||
->actions([
|
||||
Actions\Action::make('syncPaddle')
|
||||
->label('Mit Paddle abgleichen')
|
||||
->icon('heroicon-o-cloud-arrow-up')
|
||||
->color('success')
|
||||
->requiresConfirmation()
|
||||
->disabled(fn (Package $record) => $record->paddle_sync_status === 'syncing')
|
||||
->action(function (Package $record) {
|
||||
SyncPackageToPaddle::dispatch($record->id);
|
||||
|
||||
Notification::make()
|
||||
->success()
|
||||
->title('Paddle-Sync gestartet')
|
||||
->body('Das Paket wird im Hintergrund mit Paddle abgeglichen.')
|
||||
->send();
|
||||
}),
|
||||
Actions\Action::make('pullPaddle')
|
||||
->label('Status von Paddle holen')
|
||||
->icon('heroicon-o-cloud-arrow-down')
|
||||
->disabled(fn (Package $record) => ! $record->paddle_product_id && ! $record->paddle_price_id)
|
||||
->requiresConfirmation()
|
||||
->action(function (Package $record) {
|
||||
PullPackageFromPaddle::dispatch($record->id);
|
||||
|
||||
Notification::make()
|
||||
->info()
|
||||
->title('Paddle-Abgleich angefordert')
|
||||
->body('Der aktuelle Stand aus Paddle wird geladen und hier hinterlegt.')
|
||||
->send();
|
||||
}),
|
||||
ViewAction::make(),
|
||||
EditAction::make()
|
||||
->after(fn (array $data, Package $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||
@@ -419,6 +396,7 @@ class PackageResource extends Resource
|
||||
'unlimited_sharing' => 'Unbegrenztes Teilen',
|
||||
'no_watermark' => 'Kein Wasserzeichen',
|
||||
'custom_branding' => 'Eigenes Branding',
|
||||
'ai_styling' => 'AI-Styling',
|
||||
'custom_tasks' => 'Eigene Aufgaben',
|
||||
'reseller_dashboard' => 'Reseller-Dashboard',
|
||||
'advanced_analytics' => 'Erweiterte Analytics',
|
||||
|
||||
@@ -16,4 +16,4 @@ class ListPackages extends ListRecords
|
||||
Actions\CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,4 +14,3 @@ class ListPurchaseHistories extends ListRecords
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,4 +14,3 @@ class ViewPurchaseHistory extends ViewRecord
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ use App\Models\PackagePurchase;
|
||||
use App\Notifications\Customer\RefundReceipt;
|
||||
use App\Notifications\Ops\RefundProcessed;
|
||||
use App\Services\Audit\SuperAdminAuditLogger;
|
||||
use App\Services\Paddle\PaddleTransactionService;
|
||||
use App\Services\LemonSqueezy\LemonSqueezyOrderService;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
@@ -203,15 +203,15 @@ class PurchaseResource extends Resource
|
||||
$refundSuccess = true;
|
||||
$errorMessage = null;
|
||||
|
||||
if ($record->provider === 'paddle' && $record->provider_id) {
|
||||
if ($record->provider === 'lemonsqueezy' && $record->provider_id) {
|
||||
try {
|
||||
/** @var PaddleTransactionService $paddle */
|
||||
$paddle = App::make(PaddleTransactionService::class);
|
||||
$paddle->refund($record->provider_id, ['reason' => $reason]);
|
||||
/** @var LemonSqueezyOrderService $lemonsqueezy */
|
||||
$lemonsqueezy = App::make(LemonSqueezyOrderService::class);
|
||||
$lemonsqueezy->refund($record->provider_id, ['reason' => $reason]);
|
||||
} catch (\Throwable $exception) {
|
||||
$refundSuccess = false;
|
||||
$errorMessage = $exception->getMessage();
|
||||
Log::warning('Paddle refund failed', [
|
||||
Log::warning('Lemon Squeezy refund failed', [
|
||||
'purchase_id' => $record->id,
|
||||
'provider_id' => $record->provider_id,
|
||||
'error' => $exception->getMessage(),
|
||||
|
||||
@@ -16,4 +16,4 @@ class ListPurchases extends ListRecords
|
||||
Actions\CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ class ViewPurchase extends ViewRecord
|
||||
->visible(fn ($record): bool => ! $record->refunded)
|
||||
->action(function ($record) {
|
||||
$record->update(['refunded' => true]);
|
||||
// TODO: Call Paddle API for actual refund
|
||||
// TODO: Call Lemon Squeezy API for actual refund
|
||||
|
||||
app(SuperAdminAuditLogger::class)->record(
|
||||
'purchase.refunded',
|
||||
|
||||
75
app/Filament/Resources/RetentionOverrideResource.php
Normal file
75
app/Filament/Resources/RetentionOverrideResource.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Clusters\RareAdmin\RareAdminCluster;
|
||||
use App\Filament\Resources\RetentionOverrideResource\Pages\CreateRetentionOverride;
|
||||
use App\Filament\Resources\RetentionOverrideResource\Pages\EditRetentionOverride;
|
||||
use App\Filament\Resources\RetentionOverrideResource\Pages\ListRetentionOverrides;
|
||||
use App\Filament\Resources\RetentionOverrideResource\Schemas\RetentionOverrideForm;
|
||||
use App\Filament\Resources\RetentionOverrideResource\Tables\RetentionOverrideTable;
|
||||
use App\Models\RetentionOverride;
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use UnitEnum;
|
||||
|
||||
class RetentionOverrideResource extends Resource
|
||||
{
|
||||
protected static ?string $model = RetentionOverride::class;
|
||||
|
||||
protected static ?string $cluster = RareAdminCluster::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedShieldExclamation;
|
||||
|
||||
protected static UnitEnum|string|null $navigationGroup = null;
|
||||
|
||||
protected static ?int $navigationSort = 55;
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return RetentionOverrideForm::configure($schema);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return RetentionOverrideTable::configure($table);
|
||||
}
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return __('admin.retention_overrides.navigation.label');
|
||||
}
|
||||
|
||||
public static function getNavigationGroup(): UnitEnum|string|null
|
||||
{
|
||||
return __('admin.nav.platform');
|
||||
}
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
return parent::getEloquentQuery()->with(['tenant', 'event', 'createdBy', 'releasedBy']);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListRetentionOverrides::route('/'),
|
||||
'create' => CreateRetentionOverride::route('/create'),
|
||||
'edit' => EditRetentionOverride::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
|
||||
public static function canDelete($record): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function canDeleteAny(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\RetentionOverrideResource\Pages;
|
||||
|
||||
use App\Enums\RetentionOverrideScope;
|
||||
use App\Filament\Resources\Pages\AuditedCreateRecord;
|
||||
use App\Filament\Resources\RetentionOverrideResource;
|
||||
use Filament\Facades\Filament;
|
||||
|
||||
class CreateRetentionOverride extends AuditedCreateRecord
|
||||
{
|
||||
protected static string $resource = RetentionOverrideResource::class;
|
||||
|
||||
protected function mutateFormDataBeforeCreate(array $data): array
|
||||
{
|
||||
$data['created_by_id'] = Filament::auth()->id();
|
||||
$data['released_at'] = null;
|
||||
$data['released_by_id'] = null;
|
||||
|
||||
if (($data['scope'] ?? null) !== RetentionOverrideScope::EVENT->value) {
|
||||
$data['event_id'] = null;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\RetentionOverrideResource\Pages;
|
||||
|
||||
use App\Filament\Resources\Pages\AuditedEditRecord;
|
||||
use App\Filament\Resources\RetentionOverrideResource;
|
||||
use App\Models\RetentionOverride;
|
||||
use App\Services\Audit\SuperAdminAuditLogger;
|
||||
use Filament\Actions;
|
||||
use Filament\Facades\Filament;
|
||||
|
||||
class EditRetentionOverride extends AuditedEditRecord
|
||||
{
|
||||
protected static string $resource = RetentionOverrideResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\Action::make('release')
|
||||
->label(__('admin.retention_overrides.actions.release'))
|
||||
->icon('heroicon-o-check-circle')
|
||||
->color('success')
|
||||
->requiresConfirmation()
|
||||
->visible(fn () => $this->record instanceof RetentionOverride && $this->record->released_at === null)
|
||||
->action(function (): void {
|
||||
if (! ($this->record instanceof RetentionOverride) || $this->record->released_at !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->record->forceFill([
|
||||
'released_at' => now(),
|
||||
'released_by_id' => Filament::auth()->id(),
|
||||
])->save();
|
||||
|
||||
app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||
'updated',
|
||||
$this->record,
|
||||
SuperAdminAuditLogger::fieldsMetadata(['released_at', 'released_by_id']),
|
||||
static::class
|
||||
);
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\RetentionOverrideResource\Pages;
|
||||
|
||||
use App\Filament\Resources\RetentionOverrideResource;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListRetentionOverrides extends ListRecords
|
||||
{
|
||||
protected static string $resource = RetentionOverrideResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make()
|
||||
->label(__('admin.retention_overrides.actions.request')),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\RetentionOverrideResource\Schemas;
|
||||
|
||||
use App\Enums\RetentionOverrideScope;
|
||||
use App\Models\Event;
|
||||
use App\Models\RetentionOverride;
|
||||
use App\Models\Tenant;
|
||||
use Filament\Forms\Components\Placeholder;
|
||||
use Filament\Forms\Components\Section;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Get;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
class RetentionOverrideForm
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema->components([
|
||||
Section::make(__('admin.retention_overrides.sections.override'))
|
||||
->schema([
|
||||
Select::make('scope')
|
||||
->label(__('admin.retention_overrides.fields.scope'))
|
||||
->options([
|
||||
RetentionOverrideScope::TENANT->value => __('admin.retention_overrides.scope.tenant'),
|
||||
RetentionOverrideScope::EVENT->value => __('admin.retention_overrides.scope.event'),
|
||||
])
|
||||
->default(RetentionOverrideScope::TENANT->value)
|
||||
->required()
|
||||
->live()
|
||||
->disabled(fn (?RetentionOverride $record) => $record?->released_at !== null),
|
||||
Select::make('tenant_id')
|
||||
->label(__('admin.retention_overrides.fields.tenant'))
|
||||
->options(Tenant::query()->orderBy('name')->pluck('name', 'id'))
|
||||
->searchable()
|
||||
->preload()
|
||||
->required()
|
||||
->live()
|
||||
->disabled(fn (?RetentionOverride $record) => $record?->released_at !== null),
|
||||
Select::make('event_id')
|
||||
->label(__('admin.retention_overrides.fields.event'))
|
||||
->options(function (Get $get): array {
|
||||
$tenantId = $get('tenant_id');
|
||||
if (! $tenantId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Event::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->orderByDesc('date')
|
||||
->get()
|
||||
->mapWithKeys(function (Event $event): array {
|
||||
$name = $event->name['de'] ?? $event->name['en'] ?? $event->slug;
|
||||
|
||||
return [$event->id => $name];
|
||||
})
|
||||
->all();
|
||||
})
|
||||
->searchable()
|
||||
->preload()
|
||||
->visible(fn (Get $get): bool => $get('scope') === RetentionOverrideScope::EVENT->value)
|
||||
->required(fn (Get $get): bool => $get('scope') === RetentionOverrideScope::EVENT->value)
|
||||
->dehydrated(fn (Get $get): bool => $get('scope') === RetentionOverrideScope::EVENT->value)
|
||||
->disabled(fn (?RetentionOverride $record) => $record?->released_at !== null),
|
||||
TextInput::make('reason')
|
||||
->label(__('admin.retention_overrides.fields.reason'))
|
||||
->maxLength(200)
|
||||
->required()
|
||||
->disabled(fn (?RetentionOverride $record) => $record?->released_at !== null),
|
||||
Textarea::make('note')
|
||||
->label(__('admin.retention_overrides.fields.note'))
|
||||
->rows(3)
|
||||
->maxLength(2000)
|
||||
->columnSpanFull()
|
||||
->disabled(fn (?RetentionOverride $record) => $record?->released_at !== null),
|
||||
])
|
||||
->columns(2),
|
||||
Section::make(__('admin.retention_overrides.sections.status'))
|
||||
->schema([
|
||||
Placeholder::make('created_by_id')
|
||||
->label(__('admin.retention_overrides.fields.created_by'))
|
||||
->content(fn (?RetentionOverride $record) => $record?->createdBy?->name ?? '—'),
|
||||
Placeholder::make('created_at')
|
||||
->label(__('admin.retention_overrides.fields.created_at'))
|
||||
->content(fn (?RetentionOverride $record) => $record?->created_at?->diffForHumans() ?? '—'),
|
||||
Placeholder::make('released_by_id')
|
||||
->label(__('admin.retention_overrides.fields.released_by'))
|
||||
->content(fn (?RetentionOverride $record) => $record?->releasedBy?->name ?? '—'),
|
||||
Placeholder::make('released_at')
|
||||
->label(__('admin.retention_overrides.fields.released_at'))
|
||||
->content(fn (?RetentionOverride $record) => $record?->released_at?->diffForHumans() ?? '—'),
|
||||
])
|
||||
->columns(2),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\RetentionOverrideResource\Tables;
|
||||
|
||||
use App\Models\RetentionOverride;
|
||||
use App\Services\Audit\SuperAdminAuditLogger;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class RetentionOverrideTable
|
||||
{
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('created_at', 'desc')
|
||||
->columns([
|
||||
TextColumn::make('id')
|
||||
->label(__('admin.retention_overrides.fields.id'))
|
||||
->sortable(),
|
||||
TextColumn::make('scope')
|
||||
->label(__('admin.retention_overrides.fields.scope'))
|
||||
->badge()
|
||||
->formatStateUsing(fn (?string $state) => $state ? __('admin.retention_overrides.scope.'.$state) : '—'),
|
||||
TextColumn::make('tenant.name')
|
||||
->label(__('admin.retention_overrides.fields.tenant'))
|
||||
->searchable(),
|
||||
TextColumn::make('event.slug')
|
||||
->label(__('admin.retention_overrides.fields.event'))
|
||||
->toggleable()
|
||||
->placeholder('—'),
|
||||
TextColumn::make('reason')
|
||||
->label(__('admin.retention_overrides.fields.reason'))
|
||||
->limit(40)
|
||||
->searchable(),
|
||||
TextColumn::make('status')
|
||||
->label(__('admin.retention_overrides.fields.status'))
|
||||
->state(fn (RetentionOverride $record) => $record->released_at ? 'released' : 'active')
|
||||
->badge()
|
||||
->formatStateUsing(fn (string $state) => __('admin.retention_overrides.status.'.$state))
|
||||
->color(fn (string $state) => $state === 'released' ? 'gray' : 'success'),
|
||||
TextColumn::make('createdBy.name')
|
||||
->label(__('admin.retention_overrides.fields.created_by'))
|
||||
->toggleable()
|
||||
->placeholder('—'),
|
||||
TextColumn::make('created_at')
|
||||
->label(__('admin.retention_overrides.fields.created_at'))
|
||||
->since()
|
||||
->sortable(),
|
||||
TextColumn::make('releasedBy.name')
|
||||
->label(__('admin.retention_overrides.fields.released_by'))
|
||||
->toggleable(isToggledHiddenByDefault: true)
|
||||
->placeholder('—'),
|
||||
TextColumn::make('released_at')
|
||||
->label(__('admin.retention_overrides.fields.released_at'))
|
||||
->since()
|
||||
->toggleable(isToggledHiddenByDefault: true)
|
||||
->placeholder('—'),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('scope')
|
||||
->label(__('admin.retention_overrides.fields.scope'))
|
||||
->options([
|
||||
'tenant' => __('admin.retention_overrides.scope.tenant'),
|
||||
'event' => __('admin.retention_overrides.scope.event'),
|
||||
]),
|
||||
SelectFilter::make('status')
|
||||
->label(__('admin.retention_overrides.fields.status'))
|
||||
->options([
|
||||
'active' => __('admin.retention_overrides.status.active'),
|
||||
'released' => __('admin.retention_overrides.status.released'),
|
||||
])
|
||||
->query(function (Builder $query, array $data): Builder {
|
||||
return match ($data['value'] ?? null) {
|
||||
'active' => $query->whereNull('released_at'),
|
||||
'released' => $query->whereNotNull('released_at'),
|
||||
default => $query,
|
||||
};
|
||||
}),
|
||||
])
|
||||
->actions([
|
||||
Action::make('release')
|
||||
->label(__('admin.retention_overrides.actions.release'))
|
||||
->icon('heroicon-o-check-circle')
|
||||
->color('success')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (RetentionOverride $record): bool => $record->released_at === null)
|
||||
->action(function (RetentionOverride $record): void {
|
||||
if ($record->released_at !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$record->forceFill([
|
||||
'released_at' => now(),
|
||||
'released_by_id' => Filament::auth()->id(),
|
||||
])->save();
|
||||
|
||||
app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||
'updated',
|
||||
$record,
|
||||
SuperAdminAuditLogger::fieldsMetadata(['released_at', 'released_by_id']),
|
||||
static::class
|
||||
);
|
||||
}),
|
||||
])
|
||||
->bulkActions([]);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ use App\Filament\Resources\TenantAnnouncementResource\Pages;
|
||||
use App\Models\TenantAnnouncement;
|
||||
use App\Services\Audit\SuperAdminAuditLogger;
|
||||
use BackedEnum;
|
||||
use Filament\Actions;
|
||||
use Filament\Forms\Components\CheckboxList;
|
||||
use Filament\Forms\Components\DateTimePicker;
|
||||
use Filament\Forms\Components\Select;
|
||||
@@ -201,7 +202,7 @@ class TenantAnnouncementResource extends Resource
|
||||
->options($audienceOptions),
|
||||
])
|
||||
->actions([
|
||||
Tables\Actions\EditAction::make()
|
||||
Actions\EditAction::make()
|
||||
->after(fn (array $data, TenantAnnouncement $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||
'updated',
|
||||
$record,
|
||||
@@ -210,7 +211,7 @@ class TenantAnnouncementResource extends Resource
|
||||
)),
|
||||
])
|
||||
->bulkActions([
|
||||
Tables\Actions\DeleteBulkAction::make()
|
||||
Actions\DeleteBulkAction::make()
|
||||
->after(function (Collection $records): void {
|
||||
$logger = app(SuperAdminAuditLogger::class);
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ class TenantFeedbackResource extends Resource
|
||||
|
||||
public static function getNavigationGroup(): UnitEnum|string|null
|
||||
{
|
||||
return __('admin.nav.feedback_support');
|
||||
return __('admin.nav.infrastructure');
|
||||
}
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
|
||||
@@ -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;
|
||||
@@ -72,10 +73,10 @@ class TenantResource extends Resource
|
||||
->email()
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('paddle_customer_id')
|
||||
->label('Paddle Customer ID')
|
||||
TextInput::make('lemonsqueezy_customer_id')
|
||||
->label('Lemon Squeezy Customer ID')
|
||||
->maxLength(191)
|
||||
->helperText('Verknuepfung mit Paddle Billing Kundenkonto.')
|
||||
->helperText('Verknüpfung mit Lemon Squeezy Kundenkonto.')
|
||||
->nullable(),
|
||||
TextInput::make('total_revenue')
|
||||
->label(__('admin.tenants.fields.total_revenue'))
|
||||
@@ -134,8 +135,8 @@ class TenantResource extends Resource
|
||||
->getStateUsing(fn (Tenant $record) => $record->user?->full_name ?? 'Unbekannt'),
|
||||
Tables\Columns\TextColumn::make('slug')->searchable(),
|
||||
Tables\Columns\TextColumn::make('contact_email'),
|
||||
Tables\Columns\TextColumn::make('paddle_customer_id')
|
||||
->label('Paddle Customer')
|
||||
Tables\Columns\TextColumn::make('lemonsqueezy_customer_id')
|
||||
->label('Lemon Squeezy Customer')
|
||||
->toggleable(isToggledHiddenByDefault: true)
|
||||
->formatStateUsing(fn ($state) => $state ?: '-'),
|
||||
Tables\Columns\TextColumn::make('active_reseller_package_id')
|
||||
@@ -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([
|
||||
|
||||
@@ -44,7 +44,7 @@ class PackagePurchasesRelationManager extends RelationManager
|
||||
Select::make('provider')
|
||||
->label('Anbieter')
|
||||
->options([
|
||||
'paddle' => 'Paddle',
|
||||
'lemonsqueezy' => 'Lemon Squeezy',
|
||||
'manual' => 'Manuell',
|
||||
'free' => 'Kostenlos',
|
||||
])
|
||||
@@ -89,7 +89,7 @@ class PackagePurchasesRelationManager extends RelationManager
|
||||
TextColumn::make('provider')
|
||||
->badge()
|
||||
->color(fn (string $state): string => match ($state) {
|
||||
'paddle' => 'success',
|
||||
'lemonsqueezy' => 'success',
|
||||
'manual' => 'gray',
|
||||
'free' => 'success',
|
||||
default => 'gray',
|
||||
@@ -116,7 +116,7 @@ class PackagePurchasesRelationManager extends RelationManager
|
||||
]),
|
||||
SelectFilter::make('provider')
|
||||
->options([
|
||||
'paddle' => 'Paddle',
|
||||
'lemonsqueezy' => 'Lemon Squeezy',
|
||||
'manual' => 'Manuell',
|
||||
'free' => 'Kostenlos',
|
||||
]),
|
||||
|
||||
@@ -40,10 +40,10 @@ class TenantPackagesRelationManager extends RelationManager
|
||||
DateTimePicker::make('expires_at')
|
||||
->label('Ablaufdatum')
|
||||
->required(),
|
||||
TextInput::make('paddle_subscription_id')
|
||||
->label('Paddle Subscription ID')
|
||||
TextInput::make('lemonsqueezy_subscription_id')
|
||||
->label('Lemon Squeezy Subscription ID')
|
||||
->maxLength(191)
|
||||
->helperText('Abonnement-ID aus Paddle Billing.')
|
||||
->helperText('Abonnement-ID aus Lemon Squeezy.')
|
||||
->nullable(),
|
||||
Toggle::make('active')
|
||||
->label('Aktiv'),
|
||||
@@ -75,8 +75,8 @@ class TenantPackagesRelationManager extends RelationManager
|
||||
TextColumn::make('expires_at')
|
||||
->dateTime()
|
||||
->sortable(),
|
||||
TextColumn::make('paddle_subscription_id')
|
||||
->label('Paddle Subscription')
|
||||
TextColumn::make('lemonsqueezy_subscription_id')
|
||||
->label('Lemon Squeezy Subscription')
|
||||
->toggleable(isToggledHiddenByDefault: true)
|
||||
->formatStateUsing(fn ($state) => $state ?: '-'),
|
||||
IconColumn::make('active')
|
||||
|
||||
@@ -22,8 +22,8 @@ class TenantInfolist
|
||||
TextEntry::make('user.full_name')
|
||||
->label(__('admin.tenants.fields.owner'))
|
||||
->state(fn (Tenant $record) => $record->user?->full_name ?? '—'),
|
||||
TextEntry::make('paddle_customer_id')
|
||||
->label('Paddle Customer ID')
|
||||
TextEntry::make('lemonsqueezy_customer_id')
|
||||
->label('Lemon Squeezy Customer ID')
|
||||
->placeholder('—'),
|
||||
TextEntry::make('total_revenue')
|
||||
->label(__('admin.tenants.fields.total_revenue'))
|
||||
|
||||
177
app/Filament/SuperAdmin/Pages/AiEditingSettingsPage.php
Normal file
177
app/Filament/SuperAdmin/Pages/AiEditingSettingsPage.php
Normal file
@@ -0,0 +1,177 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\SuperAdmin\Pages;
|
||||
|
||||
use App\Filament\Clusters\RareAdmin\RareAdminCluster;
|
||||
use App\Models\AiEditingSetting;
|
||||
use App\Services\Audit\SuperAdminAuditLogger;
|
||||
use Filament\Forms;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
class AiEditingSettingsPage extends Page
|
||||
{
|
||||
protected static null|string|\BackedEnum $navigationIcon = 'heroicon-o-sparkles';
|
||||
|
||||
protected static ?string $cluster = RareAdminCluster::class;
|
||||
|
||||
protected string $view = 'filament.super-admin.pages.ai-editing-settings-page';
|
||||
|
||||
protected static null|string|\UnitEnum $navigationGroup = null;
|
||||
|
||||
protected static ?int $navigationSort = 30;
|
||||
|
||||
public static function getNavigationGroup(): \UnitEnum|string|null
|
||||
{
|
||||
return __('admin.nav.platform');
|
||||
}
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return 'AI Editing Settings';
|
||||
}
|
||||
|
||||
public bool $is_enabled = true;
|
||||
|
||||
public string $default_provider = 'runware';
|
||||
|
||||
public ?string $fallback_provider = null;
|
||||
|
||||
public string $runware_mode = 'live';
|
||||
|
||||
public bool $queue_auto_dispatch = false;
|
||||
|
||||
public string $queue_name = 'default';
|
||||
|
||||
public int $queue_max_polls = 6;
|
||||
|
||||
/**
|
||||
* @var array<int, string>
|
||||
*/
|
||||
public array $blocked_terms = [];
|
||||
|
||||
public ?string $status_message = null;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$settings = AiEditingSetting::current();
|
||||
|
||||
$this->is_enabled = (bool) $settings->is_enabled;
|
||||
$this->default_provider = (string) ($settings->default_provider ?: 'runware');
|
||||
$this->fallback_provider = $settings->fallback_provider ? (string) $settings->fallback_provider : null;
|
||||
$this->runware_mode = (string) ($settings->runware_mode ?: 'live');
|
||||
$this->queue_auto_dispatch = (bool) $settings->queue_auto_dispatch;
|
||||
$this->queue_name = (string) ($settings->queue_name ?: 'default');
|
||||
$this->queue_max_polls = max(1, (int) ($settings->queue_max_polls ?: 6));
|
||||
$this->blocked_terms = array_values(array_filter(array_map(
|
||||
static fn (mixed $term): string => trim((string) $term),
|
||||
(array) $settings->blocked_terms
|
||||
)));
|
||||
$this->status_message = $settings->status_message ? (string) $settings->status_message : null;
|
||||
}
|
||||
|
||||
public function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema->schema([
|
||||
Section::make('Global Availability')
|
||||
->schema([
|
||||
Forms\Components\Toggle::make('is_enabled')
|
||||
->label('Enable AI editing globally'),
|
||||
Forms\Components\Textarea::make('status_message')
|
||||
->label('Disabled message')
|
||||
->maxLength(255)
|
||||
->rows(2)
|
||||
->helperText('Shown to guest and tenant clients when the feature is disabled.')
|
||||
->nullable(),
|
||||
]),
|
||||
Section::make('Provider')
|
||||
->schema([
|
||||
Forms\Components\Select::make('default_provider')
|
||||
->label('Default provider')
|
||||
->options([
|
||||
'runware' => 'runware.ai',
|
||||
])
|
||||
->required(),
|
||||
Forms\Components\TextInput::make('fallback_provider')
|
||||
->label('Fallback provider')
|
||||
->maxLength(40)
|
||||
->helperText('Reserved for provider failover.'),
|
||||
Forms\Components\Select::make('runware_mode')
|
||||
->label('Runware mode')
|
||||
->options([
|
||||
'live' => 'Live API',
|
||||
'fake' => 'Fake mode (internal testing)',
|
||||
])
|
||||
->required(),
|
||||
])
|
||||
->columns(2),
|
||||
Section::make('Queue Orchestration')
|
||||
->schema([
|
||||
Forms\Components\Toggle::make('queue_auto_dispatch')
|
||||
->label('Auto-dispatch jobs after request creation'),
|
||||
Forms\Components\TextInput::make('queue_name')
|
||||
->label('Queue name')
|
||||
->required()
|
||||
->maxLength(60),
|
||||
Forms\Components\TextInput::make('queue_max_polls')
|
||||
->label('Max provider polls')
|
||||
->numeric()
|
||||
->minValue(1)
|
||||
->maxValue(50)
|
||||
->required(),
|
||||
])
|
||||
->columns(2),
|
||||
Section::make('Prompt Safety')
|
||||
->schema([
|
||||
Forms\Components\TagsInput::make('blocked_terms')
|
||||
->label('Blocked prompt terms')
|
||||
->helperText('Case-insensitive term match before queue dispatch.')
|
||||
->placeholder('Add blocked term'),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function save(): void
|
||||
{
|
||||
$this->validate();
|
||||
|
||||
$settings = AiEditingSetting::query()->firstOrNew(['id' => 1]);
|
||||
$settings->is_enabled = $this->is_enabled;
|
||||
$settings->default_provider = $this->default_provider;
|
||||
$settings->fallback_provider = $this->nullableString($this->fallback_provider);
|
||||
$settings->runware_mode = $this->runware_mode;
|
||||
$settings->queue_auto_dispatch = $this->queue_auto_dispatch;
|
||||
$settings->queue_name = $this->queue_name;
|
||||
$settings->queue_max_polls = max(1, $this->queue_max_polls);
|
||||
$settings->blocked_terms = array_values(array_filter(array_map(
|
||||
static fn (mixed $term): string => trim((string) $term),
|
||||
$this->blocked_terms
|
||||
)));
|
||||
$settings->status_message = $this->nullableString($this->status_message);
|
||||
$settings->save();
|
||||
|
||||
$changed = $settings->getChanges();
|
||||
if ($changed !== []) {
|
||||
app(SuperAdminAuditLogger::class)->record(
|
||||
'ai_editing.settings_updated',
|
||||
$settings,
|
||||
SuperAdminAuditLogger::fieldsMetadata(array_keys($changed)),
|
||||
source: static::class
|
||||
);
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('AI editing settings saved.')
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
|
||||
private function nullableString(?string $value): ?string
|
||||
{
|
||||
$trimmed = trim((string) $value);
|
||||
|
||||
return $trimmed !== '' ? $trimmed : null;
|
||||
}
|
||||
}
|
||||
@@ -3,39 +3,78 @@
|
||||
namespace App\Filament\SuperAdmin\Pages\Auth;
|
||||
|
||||
use Filament\Auth\Pages\EditProfile as BaseEditProfile;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
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'),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ class Login extends BaseLogin implements HasForms
|
||||
}
|
||||
|
||||
// SuperAdmin-spezifisch: Prüfe auf SuperAdmin-Rolle, keine Tenant-Prüfung
|
||||
if ($user->role !== 'super_admin') {
|
||||
if (! $user->isSuperAdmin()) {
|
||||
$authGuard->logout();
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
|
||||
@@ -45,14 +45,16 @@ class GuestPolicySettingsPage extends Page
|
||||
|
||||
public int $join_token_failure_decay_minutes = 5;
|
||||
|
||||
public int $join_token_access_limit = 120;
|
||||
public int $join_token_access_limit = 300;
|
||||
|
||||
public int $join_token_access_decay_minutes = 1;
|
||||
|
||||
public int $join_token_download_limit = 60;
|
||||
public int $join_token_download_limit = 120;
|
||||
|
||||
public int $join_token_download_decay_minutes = 1;
|
||||
|
||||
public int $join_token_ttl_hours = 168;
|
||||
|
||||
public int $share_link_ttl_hours = 48;
|
||||
|
||||
public ?int $guest_notification_ttl_hours = null;
|
||||
@@ -67,10 +69,11 @@ class GuestPolicySettingsPage extends Page
|
||||
$this->per_device_upload_limit = (int) ($settings->per_device_upload_limit ?? 50);
|
||||
$this->join_token_failure_limit = (int) ($settings->join_token_failure_limit ?? 10);
|
||||
$this->join_token_failure_decay_minutes = (int) ($settings->join_token_failure_decay_minutes ?? 5);
|
||||
$this->join_token_access_limit = (int) ($settings->join_token_access_limit ?? 120);
|
||||
$this->join_token_access_limit = (int) ($settings->join_token_access_limit ?? 300);
|
||||
$this->join_token_access_decay_minutes = (int) ($settings->join_token_access_decay_minutes ?? 1);
|
||||
$this->join_token_download_limit = (int) ($settings->join_token_download_limit ?? 60);
|
||||
$this->join_token_download_limit = (int) ($settings->join_token_download_limit ?? 120);
|
||||
$this->join_token_download_decay_minutes = (int) ($settings->join_token_download_decay_minutes ?? 1);
|
||||
$this->join_token_ttl_hours = (int) ($settings->join_token_ttl_hours ?? 168);
|
||||
$this->share_link_ttl_hours = (int) ($settings->share_link_ttl_hours ?? 48);
|
||||
$this->guest_notification_ttl_hours = $settings->guest_notification_ttl_hours;
|
||||
}
|
||||
@@ -130,6 +133,11 @@ class GuestPolicySettingsPage extends Page
|
||||
->columns(2),
|
||||
Section::make(__('admin.guest_policy.sections.retention'))
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('join_token_ttl_hours')
|
||||
->label(__('admin.guest_policy.fields.join_token_ttl_hours'))
|
||||
->numeric()
|
||||
->minValue(0)
|
||||
->helperText(__('admin.guest_policy.help.join_token_ttl')),
|
||||
Forms\Components\TextInput::make('share_link_ttl_hours')
|
||||
->label(__('admin.guest_policy.fields.share_link_ttl_hours'))
|
||||
->numeric()
|
||||
@@ -160,6 +168,7 @@ class GuestPolicySettingsPage extends Page
|
||||
$settings->join_token_access_decay_minutes = (int) $this->join_token_access_decay_minutes;
|
||||
$settings->join_token_download_limit = (int) $this->join_token_download_limit;
|
||||
$settings->join_token_download_decay_minutes = (int) $this->join_token_download_decay_minutes;
|
||||
$settings->join_token_ttl_hours = (int) $this->join_token_ttl_hours;
|
||||
$settings->share_link_ttl_hours = (int) $this->share_link_ttl_hours;
|
||||
$settings->guest_notification_ttl_hours = $this->guest_notification_ttl_hours;
|
||||
$settings->save();
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\SuperAdmin\Pages;
|
||||
|
||||
use App\Filament\Clusters\DailyOps\DailyOpsCluster;
|
||||
use App\Filament\Widgets\IntegrationsHealthWidget;
|
||||
use BackedEnum;
|
||||
use Filament\Pages\Page;
|
||||
use UnitEnum;
|
||||
|
||||
class IntegrationsHealthDashboard extends Page
|
||||
{
|
||||
protected string $view = 'filament.super-admin.pages.integrations-health-dashboard';
|
||||
|
||||
protected static ?string $cluster = DailyOpsCluster::class;
|
||||
|
||||
protected static null|string|BackedEnum $navigationIcon = 'heroicon-o-link';
|
||||
|
||||
protected static null|string|UnitEnum $navigationGroup = null;
|
||||
|
||||
protected static ?int $navigationSort = 15;
|
||||
|
||||
public static function getNavigationGroup(): UnitEnum|string|null
|
||||
{
|
||||
return __('admin.nav.infrastructure');
|
||||
}
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return __('admin.integrations_health.navigation.label');
|
||||
}
|
||||
|
||||
protected function getHeaderWidgets(): array
|
||||
{
|
||||
return [
|
||||
IntegrationsHealthWidget::class,
|
||||
];
|
||||
}
|
||||
}
|
||||
42
app/Filament/SuperAdmin/Pages/InternalDocsPage.php
Normal file
42
app/Filament/SuperAdmin/Pages/InternalDocsPage.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\SuperAdmin\Pages;
|
||||
|
||||
use App\Filament\Clusters\RareAdmin\RareAdminCluster;
|
||||
use BackedEnum;
|
||||
use Filament\Pages\Page;
|
||||
use UnitEnum;
|
||||
|
||||
class InternalDocsPage extends Page
|
||||
{
|
||||
protected static ?string $cluster = RareAdminCluster::class;
|
||||
|
||||
protected static null|string|BackedEnum $navigationIcon = 'heroicon-o-book-open';
|
||||
|
||||
protected static null|string|UnitEnum $navigationGroup = null;
|
||||
|
||||
protected static ?int $navigationSort = 18;
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return __('admin.nav.internal_docs');
|
||||
}
|
||||
|
||||
public static function getNavigationGroup(): UnitEnum|string|null
|
||||
{
|
||||
return __('admin.nav.infrastructure');
|
||||
}
|
||||
|
||||
public static function getNavigationUrl(): string
|
||||
{
|
||||
return url('/super-admin/docs');
|
||||
}
|
||||
|
||||
public static function getNavigationItemActiveRoutePattern(): string|array
|
||||
{
|
||||
return [
|
||||
static::getRouteName(),
|
||||
'filament.superadmin-kb.*',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ class WatermarkSettingsPage extends Page
|
||||
return __('admin.nav.branding');
|
||||
}
|
||||
|
||||
public ?string $asset = null;
|
||||
public $asset = [];
|
||||
|
||||
public string $position = 'bottom-right';
|
||||
|
||||
@@ -46,7 +46,7 @@ class WatermarkSettingsPage extends Page
|
||||
$settings = WatermarkSetting::query()->first();
|
||||
|
||||
if ($settings) {
|
||||
$this->asset = $settings->asset;
|
||||
$this->asset = $settings->asset ? [$settings->asset] : [];
|
||||
$this->position = $settings->position;
|
||||
$this->opacity = (float) $settings->opacity;
|
||||
$this->scale = (float) $settings->scale;
|
||||
@@ -119,8 +119,14 @@ class WatermarkSettingsPage extends Page
|
||||
{
|
||||
$this->validate();
|
||||
|
||||
$state = $this->form->getState();
|
||||
$asset = $state['asset'] ?? $this->asset;
|
||||
if (is_array($asset)) {
|
||||
$asset = $asset[0] ?? null;
|
||||
}
|
||||
|
||||
$settings = WatermarkSetting::query()->firstOrNew([]);
|
||||
$settings->asset = $this->asset;
|
||||
$settings->asset = $asset;
|
||||
$settings->position = $this->position;
|
||||
$settings->opacity = $this->opacity;
|
||||
$settings->scale = $this->scale;
|
||||
|
||||
280
app/Filament/SuperAdmin/Widgets/SupportApiTokenManager.php
Normal file
280
app/Filament/SuperAdmin/Widgets/SupportApiTokenManager.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Pages;
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Models\EventJoinToken;
|
||||
use App\Services\EventJoinTokenService;
|
||||
use App\Support\JoinTokenLayoutRegistry;
|
||||
use App\Support\TenantOnboardingState;
|
||||
use BackedEnum;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
|
||||
class InviteStudio extends Page
|
||||
{
|
||||
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-qr-code';
|
||||
|
||||
protected string $view = 'filament.tenant.pages.invite-studio';
|
||||
|
||||
protected static ?string $navigationLabel = 'Einladungen & QR';
|
||||
|
||||
protected static ?string $slug = 'invite-studio';
|
||||
|
||||
protected static ?string $title = 'Einladungen & QR-Codes';
|
||||
|
||||
protected static ?int $navigationSort = 50;
|
||||
|
||||
public ?int $selectedEventId = null;
|
||||
|
||||
public string $tokenLabel = '';
|
||||
|
||||
public array $tokens = [];
|
||||
|
||||
public array $layouts = [];
|
||||
|
||||
protected static bool $shouldRegisterNavigation = true;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$tenant = TenantOnboardingState::tenant();
|
||||
|
||||
abort_if(! $tenant, 403);
|
||||
|
||||
if (! TenantOnboardingState::completed($tenant)) {
|
||||
$this->redirect(TenantOnboarding::getUrl());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$firstEventId = $tenant->events()->orderBy('date')->value('id');
|
||||
|
||||
$this->selectedEventId = $firstEventId;
|
||||
$this->layouts = $this->buildLayouts();
|
||||
|
||||
if ($this->selectedEventId) {
|
||||
$this->loadEventContext();
|
||||
}
|
||||
}
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
return TenantOnboardingState::completed();
|
||||
}
|
||||
|
||||
public function updatedSelectedEventId(): void
|
||||
{
|
||||
$this->loadEventContext();
|
||||
}
|
||||
|
||||
public function createInvite(EventJoinTokenService $service): void
|
||||
{
|
||||
$this->validate([
|
||||
'selectedEventId' => ['required', 'exists:events,id'],
|
||||
'tokenLabel' => ['nullable', 'string', 'max:120'],
|
||||
]);
|
||||
|
||||
$tenant = TenantOnboardingState::tenant();
|
||||
|
||||
abort_if(! $tenant, 403);
|
||||
|
||||
$event = $tenant->events()->whereKey($this->selectedEventId)->first();
|
||||
|
||||
if (! $event) {
|
||||
Notification::make()
|
||||
->title('Event konnte nicht gefunden werden')
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$label = $this->tokenLabel ?: 'Einladung '.now()->format('d.m.');
|
||||
|
||||
$layoutPreference = Arr::get($tenant->settings ?? [], 'branding.preferred_invite_layout');
|
||||
|
||||
$service->createToken($event, [
|
||||
'label' => $label,
|
||||
'metadata' => [
|
||||
'preferred_layout' => $layoutPreference,
|
||||
],
|
||||
'created_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
$this->tokenLabel = '';
|
||||
|
||||
$this->loadEventContext();
|
||||
|
||||
Notification::make()
|
||||
->title('Neuer Einladungslink erstellt')
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
|
||||
protected function loadEventContext(): void
|
||||
{
|
||||
$tenant = TenantOnboardingState::tenant();
|
||||
|
||||
if (! $tenant || ! $this->selectedEventId) {
|
||||
$this->tokens = [];
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$event = $tenant->events()->whereKey($this->selectedEventId)->first();
|
||||
|
||||
if (! $event) {
|
||||
$this->tokens = [];
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->tokens = $event->joinTokens()
|
||||
->orderByDesc('created_at')
|
||||
->get()
|
||||
->map(fn (EventJoinToken $token) => $this->mapToken($event, $token))
|
||||
->toArray();
|
||||
}
|
||||
|
||||
protected function mapToken(Event $event, EventJoinToken $token): array
|
||||
{
|
||||
$downloadUrls = JoinTokenLayoutRegistry::toResponse(function (string $layoutId, string $format) use ($event, $token) {
|
||||
return route('api.v1.tenant.events.join-tokens.layouts.download', [
|
||||
'event' => $event->slug,
|
||||
'joinToken' => $token->getKey(),
|
||||
'layout' => $layoutId,
|
||||
'format' => $format,
|
||||
]);
|
||||
});
|
||||
|
||||
return [
|
||||
'id' => $token->getKey(),
|
||||
'label' => $token->label ?? 'Einladungslink',
|
||||
'url' => URL::to('/e/'.$token->token),
|
||||
'created_at' => optional($token->created_at)->format('d.m.Y H:i'),
|
||||
'usage_count' => $token->usage_count,
|
||||
'usage_limit' => $token->usage_limit,
|
||||
'active' => $token->isActive(),
|
||||
'downloads' => $downloadUrls,
|
||||
];
|
||||
}
|
||||
|
||||
protected function buildLayouts(): array
|
||||
{
|
||||
return collect(JoinTokenLayoutRegistry::all())
|
||||
->map(fn (array $layout) => [
|
||||
'id' => $layout['id'],
|
||||
'name' => $layout['name'],
|
||||
'subtitle' => $layout['subtitle'] ?? '',
|
||||
'description' => $layout['description'] ?? '',
|
||||
])
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public function getEventsProperty(): Collection
|
||||
{
|
||||
$tenant = TenantOnboardingState::tenant();
|
||||
|
||||
if (! $tenant) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return $tenant->events()->orderBy('date')->get();
|
||||
}
|
||||
}
|
||||
@@ -1,311 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Pages;
|
||||
|
||||
use App\Filament\Tenant\Resources\EventResource;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventType;
|
||||
use App\Models\TaskCollection;
|
||||
use App\Services\EventJoinTokenService;
|
||||
use App\Services\Tenant\TaskCollectionImportService;
|
||||
use App\Support\JoinTokenLayoutRegistry;
|
||||
use App\Support\TenantOnboardingState;
|
||||
use BackedEnum;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Throwable;
|
||||
use UnitEnum;
|
||||
|
||||
class TenantOnboarding extends Page
|
||||
{
|
||||
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-sparkles';
|
||||
|
||||
protected string $view = 'filament.tenant.pages.onboarding';
|
||||
|
||||
protected static ?string $navigationLabel = 'Willkommen';
|
||||
|
||||
protected static ?string $slug = 'willkommen';
|
||||
|
||||
protected static ?string $title = 'Euer Start mit Fotospiel';
|
||||
|
||||
protected static UnitEnum|string|null $navigationGroup = null;
|
||||
|
||||
public string $step = 'intro';
|
||||
|
||||
public array $status = [];
|
||||
|
||||
public array $inviteDownloads = [];
|
||||
|
||||
public array $selectedPackages = [];
|
||||
|
||||
public string $eventName = '';
|
||||
|
||||
public ?string $eventDate = null;
|
||||
|
||||
public ?int $eventTypeId = null;
|
||||
|
||||
public ?string $palette = null;
|
||||
|
||||
public ?string $inviteLayout = null;
|
||||
|
||||
public bool $isProcessing = false;
|
||||
|
||||
protected static bool $shouldRegisterNavigation = true;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$tenant = TenantOnboardingState::tenant();
|
||||
|
||||
abort_if(! $tenant, 403);
|
||||
|
||||
$this->status = TenantOnboardingState::status($tenant);
|
||||
|
||||
if (TenantOnboardingState::completed($tenant)) {
|
||||
$this->redirect(EventResource::getUrl());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->eventDate = Carbon::now()->addWeeks(2)->format('Y-m-d');
|
||||
$this->eventTypeId = $this->getDefaultEventTypeId();
|
||||
}
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
$tenant = TenantOnboardingState::tenant();
|
||||
|
||||
return ! TenantOnboardingState::completed($tenant);
|
||||
}
|
||||
|
||||
public function start(): void
|
||||
{
|
||||
$this->step = 'packages';
|
||||
}
|
||||
|
||||
public function savePackages(): void
|
||||
{
|
||||
$this->validate([
|
||||
'selectedPackages' => ['required', 'array', 'min:1'],
|
||||
'selectedPackages.*' => ['integer', 'exists:task_collections,id'],
|
||||
], [
|
||||
'selectedPackages.required' => 'Bitte wählt mindestens ein Aufgabenpaket aus.',
|
||||
]);
|
||||
|
||||
$this->step = 'event';
|
||||
}
|
||||
|
||||
public function saveEvent(): void
|
||||
{
|
||||
$this->validate([
|
||||
'eventName' => ['required', 'string', 'max:255'],
|
||||
'eventDate' => ['required', 'date'],
|
||||
'eventTypeId' => ['required', 'exists:event_types,id'],
|
||||
]);
|
||||
|
||||
$this->step = 'palette';
|
||||
}
|
||||
|
||||
public function savePalette(): void
|
||||
{
|
||||
$this->validate([
|
||||
'palette' => ['required', 'string'],
|
||||
]);
|
||||
|
||||
$this->step = 'invite';
|
||||
}
|
||||
|
||||
public function finish(
|
||||
TaskCollectionImportService $importService,
|
||||
EventJoinTokenService $joinTokenService
|
||||
): void {
|
||||
$this->validate([
|
||||
'inviteLayout' => ['required', 'string'],
|
||||
], [
|
||||
'inviteLayout.required' => 'Bitte wählt ein Layout aus.',
|
||||
]);
|
||||
|
||||
$tenant = TenantOnboardingState::tenant();
|
||||
|
||||
abort_if(! $tenant, 403);
|
||||
|
||||
$this->isProcessing = true;
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($tenant, $importService, $joinTokenService) {
|
||||
$event = $this->createEvent($tenant);
|
||||
$this->importPackages($importService, $this->selectedPackages, $event);
|
||||
|
||||
$token = $joinTokenService->createToken($event, [
|
||||
'label' => 'Fotospiel Einladung',
|
||||
'metadata' => [
|
||||
'preferred_layout' => $this->inviteLayout,
|
||||
],
|
||||
]);
|
||||
|
||||
$settings = $tenant->settings ?? [];
|
||||
Arr::set($settings, 'branding.palette', $this->palette);
|
||||
Arr::set($settings, 'branding.primary_event_id', $event->id);
|
||||
Arr::set($settings, 'branding.preferred_invite_layout', $this->inviteLayout);
|
||||
$tenant->forceFill(['settings' => $settings])->save();
|
||||
|
||||
TenantOnboardingState::markCompleted($tenant, [
|
||||
'primary_event_id' => $event->id,
|
||||
'selected_packages' => $this->selectedPackages,
|
||||
'qr_layout' => $this->inviteLayout,
|
||||
]);
|
||||
|
||||
$this->inviteDownloads = $this->buildInviteDownloads($event, $token);
|
||||
$this->status = TenantOnboardingState::status($tenant);
|
||||
|
||||
Notification::make()
|
||||
->title('Euer Setup ist bereit!')
|
||||
->body('Wir haben euer Event erstellt, Aufgaben importiert und euren Einladungslink vorbereitet.')
|
||||
->success()
|
||||
->send();
|
||||
|
||||
$this->redirect(EventResource::getUrl('view', ['record' => $event]));
|
||||
});
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
|
||||
Notification::make()
|
||||
->title('Setup konnte nicht abgeschlossen werden')
|
||||
->body('Bitte prüft eure Eingaben oder versucht es später erneut.')
|
||||
->danger()
|
||||
->send();
|
||||
} finally {
|
||||
$this->isProcessing = false;
|
||||
}
|
||||
}
|
||||
|
||||
protected function createEvent($tenant): Event
|
||||
{
|
||||
$slugBase = Str::slug($this->eventName) ?: 'event';
|
||||
|
||||
do {
|
||||
$slug = Str::of($slugBase)->append('-', Str::random(6))->lower();
|
||||
} while (Event::where('slug', $slug)->exists());
|
||||
|
||||
return Event::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => [
|
||||
app()->getLocale() => $this->eventName,
|
||||
'de' => $this->eventName,
|
||||
],
|
||||
'description' => null,
|
||||
'date' => $this->eventDate,
|
||||
'slug' => (string) $slug,
|
||||
'event_type_id' => $this->eventTypeId,
|
||||
'is_active' => true,
|
||||
'default_locale' => app()->getLocale(),
|
||||
'status' => 'draft',
|
||||
'settings' => [
|
||||
'appearance' => [
|
||||
'palette' => $this->palette,
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
protected function importPackages(
|
||||
TaskCollectionImportService $importService,
|
||||
array $packageIds,
|
||||
Event $event
|
||||
): void {
|
||||
if (empty($packageIds)) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var EloquentCollection<TaskCollection> $collections */
|
||||
$collections = TaskCollection::query()
|
||||
->whereIn('id', $packageIds)
|
||||
->get();
|
||||
|
||||
$collections->each(function (TaskCollection $collection) use ($importService, $event) {
|
||||
$importService->import($collection, $event);
|
||||
});
|
||||
}
|
||||
|
||||
protected function buildInviteDownloads(Event $event, $token): array
|
||||
{
|
||||
return JoinTokenLayoutRegistry::toResponse(function (string $layoutId, string $format) use ($event, $token) {
|
||||
return route('api.v1.tenant.events.join-tokens.layouts.download', [
|
||||
'event' => $event->slug,
|
||||
'joinToken' => $token->getKey(),
|
||||
'layout' => $layoutId,
|
||||
'format' => $format,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
public function getPackageListProperty(): array
|
||||
{
|
||||
return TaskCollection::query()
|
||||
->whereNull('tenant_id')
|
||||
->orderBy('position')
|
||||
->get()
|
||||
->map(fn (TaskCollection $collection) => [
|
||||
'id' => $collection->getKey(),
|
||||
'name' => $collection->name,
|
||||
'description' => $collection->description,
|
||||
])
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public function getEventTypeOptionsProperty(): array
|
||||
{
|
||||
return EventType::query()
|
||||
->orderBy('name->'.app()->getLocale())
|
||||
->get()
|
||||
->mapWithKeys(function (EventType $type) {
|
||||
$name = $type->name[app()->getLocale()] ?? $type->name['de'] ?? Arr::first($type->name);
|
||||
|
||||
return [$type->getKey() => $name];
|
||||
})
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public function getPaletteOptionsProperty(): array
|
||||
{
|
||||
return [
|
||||
'romance' => [
|
||||
'label' => 'Rosé & Gold',
|
||||
'description' => 'Warme Rosé-Töne mit goldenen Akzenten – romantisch und elegant.',
|
||||
],
|
||||
'sunset' => [
|
||||
'label' => 'Sonnenuntergang',
|
||||
'description' => 'Leuchtende Orange- und Pink-Verläufe für lebhafte Partys.',
|
||||
],
|
||||
'evergreen' => [
|
||||
'label' => 'Evergreen',
|
||||
'description' => 'Sanfte Grüntöne und Naturakzente für Boho- & Outdoor-Events.',
|
||||
],
|
||||
'midnight' => [
|
||||
'label' => 'Midnight',
|
||||
'description' => 'Tiefes Navy und Flieder – perfekt für elegante Abendveranstaltungen.',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function getLayoutOptionsProperty(): array
|
||||
{
|
||||
return collect(JoinTokenLayoutRegistry::all())
|
||||
->map(fn ($layout) => [
|
||||
'id' => $layout['id'],
|
||||
'name' => $layout['name'],
|
||||
'subtitle' => $layout['subtitle'] ?? '',
|
||||
'description' => $layout['description'] ?? '',
|
||||
])
|
||||
->toArray();
|
||||
}
|
||||
|
||||
protected function getDefaultEventTypeId(): ?int
|
||||
{
|
||||
return EventType::query()->orderBy('name->'.app()->getLocale())->value('id');
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user