Compare commits
92 Commits
beads-sync
...
b316beb522
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b316beb522 | ||
|
|
6d3f4f36e8 | ||
|
|
9e4ea3dafb | ||
|
|
1517eb8631 | ||
|
|
9a4ece33bf | ||
|
|
30c653913d | ||
|
|
4c37f874bd | ||
|
|
05fdda811b | ||
|
|
eeeca0eed5 | ||
|
|
fa6a5678f0 | ||
|
|
63956087a4 | ||
|
|
a3f153de6f | ||
|
|
8d729c6a86 | ||
|
|
7ad43a3661 | ||
|
|
7aa0a4c847 | ||
|
|
df60be826d | ||
|
|
918bff08aa | ||
|
|
292c8f0b26 | ||
|
|
11018f273d | ||
|
|
7e32d8f706 | ||
|
|
ad829ae509 | ||
|
|
2f93271d94 | ||
|
|
62255dc9e7 | ||
|
|
738659112d | ||
|
|
89d9b656de | ||
|
|
5d0ae0faa5 | ||
|
|
2ecd417b55 | ||
|
|
3755213010 | ||
|
|
9cb236f123 | ||
|
|
10232cf40e | ||
|
|
3ce6507268 | ||
|
|
a39295a0f0 | ||
|
|
5dc69fb187 | ||
|
|
92b341bdcd | ||
|
|
725a7a29b3 | ||
|
|
8634d16359 | ||
|
|
81446b37c3 | ||
|
|
33e46b448d | ||
|
|
289ef70e53 | ||
|
|
d0559bf8c9 | ||
|
|
0ef4b32bf6 | ||
|
|
3612c97e86 | ||
|
|
c0510581c6 | ||
|
|
1ffd3e3b9d | ||
|
|
e05ee3b186 | ||
|
|
cf7b2e563a | ||
|
|
719afb6920 | ||
|
|
83c58358a1 | ||
|
|
2b888078a0 | ||
|
|
2f584162d6 | ||
|
|
0833ea6b36 | ||
|
|
5bdc15d399 | ||
|
|
693540f609 | ||
|
|
c0193c9581 | ||
|
|
03c7b20cae | ||
|
|
3a78c4f2c0 | ||
|
|
fa333deed9 | ||
|
|
a733df6221 | ||
|
|
5ee1baa7e2 | ||
|
|
2f19752199 | ||
|
|
7dd7ec14a4 | ||
|
|
d9568be579 | ||
|
|
9cf6e9d94d | ||
|
|
a23ce0c86f | ||
|
|
9efea136bd | ||
|
|
7a6f489b8b | ||
|
|
cc11e024f0 | ||
|
|
2089251a92 | ||
|
|
53094b8d36 | ||
|
|
0c33c1ddc1 | ||
|
|
ce0b7c951a | ||
|
|
fbbbbdac4c | ||
|
|
94d0713ec0 | ||
|
|
3e36354916 | ||
|
|
24a1319cc2 | ||
|
|
b1250c6246 | ||
|
|
fd7a3c846a | ||
|
|
1ca7545f86 | ||
|
|
9f4a202d2b | ||
|
|
fe0525e678 | ||
|
|
d62efdb55c | ||
|
|
be722f6e37 | ||
|
|
898ac9ff0e | ||
|
|
c8d1ac7971 | ||
|
|
3ee23f3a66 | ||
|
|
993c351832 | ||
|
|
2444a62a4d | ||
|
|
e52720a3cb | ||
|
|
93bed358ba | ||
|
|
a16bd9c498 | ||
|
|
e32b1fa45a | ||
|
|
6edc890e01 |
6
.beads/.gitignore
vendored
6
.beads/.gitignore
vendored
@@ -11,6 +11,12 @@ daemon.log
|
|||||||
daemon.pid
|
daemon.pid
|
||||||
bd.sock
|
bd.sock
|
||||||
sync-state.json
|
sync-state.json
|
||||||
|
.sync.lock
|
||||||
|
last-touched
|
||||||
|
sync_base.jsonl
|
||||||
|
.sync.lock
|
||||||
|
last-touched
|
||||||
|
sync_base.jsonl
|
||||||
|
|
||||||
# Local version tracking (prevents upgrade notification spam after git ops)
|
# Local version tracking (prevents upgrade notification spam after git ops)
|
||||||
.local_version
|
.local_version
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
# This setting persists across clones (unlike database config which is gitignored).
|
# This setting persists across clones (unlike database config which is gitignored).
|
||||||
# Can also use BEADS_SYNC_BRANCH env var for local override.
|
# Can also use BEADS_SYNC_BRANCH env var for local override.
|
||||||
# If not set, bd sync will require you to run 'bd config set sync.branch <branch>'.
|
# If not set, bd sync will require you to run 'bd config set sync.branch <branch>'.
|
||||||
# sync-branch: "beads-sync"
|
sync-branch: "beads-sync"
|
||||||
|
|
||||||
# Multi-repo configuration (experimental - bd-307)
|
# Multi-repo configuration (experimental - bd-307)
|
||||||
# Allows hydrating from multiple repositories and routing writes to the correct JSONL
|
# Allows hydrating from multiple repositories and routing writes to the correct JSONL
|
||||||
@@ -59,4 +59,4 @@
|
|||||||
# - linear.url
|
# - linear.url
|
||||||
# - linear.api-key
|
# - linear.api-key
|
||||||
# - github.org
|
# - github.org
|
||||||
# - github.repo
|
# - github.repo
|
||||||
@@ -17,12 +17,10 @@
|
|||||||
{"id":"fotospiel-app-38f","title":"Paddle catalog sync: surface last sync error/log context in admin","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:59:14.865414785+01:00","created_by":"soeren","updated_at":"2026-01-02T21:16:09.109922491+01:00","closed_at":"2026-01-02T21:16:09.109922491+01:00","close_reason":"Completed"}
|
{"id":"fotospiel-app-38f","title":"Paddle catalog sync: surface last sync error/log context in admin","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:59:14.865414785+01:00","created_by":"soeren","updated_at":"2026-01-02T21:16:09.109922491+01:00","closed_at":"2026-01-02T21:16:09.109922491+01:00","close_reason":"Completed"}
|
||||||
{"id":"fotospiel-app-3ut","title":"SEC-API-03 Synthetic monitoring + alert config","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:52:46.793875724+01:00","created_by":"soeren","updated_at":"2026-01-01T15:52:46.793875724+01:00"}
|
{"id":"fotospiel-app-3ut","title":"SEC-API-03 Synthetic monitoring + alert config","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:52:46.793875724+01:00","created_by":"soeren","updated_at":"2026-01-01T15:52:46.793875724+01:00"}
|
||||||
{"id":"fotospiel-app-3xa","title":"Security review: event admin code audit (policies, PKCE, file handling)","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:05:20.115675149+01:00","created_by":"soeren","updated_at":"2026-01-01T16:05:20.115675149+01:00"}
|
{"id":"fotospiel-app-3xa","title":"Security review: event admin code audit (policies, PKCE, file handling)","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:05:20.115675149+01:00","created_by":"soeren","updated_at":"2026-01-01T16:05:20.115675149+01:00"}
|
||||||
{"id":"fotospiel-app-43mp","title":"Help-System für Event Admin PWA planen","notes":"Context help links wired into priority admin pages.","status":"in_progress","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-23T08:21:47.812129626+01:00","created_by":"Codex Agent","updated_at":"2026-01-23T09:19:45.828239299+01:00"}
|
|
||||||
{"id":"fotospiel-app-4ar","title":"SEC-BILL-03 Failed capture notifications + ledger hook","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:54:33.266516715+01:00","created_by":"soeren","updated_at":"2026-01-01T15:54:33.266516715+01:00"}
|
{"id":"fotospiel-app-4ar","title":"SEC-BILL-03 Failed capture notifications + ledger hook","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:54:33.266516715+01:00","created_by":"soeren","updated_at":"2026-01-01T15:54:33.266516715+01:00"}
|
||||||
{"id":"fotospiel-app-4en","title":"Add translations for Mobile Package Shop","description":"The new MobilePackageShopPage.tsx uses translation keys like 'shop.title', 'shop.legal.agb', etc. Ensure these are added to the management.json files for de and en.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T18:05:50.469751088+01:00","created_by":"soeren","updated_at":"2026-01-06T18:14:19.984343737+01:00","closed_at":"2026-01-06T18:14:19.984346372+01:00"}
|
{"id":"fotospiel-app-4en","title":"Add translations for Mobile Package Shop","description":"The new MobilePackageShopPage.tsx uses translation keys like 'shop.title', 'shop.legal.agb', etc. Ensure these are added to the management.json files for de and en.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T18:05:50.469751088+01:00","created_by":"soeren","updated_at":"2026-01-06T18:14:19.984343737+01:00","closed_at":"2026-01-06T18:14:19.984346372+01:00"}
|
||||||
{"id":"fotospiel-app-4i4","title":"Security review: map roles/data","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:02:58.370301875+01:00","created_by":"soeren","updated_at":"2026-01-01T16:03:03.997327414+01:00","closed_at":"2026-01-01T16:03:03.997327414+01:00","close_reason":"Completed in codebase (verified)"}
|
{"id":"fotospiel-app-4i4","title":"Security review: map roles/data","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:02:58.370301875+01:00","created_by":"soeren","updated_at":"2026-01-01T16:03:03.997327414+01:00","closed_at":"2026-01-01T16:03:03.997327414+01:00","close_reason":"Completed in codebase (verified)"}
|
||||||
{"id":"fotospiel-app-4zu","title":"SEC-IO-02 Refresh-token management UI + audit logs","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:51:50.24186222+01:00","created_by":"soeren","updated_at":"2026-01-04T16:10:39.752587431+01:00","closed_at":"2026-01-04T16:10:39.752587431+01:00","close_reason":"Obsolete: authentication now uses Sanctum PATs; OAuth/refresh-token tables removed and no refresh-token flow remains. See docs/archive/prp/13-backend-authentication.md and docs/archive/prp/marketing-checkout-payment-architecture.md."}
|
{"id":"fotospiel-app-4zu","title":"SEC-IO-02 Refresh-token management UI + audit logs","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:51:50.24186222+01:00","created_by":"soeren","updated_at":"2026-01-04T16:10:39.752587431+01:00","closed_at":"2026-01-04T16:10:39.752587431+01:00","close_reason":"Obsolete: authentication now uses Sanctum PATs; OAuth/refresh-token tables removed and no refresh-token flow remains. See docs/archive/prp/13-backend-authentication.md and docs/archive/prp/marketing-checkout-payment-architecture.md."}
|
||||||
{"id":"fotospiel-app-4zy","title":"Refine Dashboard Translations","description":"Fix missing translations in the modern dashboard UI and use proper i18n keys for stats and status labels.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-17T16:35:14.464529363+01:00","created_by":"Codex Agent","updated_at":"2026-01-17T16:35:14.464529363+01:00"}
|
|
||||||
{"id":"fotospiel-app-539","title":"Live Show: public player view with effects engine","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-01-05T11:11:36.821959901+01:00","created_by":"soeren","updated_at":"2026-01-05T18:30:13.318396255+01:00","closed_at":"2026-01-05T18:30:13.318396255+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-539","depends_on_id":"fotospiel-app-qne","type":"blocks","created_at":"2026-01-05T11:12:58.721858159+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-539","depends_on_id":"fotospiel-app-6zc","type":"blocks","created_at":"2026-01-05T11:13:07.289796993+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-539","depends_on_id":"fotospiel-app-h5d","type":"blocks","created_at":"2026-01-05T11:44:42.719445471+01:00","created_by":"soeren"}]}
|
{"id":"fotospiel-app-539","title":"Live Show: public player view with effects engine","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-01-05T11:11:36.821959901+01:00","created_by":"soeren","updated_at":"2026-01-05T18:30:13.318396255+01:00","closed_at":"2026-01-05T18:30:13.318396255+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-539","depends_on_id":"fotospiel-app-qne","type":"blocks","created_at":"2026-01-05T11:12:58.721858159+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-539","depends_on_id":"fotospiel-app-6zc","type":"blocks","created_at":"2026-01-05T11:13:07.289796993+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-539","depends_on_id":"fotospiel-app-h5d","type":"blocks","created_at":"2026-01-05T11:44:42.719445471+01:00","created_by":"soeren"}]}
|
||||||
{"id":"fotospiel-app-539.2","title":"Live Show player shell + routing + data layer","description":"Add /show/{token} route + guest player page shell, Live Show API client, SSE/polling subscription and state model.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-05T15:57:41.587003393+01:00","created_by":"soeren","updated_at":"2026-01-05T16:44:39.577762479+01:00","closed_at":"2026-01-05T16:44:39.577762479+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-539.2","depends_on_id":"fotospiel-app-539","type":"parent-child","created_at":"2026-01-05T15:57:41.641767879+01:00","created_by":"soeren"}]}
|
{"id":"fotospiel-app-539.2","title":"Live Show player shell + routing + data layer","description":"Add /show/{token} route + guest player page shell, Live Show API client, SSE/polling subscription and state model.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-05T15:57:41.587003393+01:00","created_by":"soeren","updated_at":"2026-01-05T16:44:39.577762479+01:00","closed_at":"2026-01-05T16:44:39.577762479+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-539.2","depends_on_id":"fotospiel-app-539","type":"parent-child","created_at":"2026-01-05T15:57:41.641767879+01:00","created_by":"soeren"}]}
|
||||||
{"id":"fotospiel-app-539.3","title":"Live Show playback engine (queue, pacing, layouts)","description":"Implement player playback scheduler, queue management, and layout rendering for single/split/grid.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-05T15:57:56.531080931+01:00","created_by":"soeren","updated_at":"2026-01-05T17:40:45.929168571+01:00","closed_at":"2026-01-05T17:40:45.929168571+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-539.3","depends_on_id":"fotospiel-app-539","type":"parent-child","created_at":"2026-01-05T15:57:56.631147026+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-539.3","depends_on_id":"fotospiel-app-539.2","type":"blocks","created_at":"2026-01-05T15:57:56.655278463+01:00","created_by":"soeren"}]}
|
{"id":"fotospiel-app-539.3","title":"Live Show playback engine (queue, pacing, layouts)","description":"Implement player playback scheduler, queue management, and layout rendering for single/split/grid.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-05T15:57:56.531080931+01:00","created_by":"soeren","updated_at":"2026-01-05T17:40:45.929168571+01:00","closed_at":"2026-01-05T17:40:45.929168571+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-539.3","depends_on_id":"fotospiel-app-539","type":"parent-child","created_at":"2026-01-05T15:57:56.631147026+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-539.3","depends_on_id":"fotospiel-app-539.2","type":"blocks","created_at":"2026-01-05T15:57:56.655278463+01:00","created_by":"soeren"}]}
|
||||||
@@ -38,7 +36,6 @@
|
|||||||
{"id":"fotospiel-app-5ie","title":"Help docs: Live Show how-to + recommended hardware (DE/EN)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-05T11:12:05.973844187+01:00","created_by":"soeren","updated_at":"2026-01-05T19:42:44.39939087+01:00","closed_at":"2026-01-05T19:42:44.39939087+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-5ie","depends_on_id":"fotospiel-app-vro","type":"blocks","created_at":"2026-01-05T11:13:54.925412888+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-5ie","depends_on_id":"fotospiel-app-539","type":"blocks","created_at":"2026-01-05T11:14:03.257649076+01:00","created_by":"soeren"}]}
|
{"id":"fotospiel-app-5ie","title":"Help docs: Live Show how-to + recommended hardware (DE/EN)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-05T11:12:05.973844187+01:00","created_by":"soeren","updated_at":"2026-01-05T19:42:44.39939087+01:00","closed_at":"2026-01-05T19:42:44.39939087+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-5ie","depends_on_id":"fotospiel-app-vro","type":"blocks","created_at":"2026-01-05T11:13:54.925412888+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-5ie","depends_on_id":"fotospiel-app-539","type":"blocks","created_at":"2026-01-05T11:14:03.257649076+01:00","created_by":"soeren"}]}
|
||||||
{"id":"fotospiel-app-5iy","title":"Security review: confirm env/header defaults","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:03:20.808188183+01:00","created_by":"soeren","updated_at":"2026-01-01T16:03:26.388002115+01:00","closed_at":"2026-01-01T16:03:26.388002115+01:00","close_reason":"Completed in codebase (verified)"}
|
{"id":"fotospiel-app-5iy","title":"Security review: confirm env/header defaults","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:03:20.808188183+01:00","created_by":"soeren","updated_at":"2026-01-01T16:03:26.388002115+01:00","closed_at":"2026-01-01T16:03:26.388002115+01:00","close_reason":"Completed in codebase (verified)"}
|
||||||
{"id":"fotospiel-app-5s3","title":"Localized SEO: canonical/hreflang tags + localized navigation","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:02:03.909947355+01:00","created_by":"soeren","updated_at":"2026-01-01T16:02:09.550647107+01:00","closed_at":"2026-01-01T16:02:09.550647107+01:00","close_reason":"Completed in codebase (verified)"}
|
{"id":"fotospiel-app-5s3","title":"Localized SEO: canonical/hreflang tags + localized navigation","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:02:03.909947355+01:00","created_by":"soeren","updated_at":"2026-01-01T16:02:09.550647107+01:00","closed_at":"2026-01-01T16:02:09.550647107+01:00","close_reason":"Completed in codebase (verified)"}
|
||||||
{"id":"fotospiel-app-5veo","title":"Investigate vite build timeout","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-21T12:49:14.166622473+01:00","created_by":"Codex Agent","updated_at":"2026-01-21T12:49:14.166622473+01:00"}
|
|
||||||
{"id":"fotospiel-app-5zl","title":"Ensure checkout step 3 requires login for Paddle checkout","description":"Problem: Paddle checkout on step 3 fails when user is not logged in. Step 3 must enforce authentication before initializing Paddle checkout.\\n\\nSuggestions:\\n- Protect step 3 route/controller with auth middleware and redirect to login with intended return URL.\\n- Gate step 3 UI/CTA on auth state; show inline login prompt and disable Paddle until authenticated.\\n- Require auth in backend endpoint that creates Paddle transaction/session; return 401 and send user to login.\\n- Optionally preflight at end of step 2 to prompt login before advancing.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-04T12:31:43.215017311+01:00","created_by":"soeren","updated_at":"2026-01-04T12:42:45.088723058+01:00","closed_at":"2026-01-04T12:42:45.088723058+01:00","close_reason":"Closed"}
|
{"id":"fotospiel-app-5zl","title":"Ensure checkout step 3 requires login for Paddle checkout","description":"Problem: Paddle checkout on step 3 fails when user is not logged in. Step 3 must enforce authentication before initializing Paddle checkout.\\n\\nSuggestions:\\n- Protect step 3 route/controller with auth middleware and redirect to login with intended return URL.\\n- Gate step 3 UI/CTA on auth state; show inline login prompt and disable Paddle until authenticated.\\n- Require auth in backend endpoint that creates Paddle transaction/session; return 401 and send user to login.\\n- Optionally preflight at end of step 2 to prompt login before advancing.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-04T12:31:43.215017311+01:00","created_by":"soeren","updated_at":"2026-01-04T12:42:45.088723058+01:00","closed_at":"2026-01-04T12:42:45.088723058+01:00","close_reason":"Closed"}
|
||||||
{"id":"fotospiel-app-64l","title":"SEC-FE-01 CSP nonce/hashing rollout","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:54:47.607047443+01:00","created_by":"soeren","updated_at":"2026-01-01T15:55:56.477104351+01:00","closed_at":"2026-01-01T15:55:56.477104351+01:00","close_reason":"Completed in codebase (verified) - duplicate of fotospiel-app-zli"}
|
{"id":"fotospiel-app-64l","title":"SEC-FE-01 CSP nonce/hashing rollout","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:54:47.607047443+01:00","created_by":"soeren","updated_at":"2026-01-01T15:55:56.477104351+01:00","closed_at":"2026-01-01T15:55:56.477104351+01:00","close_reason":"Completed in codebase (verified) - duplicate of fotospiel-app-zli"}
|
||||||
{"id":"fotospiel-app-6dp","title":"Coupon ops enhancements (redemption service, preview endpoint, widget, export)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:09:09.275919717+01:00","created_by":"soeren","updated_at":"2026-01-01T16:09:14.882264149+01:00","closed_at":"2026-01-01T16:09:14.882264149+01:00","close_reason":"Completed in codebase (verified)"}
|
{"id":"fotospiel-app-6dp","title":"Coupon ops enhancements (redemption service, preview endpoint, widget, export)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:09:09.275919717+01:00","created_by":"soeren","updated_at":"2026-01-01T16:09:14.882264149+01:00","closed_at":"2026-01-01T16:09:14.882264149+01:00","close_reason":"Completed in codebase (verified)"}
|
||||||
@@ -51,7 +48,6 @@
|
|||||||
{"id":"fotospiel-app-7uu","title":"Uploader: improve file readiness detection","description":"Part of epic fotospiel-app-5aa. Use size + last-write stabilization to avoid partial uploads.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:01:54.142231578+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:01:54.142231578+01:00"}
|
{"id":"fotospiel-app-7uu","title":"Uploader: improve file readiness detection","description":"Part of epic fotospiel-app-5aa. Use size + last-write stabilization to avoid partial uploads.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:01:54.142231578+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:01:54.142231578+01:00"}
|
||||||
{"id":"fotospiel-app-7x1","title":"Uploader: response format manual override","description":"Part of epic fotospiel-app-5aa. Allow manual response format override when connect code doesn't set it.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:03:54.824613016+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:03:54.824613016+01:00"}
|
{"id":"fotospiel-app-7x1","title":"Uploader: response format manual override","description":"Part of epic fotospiel-app-5aa. Allow manual response format override when connect code doesn't set it.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:03:54.824613016+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:03:54.824613016+01:00"}
|
||||||
{"id":"fotospiel-app-83q","title":"Implement Advanced Analytics","description":"Full plan: Phase 1 (MVP) includes Activity Timeline, Top Contributors, and Task Stats. Phase 2 includes Engagement Funnel, Vibe Check, and PDF Export. See chat history for details.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T15:40:08.826105426+01:00","created_by":"soeren","updated_at":"2026-01-06T16:15:17.722450844+01:00","closed_at":"2026-01-06T16:15:17.722455019+01:00"}
|
{"id":"fotospiel-app-83q","title":"Implement Advanced Analytics","description":"Full plan: Phase 1 (MVP) includes Activity Timeline, Top Contributors, and Task Stats. Phase 2 includes Engagement Funnel, Vibe Check, and PDF Export. See chat history for details.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T15:40:08.826105426+01:00","created_by":"soeren","updated_at":"2026-01-06T16:15:17.722450844+01:00","closed_at":"2026-01-06T16:15:17.722455019+01:00"}
|
||||||
{"id":"fotospiel-app-8iw","title":"Modernize Tenant Admin PWA UI","status":"open","priority":1,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-17T14:36:39.802617182+01:00","created_by":"Codex Agent","updated_at":"2026-01-17T14:36:39.802617182+01:00"}
|
|
||||||
{"id":"fotospiel-app-8ui","title":"Uploader: persist queue across restarts","description":"Part of epic fotospiel-app-5aa. Persist pending upload queue to disk (settings or local DB) so restarts don't lose files.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:01:42.213478619+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:01:42.213478619+01:00"}
|
{"id":"fotospiel-app-8ui","title":"Uploader: persist queue across restarts","description":"Part of epic fotospiel-app-5aa. Persist pending upload queue to disk (settings or local DB) so restarts don't lose files.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:01:42.213478619+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:01:42.213478619+01:00"}
|
||||||
{"id":"fotospiel-app-95m","title":"Paddle migration: admin catalog sync UI for packages","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:57:49.790409261+01:00","created_by":"soeren","updated_at":"2026-01-01T15:57:55.418180246+01:00","closed_at":"2026-01-01T15:57:55.418180246+01:00","close_reason":"Completed in codebase (verified)"}
|
{"id":"fotospiel-app-95m","title":"Paddle migration: admin catalog sync UI for packages","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:57:49.790409261+01:00","created_by":"soeren","updated_at":"2026-01-01T15:57:55.418180246+01:00","closed_at":"2026-01-01T15:57:55.418180246+01:00","close_reason":"Completed in codebase (verified)"}
|
||||||
{"id":"fotospiel-app-99d","title":"Paddle migration: marketing checkout uses Paddle-hosted checkout","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:58:12.298063897+01:00","created_by":"soeren","updated_at":"2026-01-01T15:58:17.968032021+01:00","closed_at":"2026-01-01T15:58:17.968032021+01:00","close_reason":"Completed in codebase (verified)"}
|
{"id":"fotospiel-app-99d","title":"Paddle migration: marketing checkout uses Paddle-hosted checkout","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:58:12.298063897+01:00","created_by":"soeren","updated_at":"2026-01-01T15:58:17.968032021+01:00","closed_at":"2026-01-01T15:58:17.968032021+01:00","close_reason":"Completed in codebase (verified)"}
|
||||||
@@ -140,7 +136,6 @@
|
|||||||
{"id":"fotospiel-app-sbs","title":"Compliance tools: data export + retention overrides","description":"GDPR-compliant export requests and retention override workflows for tenants/events.","status":"closed","priority":3,"issue_type":"feature","created_at":"2026-01-01T14:20:16.530289009+01:00","updated_at":"2026-01-02T20:13:31.704875591+01:00","closed_at":"2026-01-02T20:13:31.704875591+01:00","close_reason":"Closed"}
|
{"id":"fotospiel-app-sbs","title":"Compliance tools: data export + retention overrides","description":"GDPR-compliant export requests and retention override workflows for tenants/events.","status":"closed","priority":3,"issue_type":"feature","created_at":"2026-01-01T14:20:16.530289009+01:00","updated_at":"2026-01-02T20:13:31.704875591+01:00","closed_at":"2026-01-02T20:13:31.704875591+01:00","close_reason":"Closed"}
|
||||||
{"id":"fotospiel-app-sdg","title":"Uploader: watch include/exclude patterns","description":"Part of epic fotospiel-app-5aa. Configurable file patterns (ignore tmp/preview) for watcher.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:02:17.188267106+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:02:17.188267106+01:00"}
|
{"id":"fotospiel-app-sdg","title":"Uploader: watch include/exclude patterns","description":"Part of epic fotospiel-app-5aa. Configurable file patterns (ignore tmp/preview) for watcher.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:02:17.188267106+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:02:17.188267106+01:00"}
|
||||||
{"id":"fotospiel-app-sju","title":"Live Show link sharing + QR in admin","description":"Expose Live Show link in Event Admin with copy/share/open actions and embedded QR (use simplesoftwareio/simple-qrcode, no external service). Add API endpoints for link fetch/rotate, admin UI card with rotate confirmation, and tests.","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-05T20:00:25.427132538+01:00","created_by":"soeren","updated_at":"2026-01-05T20:00:25.427132538+01:00"}
|
{"id":"fotospiel-app-sju","title":"Live Show link sharing + QR in admin","description":"Expose Live Show link in Event Admin with copy/share/open actions and embedded QR (use simplesoftwareio/simple-qrcode, no external service). Add API endpoints for link fetch/rotate, admin UI card with rotate confirmation, and tests.","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-05T20:00:25.427132538+01:00","created_by":"soeren","updated_at":"2026-01-05T20:00:25.427132538+01:00"}
|
||||||
{"id":"fotospiel-app-spq8","title":"Eslint fails due to existing repo violations","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-19T18:49:19.208323875+01:00","created_by":"Codex Agent","updated_at":"2026-01-19T18:49:19.208323875+01:00"}
|
|
||||||
{"id":"fotospiel-app-swb","title":"Security review: replace public asset URLs with signed routes","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:04:05.610098299+01:00","created_by":"soeren","updated_at":"2026-01-01T16:04:11.215921463+01:00","closed_at":"2026-01-01T16:04:11.215921463+01:00","close_reason":"Completed in codebase (verified)"}
|
{"id":"fotospiel-app-swb","title":"Security review: replace public asset URLs with signed routes","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:04:05.610098299+01:00","created_by":"soeren","updated_at":"2026-01-01T16:04:11.215921463+01:00","closed_at":"2026-01-01T16:04:11.215921463+01:00","close_reason":"Completed in codebase (verified)"}
|
||||||
{"id":"fotospiel-app-t1k","title":"Live Show: data model \u0026 status workflow (pending/approved/ready)","acceptance_criteria":"- DB migrations add event token + photo live fields + indexes\\n- Token generation supports rotation (no expiry)\\n- Photo live workflow methods set timestamps/reviewer consistently\\n- Feature test covers token + workflow","notes":"Implemented Live Show data model: events.live_show_token + live_show_token_rotated_at; photos.live_status + timestamps/reviewer/rejection fields + indexes. Added PhotoLiveStatus enum and Photo workflow methods (markLivePending/approveForLiveShow/rejectForLiveShow). Added Event helpers (ensureLiveShowToken/rotateLiveShowToken). Tests: tests/Feature/LiveShowDataModelTest.php.","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-01-05T11:10:56.560421826+01:00","created_by":"soeren","updated_at":"2026-01-05T12:22:51.967913423+01:00","closed_at":"2026-01-05T12:22:51.967913423+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-t1k","depends_on_id":"fotospiel-app-vro","type":"blocks","created_at":"2026-01-05T11:12:20.345646244+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-t1k","depends_on_id":"fotospiel-app-h5d","type":"blocks","created_at":"2026-01-05T11:44:12.439413712+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-t1k","depends_on_id":"fotospiel-app-1eu","type":"blocks","created_at":"2026-01-05T11:44:22.588642567+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-t1k","depends_on_id":"fotospiel-app-1we","type":"blocks","created_at":"2026-01-05T11:44:31.775634827+01:00","created_by":"soeren"}]}
|
{"id":"fotospiel-app-t1k","title":"Live Show: data model \u0026 status workflow (pending/approved/ready)","acceptance_criteria":"- DB migrations add event token + photo live fields + indexes\\n- Token generation supports rotation (no expiry)\\n- Photo live workflow methods set timestamps/reviewer consistently\\n- Feature test covers token + workflow","notes":"Implemented Live Show data model: events.live_show_token + live_show_token_rotated_at; photos.live_status + timestamps/reviewer/rejection fields + indexes. Added PhotoLiveStatus enum and Photo workflow methods (markLivePending/approveForLiveShow/rejectForLiveShow). Added Event helpers (ensureLiveShowToken/rotateLiveShowToken). Tests: tests/Feature/LiveShowDataModelTest.php.","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-01-05T11:10:56.560421826+01:00","created_by":"soeren","updated_at":"2026-01-05T12:22:51.967913423+01:00","closed_at":"2026-01-05T12:22:51.967913423+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-t1k","depends_on_id":"fotospiel-app-vro","type":"blocks","created_at":"2026-01-05T11:12:20.345646244+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-t1k","depends_on_id":"fotospiel-app-h5d","type":"blocks","created_at":"2026-01-05T11:44:12.439413712+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-t1k","depends_on_id":"fotospiel-app-1eu","type":"blocks","created_at":"2026-01-05T11:44:22.588642567+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-t1k","depends_on_id":"fotospiel-app-1we","type":"blocks","created_at":"2026-01-05T11:44:31.775634827+01:00","created_by":"soeren"}]}
|
||||||
{"id":"fotospiel-app-t2s","title":"Uploader: multiple event profiles","description":"Part of epic fotospiel-app-5aa. Save multiple event profiles and allow quick switching.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:04:18.20222112+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:04:18.20222112+01:00"}
|
{"id":"fotospiel-app-t2s","title":"Uploader: multiple event profiles","description":"Part of epic fotospiel-app-5aa. Save multiple event profiles and allow quick switching.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:04:18.20222112+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:04:18.20222112+01:00"}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
fotospiel-app-29r
|
fotospiel-app-de7
|
||||||
|
|||||||
@@ -337,8 +337,8 @@ Tokens are design system values that can be referenced using the `$` prefix.
|
|||||||
|
|
||||||
### Color Tokens
|
### Color Tokens
|
||||||
|
|
||||||
- `accent`: #FFB6C1
|
- `accent`: #3D5AFE
|
||||||
- `accentSoft`: #FFE5EC
|
- `accentSoft`: #E8ECFF
|
||||||
- `blue10Dark`: hsl(209, 100%, 60.6%)
|
- `blue10Dark`: hsl(209, 100%, 60.6%)
|
||||||
- `blue10Light`: hsl(208, 100%, 47.3%)
|
- `blue10Light`: hsl(208, 100%, 47.3%)
|
||||||
- `blue11Dark`: hsl(210, 100%, 66.1%)
|
- `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%)
|
- `blue8Light`: hsl(206, 81.9%, 65.3%)
|
||||||
- `blue9Dark`: hsl(206, 100%, 50.0%)
|
- `blue9Dark`: hsl(206, 100%, 50.0%)
|
||||||
- `blue9Light`: hsl(206, 100%, 50.0%)
|
- `blue9Light`: hsl(206, 100%, 50.0%)
|
||||||
- `border`: #F2E4DA
|
- `border`: #F3D6C9
|
||||||
- `danger`: #E04848
|
- `danger`: #EF4444
|
||||||
- `gray10Dark`: hsl(0, 0%, 49.4%)
|
- `gray10Dark`: hsl(0, 0%, 49.4%)
|
||||||
- `gray10Light`: hsl(0, 0%, 52.3%)
|
- `gray10Light`: hsl(0, 0%, 52.3%)
|
||||||
- `gray11Dark`: hsl(0, 0%, 62.8%)
|
- `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%)
|
- `green8Light`: hsl(151, 40.2%, 54.1%)
|
||||||
- `green9Dark`: hsl(151, 55.0%, 41.5%)
|
- `green9Dark`: hsl(151, 55.0%, 41.5%)
|
||||||
- `green9Light`: hsl(151, 55.0%, 41.5%)
|
- `green9Light`: hsl(151, 55.0%, 41.5%)
|
||||||
- `muted`: #F4ECE8
|
- `muted`: #FFF6F0
|
||||||
- `orange10Dark`: hsl(24, 100%, 58.5%)
|
- `orange10Dark`: hsl(24, 100%, 58.5%)
|
||||||
- `orange10Light`: hsl(24, 100%, 46.5%)
|
- `orange10Light`: hsl(24, 100%, 46.5%)
|
||||||
- `orange11Dark`: hsl(24, 100%, 62.2%)
|
- `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%)
|
- `pink8Light`: hsl(323, 60.3%, 72.4%)
|
||||||
- `pink9Dark`: hsl(322, 65.0%, 54.5%)
|
- `pink9Dark`: hsl(322, 65.0%, 54.5%)
|
||||||
- `pink9Light`: hsl(322, 65.0%, 54.5%)
|
- `pink9Light`: hsl(322, 65.0%, 54.5%)
|
||||||
- `primary`: #FF5A5F
|
- `primary`: #FF5C5C
|
||||||
- `purple10Dark`: hsl(273, 57.3%, 59.1%)
|
- `purple10Dark`: hsl(273, 57.3%, 59.1%)
|
||||||
- `purple10Light`: hsl(272, 46.8%, 50.3%)
|
- `purple10Light`: hsl(272, 46.8%, 50.3%)
|
||||||
- `purple11Dark`: hsl(275, 80.0%, 71.0%)
|
- `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%)
|
- `red8Light`: hsl(359, 69.5%, 74.3%)
|
||||||
- `red9Dark`: hsl(358, 75.0%, 59.0%)
|
- `red9Dark`: hsl(358, 75.0%, 59.0%)
|
||||||
- `red9Light`: hsl(358, 75.0%, 59.0%)
|
- `red9Light`: hsl(358, 75.0%, 59.0%)
|
||||||
- `success`: #06D6A0
|
- `success`: #22C55E
|
||||||
- `surface`: #ffffff
|
- `surface`: #ffffff
|
||||||
- `text`: #1F2937
|
- `text`: #0B132B
|
||||||
- `warning`: #F5C542
|
- `warning`: #FBBF24
|
||||||
- `yellow10Dark`: hsl(54, 100%, 68.0%)
|
- `yellow10Dark`: hsl(54, 100%, 68.0%)
|
||||||
- `yellow10Light`: hsl(50, 100%, 48.5%)
|
- `yellow10Light`: hsl(50, 100%, 48.5%)
|
||||||
- `yellow11Dark`: hsl(48, 100%, 47.0%)
|
- `yellow11Dark`: hsl(48, 100%, 47.0%)
|
||||||
|
|||||||
@@ -4160,16 +4160,16 @@ var tokens3 = {
|
|||||||
...tokens2,
|
...tokens2,
|
||||||
color: {
|
color: {
|
||||||
...tokens2.color,
|
...tokens2.color,
|
||||||
primary: "#FF5A5F",
|
primary: "#FF5C5C",
|
||||||
accent: "#FFB6C1",
|
accent: "#3D5AFE",
|
||||||
accentSoft: "#FFE5EC",
|
accentSoft: "#E8ECFF",
|
||||||
success: "#06D6A0",
|
success: "#22C55E",
|
||||||
warning: "#F5C542",
|
warning: "#FBBF24",
|
||||||
danger: "#E04848",
|
danger: "#EF4444",
|
||||||
surface: "#ffffff",
|
surface: "#ffffff",
|
||||||
muted: "#F4ECE8",
|
muted: "#FFF6F0",
|
||||||
border: "#F2E4DA",
|
border: "#F3D6C9",
|
||||||
text: "#1F2937"
|
text: "#0B132B"
|
||||||
},
|
},
|
||||||
radius: {
|
radius: {
|
||||||
...tokens2.radius,
|
...tokens2.radius,
|
||||||
@@ -4188,53 +4188,53 @@ var themes3 = {
|
|||||||
...themes2.light,
|
...themes2.light,
|
||||||
primary: tokens3.color.primary,
|
primary: tokens3.color.primary,
|
||||||
accent: tokens3.color.accent,
|
accent: tokens3.color.accent,
|
||||||
background: "#FFF8F5",
|
background: "#FFF1E8",
|
||||||
backgroundHover: "#FFF1EC",
|
backgroundHover: "#FFE8DD",
|
||||||
backgroundPress: "#FFE7E0",
|
backgroundPress: "#FFE1D2",
|
||||||
backgroundStrong: tokens3.color.surface,
|
backgroundStrong: tokens3.color.surface,
|
||||||
backgroundTransparent: "rgba(255, 248, 245, 0)",
|
backgroundTransparent: "rgba(255, 241, 232, 0)",
|
||||||
color: tokens3.color.text,
|
color: tokens3.color.text,
|
||||||
colorHover: "#111827",
|
colorHover: "#091024",
|
||||||
colorPress: "#0F172A",
|
colorPress: "#091024",
|
||||||
colorFocus: "#0F172A",
|
colorFocus: "#091024",
|
||||||
borderColor: tokens3.color.border,
|
borderColor: tokens3.color.border,
|
||||||
borderColorHover: "#EAD5C9",
|
borderColorHover: "#EBCABA",
|
||||||
borderColorPress: "#E0C9BC",
|
borderColorPress: "#E1BFAE",
|
||||||
shadowColor: "rgba(31, 41, 55, 0.12)",
|
shadowColor: "rgba(11, 19, 43, 0.16)",
|
||||||
shadowColorPress: "rgba(31, 41, 55, 0.16)",
|
shadowColorPress: "rgba(11, 19, 43, 0.2)",
|
||||||
shadowColorFocus: "rgba(31, 41, 55, 0.18)",
|
shadowColorFocus: "rgba(11, 19, 43, 0.24)",
|
||||||
surface: tokens3.color.surface,
|
surface: tokens3.color.surface,
|
||||||
muted: tokens3.color.muted,
|
muted: tokens3.color.muted,
|
||||||
blue3: tokens3.color.accentSoft,
|
blue3: tokens3.color.accentSoft,
|
||||||
blue6: tokens3.color.accent,
|
blue6: tokens3.color.accent,
|
||||||
blue10: tokens3.color.primary,
|
blue10: tokens3.color.primary,
|
||||||
blue11: "#C2413B"
|
blue11: "#1E36F1"
|
||||||
},
|
},
|
||||||
dark: {
|
dark: {
|
||||||
...themes2.dark,
|
...themes2.dark,
|
||||||
primary: tokens3.color.primary,
|
primary: tokens3.color.primary,
|
||||||
accent: tokens3.color.accent,
|
accent: tokens3.color.accent,
|
||||||
background: "#171219",
|
background: "#0B132B",
|
||||||
backgroundHover: "#1F1A23",
|
backgroundHover: "#101A36",
|
||||||
backgroundPress: "#26212B",
|
backgroundPress: "#132142",
|
||||||
backgroundStrong: "#1F1A23",
|
backgroundStrong: "#101A36",
|
||||||
backgroundTransparent: "rgba(23, 18, 25, 0)",
|
backgroundTransparent: "rgba(11, 19, 43, 0)",
|
||||||
color: "#F8F6F2",
|
color: "#F8FAFF",
|
||||||
colorHover: "#FFFFFF",
|
colorHover: "#FFFFFF",
|
||||||
colorPress: "#FDF8F5",
|
colorPress: "#F2F6FF",
|
||||||
colorFocus: "#FFFFFF",
|
colorFocus: "#FFFFFF",
|
||||||
borderColor: "#2C2531",
|
borderColor: "#1F2A4A",
|
||||||
borderColorHover: "#3A3240",
|
borderColorHover: "#29345A",
|
||||||
borderColorPress: "#443C4A",
|
borderColorPress: "#313D67",
|
||||||
shadowColor: "rgba(0, 0, 0, 0.55)",
|
shadowColor: "rgba(0, 0, 0, 0.55)",
|
||||||
shadowColorPress: "rgba(0, 0, 0, 0.65)",
|
shadowColorPress: "rgba(0, 0, 0, 0.65)",
|
||||||
shadowColorFocus: "rgba(0, 0, 0, 0.6)",
|
shadowColorFocus: "rgba(0, 0, 0, 0.6)",
|
||||||
surface: "#1F1A23",
|
surface: "#0F1B36",
|
||||||
muted: "#241E28",
|
muted: "#121F3D",
|
||||||
blue3: "#2B1D23",
|
blue3: "#1B2550",
|
||||||
blue6: "#5A2D34",
|
blue6: "#3D5AFE",
|
||||||
blue10: "#FF7A7F",
|
blue10: "#FF5C5C",
|
||||||
blue11: "#FFB3B6"
|
blue11: "#FF8A8A"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
var sharedWeights = {
|
var sharedWeights = {
|
||||||
@@ -4254,12 +4254,12 @@ var fonts2 = {
|
|||||||
},
|
},
|
||||||
heading: {
|
heading: {
|
||||||
...defaultConfig.fonts.heading,
|
...defaultConfig.fonts.heading,
|
||||||
family: "Manrope",
|
family: "Archivo Black",
|
||||||
weight: sharedWeights
|
weight: sharedWeights
|
||||||
},
|
},
|
||||||
display: {
|
display: {
|
||||||
...defaultConfig.fonts.heading,
|
...defaultConfig.fonts.heading,
|
||||||
family: "Fraunces",
|
family: "Archivo Black",
|
||||||
weight: sharedWeights
|
weight: sharedWeights
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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 $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)
|
public function __construct(private EventStorageManager $eventStorageManager)
|
||||||
{
|
{
|
||||||
@@ -129,7 +129,7 @@ class SeedDemoSwitcherTenants extends Command
|
|||||||
$slugs = [
|
$slugs = [
|
||||||
'starter' => 'Starter',
|
'starter' => 'Starter',
|
||||||
'standard' => 'Standard',
|
'standard' => 'Standard',
|
||||||
's-small-reseller' => 'Reseller S',
|
's-small-reseller' => 'Partner Start',
|
||||||
];
|
];
|
||||||
|
|
||||||
$packages = [];
|
$packages = [];
|
||||||
@@ -165,10 +165,10 @@ class SeedDemoSwitcherTenants extends Command
|
|||||||
{
|
{
|
||||||
$tenant = $this->upsertTenant(
|
$tenant = $this->upsertTenant(
|
||||||
slug: 'demo-standard-empty',
|
slug: 'demo-standard-empty',
|
||||||
name: 'Demo Standard (ohne Event)',
|
name: 'Demo Starter (ohne Event)',
|
||||||
contactEmail: 'standard-empty@demo.fotospiel',
|
contactEmail: 'standard-empty@demo.fotospiel',
|
||||||
attributes: [
|
attributes: [
|
||||||
'subscription_tier' => 'standard',
|
'subscription_tier' => 'starter',
|
||||||
'subscription_status' => 'active',
|
'subscription_status' => 'active',
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -176,9 +176,9 @@ class SeedDemoSwitcherTenants extends Command
|
|||||||
$this->upsertAdmin($tenant, 'standard-empty@demo.fotospiel');
|
$this->upsertAdmin($tenant, 'standard-empty@demo.fotospiel');
|
||||||
|
|
||||||
TenantPackage::updateOrCreate(
|
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),
|
'purchased_at' => Carbon::now()->subDays(1),
|
||||||
'expires_at' => Carbon::now()->addMonths(12),
|
'expires_at' => Carbon::now()->addMonths(12),
|
||||||
'used_events' => 0,
|
'used_events' => 0,
|
||||||
@@ -186,7 +186,7 @@ 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 seedCustomerStarterWedding(array $packages, array $eventTypes): void
|
||||||
@@ -204,19 +204,19 @@ class SeedDemoSwitcherTenants extends Command
|
|||||||
$this->upsertAdmin($tenant, 'starter-wedding@demo.fotospiel');
|
$this->upsertAdmin($tenant, 'starter-wedding@demo.fotospiel');
|
||||||
|
|
||||||
TenantPackage::updateOrCreate(
|
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),
|
'purchased_at' => Carbon::now()->subDays(1),
|
||||||
'expires_at' => Carbon::now()->addMonths(12),
|
'expires_at' => Carbon::now()->addMonths(12),
|
||||||
'used_events' => 0,
|
'used_events' => 1,
|
||||||
'active' => true,
|
'active' => true,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
$event = $this->upsertEvent(
|
$event = $this->upsertEvent(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
package: $packages['standard'],
|
package: $packages['starter'],
|
||||||
eventType: $eventTypes['wedding'] ?? null,
|
eventType: $eventTypes['wedding'] ?? null,
|
||||||
attributes: [
|
attributes: [
|
||||||
'name' => ['de' => 'Hochzeit Mia & Jonas', 'en' => 'Wedding Mia & Jonas'],
|
'name' => ['de' => 'Hochzeit Mia & Jonas', 'en' => 'Wedding Mia & Jonas'],
|
||||||
@@ -232,17 +232,18 @@ class SeedDemoSwitcherTenants extends Command
|
|||||||
|
|
||||||
private function seedResellerActive(array $packages, array $eventTypes): void
|
private function seedResellerActive(array $packages, array $eventTypes): void
|
||||||
{
|
{
|
||||||
|
$eventPackage = $this->resolveIncludedPackage($packages['s-small-reseller'], $packages);
|
||||||
$tenant = $this->upsertTenant(
|
$tenant = $this->upsertTenant(
|
||||||
slug: 'demo-reseller-active',
|
slug: 'demo-reseller-active',
|
||||||
name: 'Demo Reseller Active',
|
name: 'Demo Partner Active',
|
||||||
contactEmail: 'reseller-active@demo.fotospiel',
|
contactEmail: 'partner-active@demo.fotospiel',
|
||||||
attributes: [
|
attributes: [
|
||||||
'subscription_tier' => 'reseller',
|
'subscription_tier' => 'reseller',
|
||||||
'subscription_status' => 'active',
|
'subscription_status' => 'active',
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->upsertAdmin($tenant, 'reseller-active@demo.fotospiel');
|
$this->upsertAdmin($tenant, 'partner-active@demo.fotospiel');
|
||||||
|
|
||||||
TenantPackage::updateOrCreate(
|
TenantPackage::updateOrCreate(
|
||||||
['tenant_id' => $tenant->id, 'package_id' => $packages['s-small-reseller']->id],
|
['tenant_id' => $tenant->id, 'package_id' => $packages['s-small-reseller']->id],
|
||||||
@@ -279,7 +280,7 @@ class SeedDemoSwitcherTenants extends Command
|
|||||||
foreach ($events as $index => $config) {
|
foreach ($events as $index => $config) {
|
||||||
$event = $this->upsertEvent(
|
$event = $this->upsertEvent(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
package: $packages['standard'],
|
package: $eventPackage,
|
||||||
eventType: $config['type'],
|
eventType: $config['type'],
|
||||||
attributes: [
|
attributes: [
|
||||||
'name' => $config['name'],
|
'name' => $config['name'],
|
||||||
@@ -296,17 +297,18 @@ class SeedDemoSwitcherTenants extends Command
|
|||||||
|
|
||||||
private function seedResellerFull(array $packages, array $eventTypes): void
|
private function seedResellerFull(array $packages, array $eventTypes): void
|
||||||
{
|
{
|
||||||
|
$eventPackage = $this->resolveIncludedPackage($packages['s-small-reseller'], $packages);
|
||||||
$tenant = $this->upsertTenant(
|
$tenant = $this->upsertTenant(
|
||||||
slug: 'demo-reseller-full',
|
slug: 'demo-reseller-full',
|
||||||
name: 'Demo Reseller Voll',
|
name: 'Demo Partner Voll',
|
||||||
contactEmail: 'reseller-full@demo.fotospiel',
|
contactEmail: 'partner-full@demo.fotospiel',
|
||||||
attributes: [
|
attributes: [
|
||||||
'subscription_tier' => 'reseller',
|
'subscription_tier' => 'reseller',
|
||||||
'subscription_status' => 'active',
|
'subscription_status' => 'active',
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->upsertAdmin($tenant, 'reseller-full@demo.fotospiel');
|
$this->upsertAdmin($tenant, 'partner-full@demo.fotospiel');
|
||||||
|
|
||||||
TenantPackage::updateOrCreate(
|
TenantPackage::updateOrCreate(
|
||||||
['tenant_id' => $tenant->id, 'package_id' => $packages['s-small-reseller']->id],
|
['tenant_id' => $tenant->id, 'package_id' => $packages['s-small-reseller']->id],
|
||||||
@@ -330,7 +332,7 @@ class SeedDemoSwitcherTenants extends Command
|
|||||||
foreach ($eventConfigs as $index => $config) {
|
foreach ($eventConfigs as $index => $config) {
|
||||||
$event = $this->upsertEvent(
|
$event = $this->upsertEvent(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
package: $packages['standard'],
|
package: $eventPackage,
|
||||||
eventType: $config['type'],
|
eventType: $config['type'],
|
||||||
attributes: [
|
attributes: [
|
||||||
'name' => $config['name'],
|
'name' => $config['name'],
|
||||||
@@ -357,8 +359,8 @@ class SeedDemoSwitcherTenants extends Command
|
|||||||
'settings' => [
|
'settings' => [
|
||||||
'branding' => [
|
'branding' => [
|
||||||
'logo_url' => null,
|
'logo_url' => null,
|
||||||
'primary_color' => '#1D4ED8',
|
'primary_color' => '#FF5A5F',
|
||||||
'secondary_color' => '#0F172A',
|
'secondary_color' => '#FFF8F5',
|
||||||
'font_family' => 'Inter, sans-serif',
|
'font_family' => 'Inter, sans-serif',
|
||||||
],
|
],
|
||||||
'features' => [
|
'features' => [
|
||||||
@@ -435,6 +437,19 @@ class SeedDemoSwitcherTenants extends Command
|
|||||||
return $event;
|
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
|
private function fallbackEventType(): ?EventType
|
||||||
{
|
{
|
||||||
$fallback = EventType::first();
|
$fallback = EventType::first();
|
||||||
|
|||||||
@@ -1025,10 +1025,10 @@ class EventPublicController extends BaseController
|
|||||||
private function resolveBrandingPayload(Event $event): array
|
private function resolveBrandingPayload(Event $event): array
|
||||||
{
|
{
|
||||||
$defaults = [
|
$defaults = [
|
||||||
'primary' => '#f43f5e',
|
'primary' => '#FF5A5F',
|
||||||
'secondary' => '#fb7185',
|
'secondary' => '#FFF8F5',
|
||||||
'background' => '#ffffff',
|
'background' => '#FFF8F5',
|
||||||
'surface' => '#ffffff',
|
'surface' => '#FFF8F5',
|
||||||
'font' => null,
|
'font' => null,
|
||||||
'size' => 'm',
|
'size' => 'm',
|
||||||
'logo_position' => 'left',
|
'logo_position' => 'left',
|
||||||
|
|||||||
@@ -277,13 +277,13 @@ class PackageController extends Controller
|
|||||||
'purchased_at' => now(),
|
'purchased_at' => now(),
|
||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
// Reseller subscription
|
// Partner / reseller Event-Kontingent package
|
||||||
\App\Models\TenantPackage::create([
|
\App\Models\TenantPackage::create([
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->id,
|
||||||
'package_id' => $package->id,
|
'package_id' => $package->id,
|
||||||
'price' => $package->price,
|
'price' => $package->price,
|
||||||
'purchased_at' => now(),
|
'purchased_at' => now(),
|
||||||
'expires_at' => now()->addYear(),
|
'expires_at' => null,
|
||||||
'active' => true,
|
'active' => true,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api;
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\Photobooth\PhotoboothConnectRedeemRequest;
|
use App\Http\Requests\Photobooth\PhotoboothConnectRedeemRequest;
|
||||||
|
use App\Models\Event;
|
||||||
use App\Services\Photobooth\PhotoboothConnectCodeService;
|
use App\Services\Photobooth\PhotoboothConnectCodeService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
@@ -33,7 +34,8 @@ class PhotoboothConnectController extends Controller
|
|||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'data' => [
|
'data' => [
|
||||||
'upload_url' => route('api.v1.photobooth.sparkbooth.upload'),
|
'event_name' => $this->resolveEventName($event),
|
||||||
|
'upload_url' => route('api.v1.photobooth.upload'),
|
||||||
'username' => $setting->username,
|
'username' => $setting->username,
|
||||||
'password' => $setting->password,
|
'password' => $setting->password,
|
||||||
'expires_at' => optional($setting->expires_at)->toIso8601String(),
|
'expires_at' => optional($setting->expires_at)->toIso8601String(),
|
||||||
@@ -42,4 +44,27 @@ class PhotoboothConnectController extends Controller
|
|||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function resolveEventName(?Event $event): ?string
|
||||||
|
{
|
||||||
|
if (! $event) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = $event->name;
|
||||||
|
|
||||||
|
if (is_string($name) && trim($name) !== '') {
|
||||||
|
return $name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($name)) {
|
||||||
|
foreach ($name as $value) {
|
||||||
|
if (is_string($value) && trim($value) !== '') {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $event->slug ?: null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ use App\Models\Tenant;
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\EventJoinTokenService;
|
use App\Services\EventJoinTokenService;
|
||||||
use App\Support\ApiError;
|
use App\Support\ApiError;
|
||||||
|
use App\Support\TenantMemberPermissions;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
@@ -83,6 +84,8 @@ class EventController extends Controller
|
|||||||
|
|
||||||
public function store(EventStoreRequest $request): JsonResponse
|
public function store(EventStoreRequest $request): JsonResponse
|
||||||
{
|
{
|
||||||
|
TenantMemberPermissions::ensureTenantPermission($request, 'events:manage');
|
||||||
|
|
||||||
$tenant = $request->attributes->get('tenant');
|
$tenant = $request->attributes->get('tenant');
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
$tenantId = $request->attributes->get('tenant_id');
|
$tenantId = $request->attributes->get('tenant_id');
|
||||||
@@ -99,6 +102,9 @@ class EventController extends Controller
|
|||||||
|
|
||||||
$requestedPackageId = $isSuperAdmin ? $request->integer('package_id') : null;
|
$requestedPackageId = $isSuperAdmin ? $request->integer('package_id') : null;
|
||||||
unset($validated['package_id']);
|
unset($validated['package_id']);
|
||||||
|
$requestedServiceSlug = $request->input('service_package_slug');
|
||||||
|
$requestedServiceSlug = is_string($requestedServiceSlug) && $requestedServiceSlug !== '' ? $requestedServiceSlug : null;
|
||||||
|
unset($validated['service_package_slug']);
|
||||||
|
|
||||||
$tenantPackage = $tenant->tenantPackages()
|
$tenantPackage = $tenant->tenantPackages()
|
||||||
->with('package')
|
->with('package')
|
||||||
@@ -116,6 +122,18 @@ class EventController extends Controller
|
|||||||
$package = $this->resolveOwnerPackage();
|
$package = $this->resolveOwnerPackage();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$billingTenantPackage = null;
|
||||||
|
if (! $package) {
|
||||||
|
$billingTenantPackage = $requestedServiceSlug
|
||||||
|
? $tenant->getActiveResellerPackageFor($requestedServiceSlug)
|
||||||
|
: $tenant->getActiveResellerPackage();
|
||||||
|
|
||||||
|
if ($billingTenantPackage && $billingTenantPackage->package) {
|
||||||
|
$package = $billingTenantPackage->package;
|
||||||
|
$requestedServiceSlug = $requestedServiceSlug ?: $package->included_package_slug;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (! $package && $tenantPackage) {
|
if (! $package && $tenantPackage) {
|
||||||
$package = $tenantPackage->package ?? Package::query()->find($tenantPackage->package_id);
|
$package = $tenantPackage->package ?? Package::query()->find($tenantPackage->package_id);
|
||||||
}
|
}
|
||||||
@@ -126,6 +144,11 @@ class EventController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$billingIsReseller = $package->isReseller();
|
||||||
|
$eventServicePackage = $billingIsReseller
|
||||||
|
? $this->resolveResellerEventPackageForSlug($requestedServiceSlug ?: $package->included_package_slug)
|
||||||
|
: $package;
|
||||||
|
|
||||||
$requiresWaiver = $package->isEndcustomer();
|
$requiresWaiver = $package->isEndcustomer();
|
||||||
$latestPurchase = $requiresWaiver ? $this->resolveLatestPackagePurchase($tenant, $package) : null;
|
$latestPurchase = $requiresWaiver ? $this->resolveLatestPackagePurchase($tenant, $package) : null;
|
||||||
$existingWaiver = $latestPurchase ? data_get($latestPurchase->metadata, 'consents.digital_content_waiver_at') : null;
|
$existingWaiver = $latestPurchase ? data_get($latestPurchase->metadata, 'consents.digital_content_waiver_at') : null;
|
||||||
@@ -161,8 +184,8 @@ class EventController extends Controller
|
|||||||
unset($eventData['features']);
|
unset($eventData['features']);
|
||||||
}
|
}
|
||||||
|
|
||||||
$settings['branding_allowed'] = $package->branding_allowed !== false;
|
$settings['branding_allowed'] = $eventServicePackage->branding_allowed !== false;
|
||||||
$settings['watermark_allowed'] = $package->watermark_allowed !== false;
|
$settings['watermark_allowed'] = $eventServicePackage->watermark_allowed !== false;
|
||||||
|
|
||||||
$eventData['settings'] = $settings;
|
$eventData['settings'] = $settings;
|
||||||
|
|
||||||
@@ -190,21 +213,23 @@ class EventController extends Controller
|
|||||||
|
|
||||||
$eventData = Arr::only($eventData, $allowed);
|
$eventData = Arr::only($eventData, $allowed);
|
||||||
|
|
||||||
$event = DB::transaction(function () use ($tenant, $eventData, $package, $isSuperAdmin) {
|
$event = DB::transaction(function () use ($tenant, $eventData, $eventServicePackage, $billingIsReseller, $isSuperAdmin) {
|
||||||
$event = Event::create($eventData);
|
$event = Event::create($eventData);
|
||||||
|
|
||||||
EventPackage::create([
|
EventPackage::create([
|
||||||
'event_id' => $event->id,
|
'event_id' => $event->id,
|
||||||
'package_id' => $package->id,
|
'package_id' => $eventServicePackage->id,
|
||||||
'purchased_price' => $package->price,
|
'purchased_price' => $billingIsReseller ? 0 : $eventServicePackage->price,
|
||||||
'purchased_at' => now(),
|
'purchased_at' => now(),
|
||||||
'gallery_expires_at' => $package->gallery_days ? now()->addDays($package->gallery_days) : null,
|
'gallery_expires_at' => $eventServicePackage->gallery_days
|
||||||
|
? now()->addDays($eventServicePackage->gallery_days)
|
||||||
|
: null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if ($package->isReseller() && ! $isSuperAdmin) {
|
if ($billingIsReseller && ! $isSuperAdmin) {
|
||||||
$note = sprintf('Event #%d created (%s)', $event->id, $event->name);
|
$note = sprintf('Event #%d created (%s)', $event->id, $event->name);
|
||||||
|
|
||||||
if (! $tenant->consumeEventAllowance(1, 'event.create', $note)) {
|
if (! $tenant->consumeEventAllowanceFor($eventServicePackage->slug, 1, 'event.create', $note)) {
|
||||||
throw new HttpException(402, 'Insufficient package allowance.');
|
throw new HttpException(402, 'Insufficient package allowance.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -227,6 +252,47 @@ class EventController extends Controller
|
|||||||
], 201);
|
], 201);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function resolveResellerDefaultEventPackage(): Package
|
||||||
|
{
|
||||||
|
return $this->resolveResellerEventPackageForSlug('standard');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveResellerEventPackageForSlug(?string $slug): Package
|
||||||
|
{
|
||||||
|
if (is_string($slug) && $slug !== '') {
|
||||||
|
$match = Package::query()
|
||||||
|
->where('type', 'endcustomer')
|
||||||
|
->where('slug', $slug)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($match) {
|
||||||
|
return $match;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$default = Package::query()
|
||||||
|
->where('type', 'endcustomer')
|
||||||
|
->where('slug', 'standard')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($default) {
|
||||||
|
return $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fallback = Package::query()
|
||||||
|
->where('type', 'endcustomer')
|
||||||
|
->orderBy('price')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $fallback) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'package_id' => __('Aktuell ist kein Endkunden-Paket verfügbar. Bitte kontaktiere den Support.'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $fallback;
|
||||||
|
}
|
||||||
|
|
||||||
private function resolveLatestPackagePurchase(Tenant $tenant, Package $package): ?PackagePurchase
|
private function resolveLatestPackagePurchase(Tenant $tenant, Package $package): ?PackagePurchase
|
||||||
{
|
{
|
||||||
return PackagePurchase::query()
|
return PackagePurchase::query()
|
||||||
@@ -320,14 +386,24 @@ class EventController extends Controller
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TenantMemberPermissions::ensureEventPermission($request, $event, 'events:manage');
|
||||||
|
|
||||||
$validated = $request->validated();
|
$validated = $request->validated();
|
||||||
|
$nameProvided = array_key_exists('name', $validated);
|
||||||
|
|
||||||
|
$validated = array_merge([
|
||||||
|
'name' => $event->name,
|
||||||
|
'event_type_id' => $event->event_type_id,
|
||||||
|
'event_date' => $event->date?->toDateString(),
|
||||||
|
'status' => $event->status,
|
||||||
|
], $validated);
|
||||||
|
|
||||||
if (isset($validated['event_date'])) {
|
if (isset($validated['event_date'])) {
|
||||||
$validated['date'] = $validated['event_date'];
|
$validated['date'] = $validated['event_date'];
|
||||||
unset($validated['event_date']);
|
unset($validated['event_date']);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($validated['name'] !== $event->name) {
|
if ($nameProvided && $validated['name'] !== $event->name) {
|
||||||
$validated['slug'] = $this->generateUniqueSlug($validated['name'], $tenantId, $event->id);
|
$validated['slug'] = $this->generateUniqueSlug($validated['name'], $tenantId, $event->id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,9 +425,14 @@ class EventController extends Controller
|
|||||||
$validated['settings']['watermark_allowed'] = $watermarkAllowed;
|
$validated['settings']['watermark_allowed'] = $watermarkAllowed;
|
||||||
|
|
||||||
$settings = $validated['settings'];
|
$settings = $validated['settings'];
|
||||||
|
$branding = Arr::get($settings, 'branding', []);
|
||||||
$watermark = Arr::get($settings, 'watermark', []);
|
$watermark = Arr::get($settings, 'watermark', []);
|
||||||
$existingWatermark = is_array($watermark) ? $watermark : [];
|
$existingWatermark = is_array($watermark) ? $watermark : [];
|
||||||
|
|
||||||
|
if (is_array($branding)) {
|
||||||
|
$settings['branding'] = $this->normalizeBrandingSettings($branding, $event, $brandingAllowed);
|
||||||
|
}
|
||||||
|
|
||||||
if (is_array($watermark)) {
|
if (is_array($watermark)) {
|
||||||
$mode = $watermark['mode'] ?? 'base';
|
$mode = $watermark['mode'] ?? 'base';
|
||||||
$policy = $watermarkAllowed ? 'basic' : 'none';
|
$policy = $watermarkAllowed ? 'basic' : 'none';
|
||||||
@@ -442,6 +523,68 @@ class EventController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $branding
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function normalizeBrandingSettings(array $branding, Event $event, bool $brandingAllowed): array
|
||||||
|
{
|
||||||
|
$logoDataUrl = $branding['logo_data_url'] ?? null;
|
||||||
|
|
||||||
|
if (! $brandingAllowed) {
|
||||||
|
unset($branding['logo_data_url']);
|
||||||
|
|
||||||
|
return $branding;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_string($logoDataUrl) || trim($logoDataUrl) === '') {
|
||||||
|
unset($branding['logo_data_url']);
|
||||||
|
|
||||||
|
return $branding;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! preg_match('/^data:image\\/(png|webp|jpe?g);base64,(.+)$/i', $logoDataUrl, $matches)) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'settings.branding.logo_data_url' => __('Ungültiges Branding-Logo.'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = base64_decode($matches[2], true);
|
||||||
|
|
||||||
|
if ($decoded === false) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'settings.branding.logo_data_url' => __('Branding-Logo konnte nicht gelesen werden.'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strlen($decoded) > 1024 * 1024) { // 1 MB
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'settings.branding.logo_data_url' => __('Branding-Logo ist zu groß (max. 1 MB).'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$extension = str_starts_with(strtolower($matches[1]), 'jp') ? 'jpg' : strtolower($matches[1]);
|
||||||
|
$path = sprintf('branding/logos/event-%s.%s', $event->id, $extension);
|
||||||
|
Storage::disk('public')->put($path, $decoded);
|
||||||
|
|
||||||
|
$branding['logo_url'] = $path;
|
||||||
|
$branding['logo_mode'] = 'upload';
|
||||||
|
$branding['logo_value'] = $path;
|
||||||
|
|
||||||
|
$logo = $branding['logo'] ?? [];
|
||||||
|
if (! is_array($logo)) {
|
||||||
|
$logo = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$logo['mode'] = 'upload';
|
||||||
|
$logo['value'] = $path;
|
||||||
|
$branding['logo'] = $logo;
|
||||||
|
|
||||||
|
unset($branding['logo_data_url']);
|
||||||
|
|
||||||
|
return $branding;
|
||||||
|
}
|
||||||
|
|
||||||
public function destroy(Request $request, Event $event): JsonResponse
|
public function destroy(Request $request, Event $event): JsonResponse
|
||||||
{
|
{
|
||||||
$tenantId = $request->attributes->get('tenant_id');
|
$tenantId = $request->attributes->get('tenant_id');
|
||||||
@@ -456,6 +599,8 @@ class EventController extends Controller
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TenantMemberPermissions::ensureEventPermission($request, $event, 'events:manage');
|
||||||
|
|
||||||
$event->delete();
|
$event->delete();
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use App\Models\Event;
|
|||||||
use App\Models\GuestNotification;
|
use App\Models\GuestNotification;
|
||||||
use App\Models\GuestPolicySetting;
|
use App\Models\GuestPolicySetting;
|
||||||
use App\Services\GuestNotificationService;
|
use App\Services\GuestNotificationService;
|
||||||
|
use App\Support\TenantMemberPermissions;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
@@ -23,6 +24,7 @@ class EventGuestNotificationController extends Controller
|
|||||||
public function index(Request $request, Event $event): JsonResponse
|
public function index(Request $request, Event $event): JsonResponse
|
||||||
{
|
{
|
||||||
$this->assertEventTenant($request, $event);
|
$this->assertEventTenant($request, $event);
|
||||||
|
TenantMemberPermissions::ensureEventPermission($request, $event, 'guest-notifications:manage');
|
||||||
|
|
||||||
$limit = max(1, min(100, (int) $request->integer('limit', 25)));
|
$limit = max(1, min(100, (int) $request->integer('limit', 25)));
|
||||||
|
|
||||||
@@ -38,6 +40,7 @@ class EventGuestNotificationController extends Controller
|
|||||||
public function store(BroadcastGuestNotificationRequest $request, Event $event): JsonResponse
|
public function store(BroadcastGuestNotificationRequest $request, Event $event): JsonResponse
|
||||||
{
|
{
|
||||||
$this->assertEventTenant($request, $event);
|
$this->assertEventTenant($request, $event);
|
||||||
|
TenantMemberPermissions::ensureEventPermission($request, $event, 'guest-notifications:manage');
|
||||||
|
|
||||||
$data = $request->validated();
|
$data = $request->validated();
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use App\Http\Resources\Tenant\EventJoinTokenResource;
|
|||||||
use App\Models\Event;
|
use App\Models\Event;
|
||||||
use App\Models\EventJoinToken;
|
use App\Models\EventJoinToken;
|
||||||
use App\Services\EventJoinTokenService;
|
use App\Services\EventJoinTokenService;
|
||||||
|
use App\Support\TenantMemberPermissions;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
@@ -19,7 +20,7 @@ class EventJoinTokenController extends Controller
|
|||||||
|
|
||||||
public function index(Request $request, Event $event): AnonymousResourceCollection
|
public function index(Request $request, Event $event): AnonymousResourceCollection
|
||||||
{
|
{
|
||||||
$this->authorizeEvent($request, $event);
|
$this->authorizeEvent($request, $event, 'join-tokens:manage');
|
||||||
|
|
||||||
$tokens = $event->joinTokens()
|
$tokens = $event->joinTokens()
|
||||||
->orderByDesc('created_at')
|
->orderByDesc('created_at')
|
||||||
@@ -30,7 +31,7 @@ class EventJoinTokenController extends Controller
|
|||||||
|
|
||||||
public function store(Request $request, Event $event): JsonResponse
|
public function store(Request $request, Event $event): JsonResponse
|
||||||
{
|
{
|
||||||
$this->authorizeEvent($request, $event);
|
$this->authorizeEvent($request, $event, 'join-tokens:manage');
|
||||||
|
|
||||||
$validated = $this->validatePayload($request);
|
$validated = $this->validatePayload($request);
|
||||||
|
|
||||||
@@ -45,7 +46,7 @@ class EventJoinTokenController extends Controller
|
|||||||
|
|
||||||
public function update(Request $request, Event $event, EventJoinToken $joinToken): EventJoinTokenResource
|
public function update(Request $request, Event $event, EventJoinToken $joinToken): EventJoinTokenResource
|
||||||
{
|
{
|
||||||
$this->authorizeEvent($request, $event);
|
$this->authorizeEvent($request, $event, 'join-tokens:manage');
|
||||||
|
|
||||||
if ($joinToken->event_id !== $event->id) {
|
if ($joinToken->event_id !== $event->id) {
|
||||||
abort(404);
|
abort(404);
|
||||||
@@ -89,7 +90,7 @@ class EventJoinTokenController extends Controller
|
|||||||
|
|
||||||
public function destroy(Request $request, Event $event, EventJoinToken $joinToken): EventJoinTokenResource
|
public function destroy(Request $request, Event $event, EventJoinToken $joinToken): EventJoinTokenResource
|
||||||
{
|
{
|
||||||
$this->authorizeEvent($request, $event);
|
$this->authorizeEvent($request, $event, 'join-tokens:manage');
|
||||||
|
|
||||||
if ($joinToken->event_id !== $event->id) {
|
if ($joinToken->event_id !== $event->id) {
|
||||||
abort(404);
|
abort(404);
|
||||||
@@ -101,13 +102,17 @@ class EventJoinTokenController extends Controller
|
|||||||
return new EventJoinTokenResource($token);
|
return new EventJoinTokenResource($token);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function authorizeEvent(Request $request, Event $event): void
|
private function authorizeEvent(Request $request, Event $event, ?string $permission = null): void
|
||||||
{
|
{
|
||||||
$tenantId = $request->attributes->get('tenant_id');
|
$tenantId = $request->attributes->get('tenant_id');
|
||||||
|
|
||||||
if ($event->tenant_id !== $tenantId) {
|
if ($event->tenant_id !== $tenantId) {
|
||||||
abort(404, 'Event not found');
|
abort(404, 'Event not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($permission) {
|
||||||
|
TenantMemberPermissions::ensureEventPermission($request, $event, $permission);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function validatePayload(Request $request, bool $partial = false): array
|
private function validatePayload(Request $request, bool $partial = false): array
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
|
|||||||
use App\Models\Event;
|
use App\Models\Event;
|
||||||
use App\Models\EventJoinToken;
|
use App\Models\EventJoinToken;
|
||||||
use App\Support\JoinTokenLayoutRegistry;
|
use App\Support\JoinTokenLayoutRegistry;
|
||||||
|
use App\Support\TenantMemberPermissions;
|
||||||
use Dompdf\Dompdf;
|
use Dompdf\Dompdf;
|
||||||
use Dompdf\Options;
|
use Dompdf\Options;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -28,6 +29,7 @@ class EventJoinTokenLayoutController extends Controller
|
|||||||
public function index(Request $request, Event $event, EventJoinToken $joinToken)
|
public function index(Request $request, Event $event, EventJoinToken $joinToken)
|
||||||
{
|
{
|
||||||
$this->ensureBelongsToEvent($event, $joinToken);
|
$this->ensureBelongsToEvent($event, $joinToken);
|
||||||
|
TenantMemberPermissions::ensureEventPermission($request, $event, 'join-tokens:manage');
|
||||||
|
|
||||||
$layouts = JoinTokenLayoutRegistry::toResponse(function (string $layoutId, string $format) use ($event, $joinToken) {
|
$layouts = JoinTokenLayoutRegistry::toResponse(function (string $layoutId, string $format) use ($event, $joinToken) {
|
||||||
return route('api.v1.tenant.events.join-tokens.layouts.download', [
|
return route('api.v1.tenant.events.join-tokens.layouts.download', [
|
||||||
@@ -46,6 +48,7 @@ class EventJoinTokenLayoutController extends Controller
|
|||||||
public function download(Request $request, Event $event, EventJoinToken $joinToken, string $layout, string $format)
|
public function download(Request $request, Event $event, EventJoinToken $joinToken, string $layout, string $format)
|
||||||
{
|
{
|
||||||
$this->ensureBelongsToEvent($event, $joinToken);
|
$this->ensureBelongsToEvent($event, $joinToken);
|
||||||
|
TenantMemberPermissions::ensureEventPermission($request, $event, 'join-tokens:manage');
|
||||||
|
|
||||||
$layoutConfig = JoinTokenLayoutRegistry::find($layout);
|
$layoutConfig = JoinTokenLayoutRegistry::find($layout);
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use App\Models\Event;
|
|||||||
use App\Models\EventMember;
|
use App\Models\EventMember;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Support\TenantMemberPermissions;
|
||||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -22,6 +23,7 @@ class EventMemberController extends Controller
|
|||||||
public function index(Request $request, Event $event): JsonResponse
|
public function index(Request $request, Event $event): JsonResponse
|
||||||
{
|
{
|
||||||
$this->assertEventTenant($request, $event);
|
$this->assertEventTenant($request, $event);
|
||||||
|
TenantMemberPermissions::ensureEventPermission($request, $event, 'members:manage');
|
||||||
|
|
||||||
/** @var LengthAwarePaginator $members */
|
/** @var LengthAwarePaginator $members */
|
||||||
$members = $event->members()
|
$members = $event->members()
|
||||||
@@ -34,6 +36,7 @@ class EventMemberController extends Controller
|
|||||||
public function store(EventMemberInviteRequest $request, Event $event): JsonResponse
|
public function store(EventMemberInviteRequest $request, Event $event): JsonResponse
|
||||||
{
|
{
|
||||||
$this->assertEventTenant($request, $event);
|
$this->assertEventTenant($request, $event);
|
||||||
|
TenantMemberPermissions::ensureEventPermission($request, $event, 'members:manage');
|
||||||
|
|
||||||
$data = $request->validated();
|
$data = $request->validated();
|
||||||
$tenant = $this->resolveTenantFromRequest($request);
|
$tenant = $this->resolveTenantFromRequest($request);
|
||||||
@@ -92,6 +95,7 @@ class EventMemberController extends Controller
|
|||||||
public function destroy(Request $request, Event $event, EventMember $member): JsonResponse
|
public function destroy(Request $request, Event $event, EventMember $member): JsonResponse
|
||||||
{
|
{
|
||||||
$this->assertEventTenant($request, $event);
|
$this->assertEventTenant($request, $event);
|
||||||
|
TenantMemberPermissions::ensureEventPermission($request, $event, 'members:manage');
|
||||||
|
|
||||||
if ((int) $member->event_id !== (int) $event->id) {
|
if ((int) $member->event_id !== (int) $event->id) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\Tenant;
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\Event;
|
use App\Models\Event;
|
||||||
|
use App\Support\TenantMemberPermissions;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use SimpleSoftwareIO\QrCode\Facades\QrCode;
|
use SimpleSoftwareIO\QrCode\Facades\QrCode;
|
||||||
@@ -13,6 +14,7 @@ class LiveShowLinkController extends Controller
|
|||||||
public function show(Request $request, Event $event): JsonResponse
|
public function show(Request $request, Event $event): JsonResponse
|
||||||
{
|
{
|
||||||
$this->authorizeEvent($request, $event);
|
$this->authorizeEvent($request, $event);
|
||||||
|
TenantMemberPermissions::ensureEventPermission($request, $event, 'live-show:manage');
|
||||||
|
|
||||||
$token = $event->ensureLiveShowToken();
|
$token = $event->ensureLiveShowToken();
|
||||||
|
|
||||||
@@ -24,6 +26,7 @@ class LiveShowLinkController extends Controller
|
|||||||
public function rotate(Request $request, Event $event): JsonResponse
|
public function rotate(Request $request, Event $event): JsonResponse
|
||||||
{
|
{
|
||||||
$this->authorizeEvent($request, $event);
|
$this->authorizeEvent($request, $event);
|
||||||
|
TenantMemberPermissions::ensureEventPermission($request, $event, 'live-show:manage');
|
||||||
|
|
||||||
$token = $event->rotateLiveShowToken();
|
$token = $event->rotateLiveShowToken();
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use App\Http\Resources\Tenant\PhotoResource;
|
|||||||
use App\Models\Event;
|
use App\Models\Event;
|
||||||
use App\Models\Photo;
|
use App\Models\Photo;
|
||||||
use App\Support\ApiError;
|
use App\Support\ApiError;
|
||||||
|
use App\Support\TenantMemberPermissions;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
@@ -23,6 +24,7 @@ class LiveShowPhotoController extends Controller
|
|||||||
$event = Event::where('slug', $eventSlug)
|
$event = Event::where('slug', $eventSlug)
|
||||||
->where('tenant_id', $tenantId)
|
->where('tenant_id', $tenantId)
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
|
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
|
||||||
|
|
||||||
$liveStatus = $request->string('live_status', 'pending')->toString();
|
$liveStatus = $request->string('live_status', 'pending')->toString();
|
||||||
$perPage = (int) $request->input('per_page', 20);
|
$perPage = (int) $request->input('per_page', 20);
|
||||||
@@ -51,6 +53,7 @@ class LiveShowPhotoController extends Controller
|
|||||||
$event = Event::where('slug', $eventSlug)
|
$event = Event::where('slug', $eventSlug)
|
||||||
->where('tenant_id', $tenantId)
|
->where('tenant_id', $tenantId)
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
|
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
|
||||||
|
|
||||||
if ($photo->event_id !== $event->id) {
|
if ($photo->event_id !== $event->id) {
|
||||||
return ApiError::response(
|
return ApiError::response(
|
||||||
@@ -94,6 +97,7 @@ class LiveShowPhotoController extends Controller
|
|||||||
$event = Event::where('slug', $eventSlug)
|
$event = Event::where('slug', $eventSlug)
|
||||||
->where('tenant_id', $tenantId)
|
->where('tenant_id', $tenantId)
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
|
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
|
||||||
|
|
||||||
if ($photo->event_id !== $event->id) {
|
if ($photo->event_id !== $event->id) {
|
||||||
return ApiError::response(
|
return ApiError::response(
|
||||||
@@ -146,6 +150,7 @@ class LiveShowPhotoController extends Controller
|
|||||||
$event = Event::where('slug', $eventSlug)
|
$event = Event::where('slug', $eventSlug)
|
||||||
->where('tenant_id', $tenantId)
|
->where('tenant_id', $tenantId)
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
|
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
|
||||||
|
|
||||||
if ($photo->event_id !== $event->id) {
|
if ($photo->event_id !== $event->id) {
|
||||||
return ApiError::response(
|
return ApiError::response(
|
||||||
@@ -173,6 +178,7 @@ class LiveShowPhotoController extends Controller
|
|||||||
$event = Event::where('slug', $eventSlug)
|
$event = Event::where('slug', $eventSlug)
|
||||||
->where('tenant_id', $tenantId)
|
->where('tenant_id', $tenantId)
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
|
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
|
||||||
|
|
||||||
if ($photo->event_id !== $event->id) {
|
if ($photo->event_id !== $event->id) {
|
||||||
return ApiError::response(
|
return ApiError::response(
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ use App\Services\Packages\PackageUsageTracker;
|
|||||||
use App\Services\Storage\EventStorageManager;
|
use App\Services\Storage\EventStorageManager;
|
||||||
use App\Support\ApiError;
|
use App\Support\ApiError;
|
||||||
use App\Support\ImageHelper;
|
use App\Support\ImageHelper;
|
||||||
|
use App\Support\TenantMemberPermissions;
|
||||||
use App\Support\UploadStream;
|
use App\Support\UploadStream;
|
||||||
use App\Support\WatermarkConfigResolver;
|
use App\Support\WatermarkConfigResolver;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
@@ -524,15 +525,8 @@ class PhotoController extends Controller
|
|||||||
'alt_text' => ['sometimes', 'string', 'max:255'],
|
'alt_text' => ['sometimes', 'string', 'max:255'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Only tenant admins can moderate
|
if (isset($validated['status'])) {
|
||||||
if (isset($validated['status']) && ! $this->tokenHasScope($request, 'tenant-admin')) {
|
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
|
||||||
return ApiError::response(
|
|
||||||
'insufficient_scope',
|
|
||||||
'Insufficient Scopes',
|
|
||||||
'You are not allowed to moderate photos for this event.',
|
|
||||||
Response::HTTP_FORBIDDEN,
|
|
||||||
['required_scope' => 'tenant-admin']
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$photo->update($validated);
|
$photo->update($validated);
|
||||||
@@ -634,6 +628,7 @@ class PhotoController extends Controller
|
|||||||
$event = Event::where('slug', $eventSlug)
|
$event = Event::where('slug', $eventSlug)
|
||||||
->where('tenant_id', $tenantId)
|
->where('tenant_id', $tenantId)
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
|
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
|
||||||
|
|
||||||
if ($photo->event_id !== $event->id) {
|
if ($photo->event_id !== $event->id) {
|
||||||
return ApiError::response(
|
return ApiError::response(
|
||||||
@@ -657,6 +652,7 @@ class PhotoController extends Controller
|
|||||||
$event = Event::where('slug', $eventSlug)
|
$event = Event::where('slug', $eventSlug)
|
||||||
->where('tenant_id', $tenantId)
|
->where('tenant_id', $tenantId)
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
|
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
|
||||||
|
|
||||||
if ($photo->event_id !== $event->id) {
|
if ($photo->event_id !== $event->id) {
|
||||||
return ApiError::response(
|
return ApiError::response(
|
||||||
@@ -680,6 +676,7 @@ class PhotoController extends Controller
|
|||||||
$event = Event::where('slug', $eventSlug)
|
$event = Event::where('slug', $eventSlug)
|
||||||
->where('tenant_id', $tenantId)
|
->where('tenant_id', $tenantId)
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
|
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
|
||||||
|
|
||||||
$request->validate([
|
$request->validate([
|
||||||
'photo_ids' => 'required|array',
|
'photo_ids' => 'required|array',
|
||||||
@@ -725,6 +722,7 @@ class PhotoController extends Controller
|
|||||||
$event = Event::where('slug', $eventSlug)
|
$event = Event::where('slug', $eventSlug)
|
||||||
->where('tenant_id', $tenantId)
|
->where('tenant_id', $tenantId)
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
|
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
|
||||||
|
|
||||||
$request->validate([
|
$request->validate([
|
||||||
'photo_ids' => 'required|array',
|
'photo_ids' => 'required|array',
|
||||||
|
|||||||
@@ -3,12 +3,17 @@
|
|||||||
namespace App\Http\Controllers\Api\Tenant;
|
namespace App\Http\Controllers\Api\Tenant;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Photobooth\PhotoboothSendUploaderDownloadRequest;
|
||||||
use App\Http\Resources\Tenant\PhotoboothStatusResource;
|
use App\Http\Resources\Tenant\PhotoboothStatusResource;
|
||||||
|
use App\Mail\PhotoboothUploaderDownload;
|
||||||
use App\Models\Event;
|
use App\Models\Event;
|
||||||
use App\Models\PhotoboothSetting;
|
use App\Models\PhotoboothSetting;
|
||||||
use App\Services\Photobooth\PhotoboothProvisioner;
|
use App\Services\Photobooth\PhotoboothProvisioner;
|
||||||
|
use App\Support\LocaleConfig;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
class PhotoboothController extends Controller
|
class PhotoboothController extends Controller
|
||||||
{
|
{
|
||||||
@@ -69,6 +74,39 @@ class PhotoboothController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function sendUploaderDownloadEmail(PhotoboothSendUploaderDownloadRequest $request, Event $event): JsonResponse
|
||||||
|
{
|
||||||
|
$this->assertEventBelongsToTenant($request, $event);
|
||||||
|
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
if (! $user || ! $user->email) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'email' => __('No email address is configured for this account.'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$locale = LocaleConfig::canonicalize($user->preferred_locale ?: app()->getLocale());
|
||||||
|
$eventName = $this->resolveEventName($event, $locale);
|
||||||
|
$recipientName = $user->fullName ?? $user->name ?? $user->email;
|
||||||
|
|
||||||
|
$mail = (new PhotoboothUploaderDownload(
|
||||||
|
recipientName: $recipientName,
|
||||||
|
eventName: $eventName,
|
||||||
|
links: [
|
||||||
|
'windows' => url('/downloads/PhotoboothUploader-win-x64.exe'),
|
||||||
|
'macos' => url('/downloads/PhotoboothUploader-macos-x64'),
|
||||||
|
'linux' => url('/downloads/PhotoboothUploader-linux-x64'),
|
||||||
|
],
|
||||||
|
))->locale($locale);
|
||||||
|
|
||||||
|
Mail::to($user->email)->queue($mail);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => __('Download links sent via email.'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
protected function resource(Event $event): PhotoboothStatusResource
|
protected function resource(Event $event): PhotoboothStatusResource
|
||||||
{
|
{
|
||||||
return PhotoboothStatusResource::make([
|
return PhotoboothStatusResource::make([
|
||||||
@@ -92,4 +130,30 @@ class PhotoboothController extends Controller
|
|||||||
|
|
||||||
return in_array($mode, ['sparkbooth', 'ftp'], true) ? $mode : 'ftp';
|
return in_array($mode, ['sparkbooth', 'ftp'], true) ? $mode : 'ftp';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function resolveEventName(Event $event, ?string $locale = null): string
|
||||||
|
{
|
||||||
|
$name = $event->name;
|
||||||
|
|
||||||
|
if (is_string($name) && trim($name) !== '') {
|
||||||
|
return $name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($name)) {
|
||||||
|
$locale = $locale ?: app()->getLocale();
|
||||||
|
$localized = $name[$locale] ?? null;
|
||||||
|
|
||||||
|
if (is_string($localized) && trim($localized) !== '') {
|
||||||
|
return $localized;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($name as $value) {
|
||||||
|
if (is_string($value) && trim($value) !== '') {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $event->slug ?: __('emails.photobooth_uploader.event_fallback');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,8 +113,8 @@ class SettingsController extends Controller
|
|||||||
$defaultSettings = [
|
$defaultSettings = [
|
||||||
'branding' => [
|
'branding' => [
|
||||||
'logo_url' => null,
|
'logo_url' => null,
|
||||||
'primary_color' => '#3B82F6',
|
'primary_color' => '#FF5A5F',
|
||||||
'secondary_color' => '#1F2937',
|
'secondary_color' => '#FFF8F5',
|
||||||
'font_family' => 'Inter, sans-serif',
|
'font_family' => 'Inter, sans-serif',
|
||||||
],
|
],
|
||||||
'features' => [
|
'features' => [
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use App\Models\Task;
|
|||||||
use App\Models\TaskCollection;
|
use App\Models\TaskCollection;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Support\ApiError;
|
use App\Support\ApiError;
|
||||||
|
use App\Support\TenantMemberPermissions;
|
||||||
use App\Support\TenantRequestResolver;
|
use App\Support\TenantRequestResolver;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -66,6 +67,8 @@ class TaskController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function store(TaskStoreRequest $request): JsonResponse
|
public function store(TaskStoreRequest $request): JsonResponse
|
||||||
{
|
{
|
||||||
|
TenantMemberPermissions::ensureTenantPermission($request, 'tasks:manage');
|
||||||
|
|
||||||
$tenant = $this->currentTenant($request);
|
$tenant = $this->currentTenant($request);
|
||||||
$collectionId = $request->input('collection_id');
|
$collectionId = $request->input('collection_id');
|
||||||
$collection = $collectionId ? $this->resolveAccessibleCollection($request, $collectionId) : null;
|
$collection = $collectionId ? $this->resolveAccessibleCollection($request, $collectionId) : null;
|
||||||
@@ -107,6 +110,8 @@ class TaskController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function update(TaskUpdateRequest $request, Task $task): JsonResponse
|
public function update(TaskUpdateRequest $request, Task $task): JsonResponse
|
||||||
{
|
{
|
||||||
|
TenantMemberPermissions::ensureTenantPermission($request, 'tasks:manage');
|
||||||
|
|
||||||
$tenant = $this->currentTenant($request);
|
$tenant = $this->currentTenant($request);
|
||||||
|
|
||||||
if ($task->tenant_id !== $tenant->id) {
|
if ($task->tenant_id !== $tenant->id) {
|
||||||
@@ -138,6 +143,8 @@ class TaskController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function destroy(Request $request, Task $task): JsonResponse
|
public function destroy(Request $request, Task $task): JsonResponse
|
||||||
{
|
{
|
||||||
|
TenantMemberPermissions::ensureTenantPermission($request, 'tasks:manage');
|
||||||
|
|
||||||
if ($task->tenant_id !== $this->currentTenant($request)->id) {
|
if ($task->tenant_id !== $this->currentTenant($request)->id) {
|
||||||
abort(404, 'Task nicht gefunden.');
|
abort(404, 'Task nicht gefunden.');
|
||||||
}
|
}
|
||||||
@@ -154,6 +161,8 @@ class TaskController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function assignToEvent(Request $request, Task $task, Event $event): JsonResponse
|
public function assignToEvent(Request $request, Task $task, Event $event): JsonResponse
|
||||||
{
|
{
|
||||||
|
TenantMemberPermissions::ensureEventPermission($request, $event, 'tasks:manage');
|
||||||
|
|
||||||
$tenantId = $this->currentTenant($request)->id;
|
$tenantId = $this->currentTenant($request)->id;
|
||||||
|
|
||||||
if (($task->tenant_id && $task->tenant_id !== $tenantId) || $event->tenant_id !== $tenantId) {
|
if (($task->tenant_id && $task->tenant_id !== $tenantId) || $event->tenant_id !== $tenantId) {
|
||||||
@@ -176,6 +185,8 @@ class TaskController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function bulkAssignToEvent(Request $request, Event $event): JsonResponse
|
public function bulkAssignToEvent(Request $request, Event $event): JsonResponse
|
||||||
{
|
{
|
||||||
|
TenantMemberPermissions::ensureEventPermission($request, $event, 'tasks:manage');
|
||||||
|
|
||||||
$tenantId = $this->currentTenant($request)->id;
|
$tenantId = $this->currentTenant($request)->id;
|
||||||
|
|
||||||
if ($event->tenant_id !== $tenantId) {
|
if ($event->tenant_id !== $tenantId) {
|
||||||
@@ -230,6 +241,8 @@ class TaskController extends Controller
|
|||||||
|
|
||||||
public function bulkDetachFromEvent(Request $request, Event $event): JsonResponse
|
public function bulkDetachFromEvent(Request $request, Event $event): JsonResponse
|
||||||
{
|
{
|
||||||
|
TenantMemberPermissions::ensureEventPermission($request, $event, 'tasks:manage');
|
||||||
|
|
||||||
$tenantId = $this->currentTenant($request)->id;
|
$tenantId = $this->currentTenant($request)->id;
|
||||||
|
|
||||||
if ($event->tenant_id !== $tenantId) {
|
if ($event->tenant_id !== $tenantId) {
|
||||||
@@ -256,6 +269,8 @@ class TaskController extends Controller
|
|||||||
|
|
||||||
public function reorderForEvent(Request $request, Event $event): JsonResponse
|
public function reorderForEvent(Request $request, Event $event): JsonResponse
|
||||||
{
|
{
|
||||||
|
TenantMemberPermissions::ensureEventPermission($request, $event, 'tasks:manage');
|
||||||
|
|
||||||
$tenantId = $this->currentTenant($request)->id;
|
$tenantId = $this->currentTenant($request)->id;
|
||||||
|
|
||||||
if ($event->tenant_id !== $tenantId) {
|
if ($event->tenant_id !== $tenantId) {
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ class TenantPackageController extends Controller
|
|||||||
$pkg?->limits ?? [],
|
$pkg?->limits ?? [],
|
||||||
$this->buildUsageSnapshot($eventPackage),
|
$this->buildUsageSnapshot($eventPackage),
|
||||||
[
|
[
|
||||||
|
'included_package_slug' => $pkg?->included_package_slug,
|
||||||
'branding_allowed' => $pkg?->branding_allowed,
|
'branding_allowed' => $pkg?->branding_allowed,
|
||||||
'watermark_allowed' => $pkg?->watermark_allowed,
|
'watermark_allowed' => $pkg?->watermark_allowed,
|
||||||
'features' => $pkg?->features ?? [],
|
'features' => $pkg?->features ?? [],
|
||||||
|
|||||||
@@ -47,6 +47,15 @@ class AuthenticatedSessionController extends Controller
|
|||||||
|
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
if ($user && $user->email_verified_at === null) {
|
if ($user && $user->email_verified_at === null) {
|
||||||
|
$intended = $request->session()->get('url.intended');
|
||||||
|
$intended = is_string($intended) ? trim($intended) : null;
|
||||||
|
|
||||||
|
if ($this->isVerificationLink($intended)) {
|
||||||
|
$request->session()->forget('url.intended');
|
||||||
|
|
||||||
|
return Inertia::location($intended);
|
||||||
|
}
|
||||||
|
|
||||||
return Inertia::location(route('verification.notice'));
|
return Inertia::location(route('verification.notice'));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,6 +125,29 @@ class AuthenticatedSessionController extends Controller
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function isVerificationLink(?string $target): bool
|
||||||
|
{
|
||||||
|
if (! is_string($target) || trim($target) === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = trim($target);
|
||||||
|
|
||||||
|
if (str_starts_with($path, '/verify-email/')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$parsed = parse_url($path);
|
||||||
|
|
||||||
|
if ($parsed === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = $parsed['path'] ?? '';
|
||||||
|
|
||||||
|
return $path !== '' && str_starts_with($path, '/verify-email/');
|
||||||
|
}
|
||||||
|
|
||||||
private function decodeReturnTo(string $value, Request $request): ?string
|
private function decodeReturnTo(string $value, Request $request): ?string
|
||||||
{
|
{
|
||||||
$candidate = $this->decodeBase64Url($value) ?? $value;
|
$candidate = $this->decodeBase64Url($value) ?? $value;
|
||||||
|
|||||||
@@ -108,8 +108,8 @@ class CheckoutController extends Controller
|
|||||||
'settings' => json_encode([
|
'settings' => json_encode([
|
||||||
'branding' => [
|
'branding' => [
|
||||||
'logo_url' => null,
|
'logo_url' => null,
|
||||||
'primary_color' => '#3B82F6',
|
'primary_color' => '#FF5A5F',
|
||||||
'secondary_color' => '#1F2937',
|
'secondary_color' => '#FFF8F5',
|
||||||
'font_family' => 'Inter, sans-serif',
|
'font_family' => 'Inter, sans-serif',
|
||||||
],
|
],
|
||||||
'features' => [
|
'features' => [
|
||||||
|
|||||||
@@ -146,8 +146,8 @@ class CheckoutGoogleController extends Controller
|
|||||||
'settings' => json_encode([
|
'settings' => json_encode([
|
||||||
'branding' => [
|
'branding' => [
|
||||||
'logo_url' => null,
|
'logo_url' => null,
|
||||||
'primary_color' => '#3B82F6',
|
'primary_color' => '#FF5A5F',
|
||||||
'secondary_color' => '#1F2937',
|
'secondary_color' => '#FFF8F5',
|
||||||
'font_family' => 'Inter, sans-serif',
|
'font_family' => 'Inter, sans-serif',
|
||||||
],
|
],
|
||||||
'features' => [
|
'features' => [
|
||||||
|
|||||||
@@ -58,8 +58,8 @@ class TestGuestEventController extends Controller
|
|||||||
'date' => ($validated['date'] ?? Carbon::now()->addWeeks(2)->toDateString()),
|
'date' => ($validated['date'] ?? Carbon::now()->addWeeks(2)->toDateString()),
|
||||||
'settings' => [
|
'settings' => [
|
||||||
'branding' => [
|
'branding' => [
|
||||||
'primary_color' => '#f43f5e',
|
'primary_color' => '#FF5A5F',
|
||||||
'secondary_color' => '#fb7185',
|
'secondary_color' => '#FFF8F5',
|
||||||
'font_family' => 'Inter, sans-serif',
|
'font_family' => 'Inter, sans-serif',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -28,7 +28,12 @@ class CreditCheckMiddleware
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($this->requiresCredits($request) && ! $this->shouldBypassCreditCheck($request, $tenant)) {
|
if ($this->requiresCredits($request) && ! $this->shouldBypassCreditCheck($request, $tenant)) {
|
||||||
$violation = $this->limitEvaluator->assessEventCreation($tenant);
|
$includedSlug = $request->input('service_package_slug');
|
||||||
|
|
||||||
|
$violation = $this->limitEvaluator->assessEventCreation(
|
||||||
|
$tenant,
|
||||||
|
is_string($includedSlug) && $includedSlug !== '' ? $includedSlug : null
|
||||||
|
);
|
||||||
|
|
||||||
if ($violation !== null) {
|
if ($violation !== null) {
|
||||||
return ApiError::response(
|
return ApiError::response(
|
||||||
|
|||||||
@@ -73,7 +73,12 @@ class PackageMiddleware
|
|||||||
private function detectViolation(Request $request, Tenant $tenant): ?array
|
private function detectViolation(Request $request, Tenant $tenant): ?array
|
||||||
{
|
{
|
||||||
if ($request->routeIs('api.v1.tenant.events.store')) {
|
if ($request->routeIs('api.v1.tenant.events.store')) {
|
||||||
return $this->limitEvaluator->assessEventCreation($tenant);
|
$includedSlug = $request->input('service_package_slug');
|
||||||
|
|
||||||
|
return $this->limitEvaluator->assessEventCreation(
|
||||||
|
$tenant,
|
||||||
|
is_string($includedSlug) && $includedSlug !== '' ? $includedSlug : null
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($request->routeIs('api.v1.tenant.events.photos.store')) {
|
if ($request->routeIs('api.v1.tenant.events.photos.store')) {
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Photobooth;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class PhotoboothSendUploaderDownloadRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,14 +23,21 @@ class EventStoreRequest extends FormRequest
|
|||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
$tenantId = request()->attributes->get('tenant_id');
|
$tenantId = request()->attributes->get('tenant_id');
|
||||||
|
$creating = $this->isMethod('post');
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'name' => ['required', 'string', 'max:255'],
|
'name' => [$creating ? 'required' : 'sometimes', 'string', 'max:255'],
|
||||||
'description' => ['nullable', 'string'],
|
'description' => ['nullable', 'string'],
|
||||||
'event_date' => ['required', 'date', 'after_or_equal:today'],
|
'event_date' => $creating ? ['required', 'date', 'after_or_equal:today'] : ['sometimes', 'date'],
|
||||||
'location' => ['nullable', 'string', 'max:255'],
|
'location' => ['nullable', 'string', 'max:255'],
|
||||||
'event_type_id' => ['required', 'exists:event_types,id'],
|
'event_type_id' => [$creating ? 'required' : 'sometimes', 'exists:event_types,id'],
|
||||||
'package_id' => ['nullable', 'integer', 'exists:packages,id'],
|
'package_id' => ['nullable', 'integer', 'exists:packages,id'],
|
||||||
|
'service_package_slug' => [
|
||||||
|
'nullable',
|
||||||
|
'string',
|
||||||
|
'max:64',
|
||||||
|
Rule::exists('packages', 'slug')->where('type', 'endcustomer'),
|
||||||
|
],
|
||||||
'max_participants' => ['nullable', 'integer', 'min:1', 'max:10000'],
|
'max_participants' => ['nullable', 'integer', 'min:1', 'max:10000'],
|
||||||
'public_url' => ['nullable', 'url', 'max:500'],
|
'public_url' => ['nullable', 'url', 'max:500'],
|
||||||
'custom_domain' => ['nullable', 'string', 'max:255'],
|
'custom_domain' => ['nullable', 'string', 'max:255'],
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Http\Resources\Tenant;
|
namespace App\Http\Resources\Tenant;
|
||||||
|
|
||||||
use App\Services\Packages\PackageLimitEvaluator;
|
use App\Services\Packages\PackageLimitEvaluator;
|
||||||
|
use App\Support\TenantMemberPermissions;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
use Illuminate\Http\Resources\MissingValue;
|
use Illuminate\Http\Resources\MissingValue;
|
||||||
@@ -18,6 +19,12 @@ class EventResource extends JsonResource
|
|||||||
$showSensitive = $this->tenant_id === $tenantId;
|
$showSensitive = $this->tenant_id === $tenantId;
|
||||||
$settings = is_array($this->settings) ? $this->settings : [];
|
$settings = is_array($this->settings) ? $this->settings : [];
|
||||||
$eventPackage = null;
|
$eventPackage = null;
|
||||||
|
$memberPermissions = null;
|
||||||
|
|
||||||
|
$user = $request->user();
|
||||||
|
if ($user && $user->role === 'member') {
|
||||||
|
$memberPermissions = TenantMemberPermissions::resolveEventPermissions($request, $this->resource);
|
||||||
|
}
|
||||||
|
|
||||||
if ($this->relationLoaded('eventPackages')) {
|
if ($this->relationLoaded('eventPackages')) {
|
||||||
$related = $this->getRelation('eventPackages');
|
$related = $this->getRelation('eventPackages');
|
||||||
@@ -86,6 +93,7 @@ class EventResource extends JsonResource
|
|||||||
? $limitEvaluator->summarizeEventPackage($eventPackage)
|
? $limitEvaluator->summarizeEventPackage($eventPackage)
|
||||||
: null,
|
: null,
|
||||||
'addons' => $eventPackage ? $this->formatAddons($eventPackage) : [],
|
'addons' => $eventPackage ? $this->formatAddons($eventPackage) : [],
|
||||||
|
'member_permissions' => $memberPermissions,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ class PhotoboothStatusResource extends JsonResource
|
|||||||
'password' => $password,
|
'password' => $password,
|
||||||
'path' => $eventSetting?->path,
|
'path' => $eventSetting?->path,
|
||||||
'ftp_url' => $isSparkbooth ? null : $this->buildFtpUrl($eventSetting, $settings, $password),
|
'ftp_url' => $isSparkbooth ? null : $this->buildFtpUrl($eventSetting, $settings, $password),
|
||||||
'upload_url' => $isSparkbooth ? route('api.v1.photobooth.sparkbooth.upload') : null,
|
'upload_url' => $isSparkbooth ? route('api.v1.photobooth.upload') : null,
|
||||||
'expires_at' => optional($activeExpires)->toIso8601String(),
|
'expires_at' => optional($activeExpires)->toIso8601String(),
|
||||||
'rate_limit_per_minute' => (int) $settings->rate_limit_per_minute,
|
'rate_limit_per_minute' => (int) $settings->rate_limit_per_minute,
|
||||||
'ftp' => [
|
'ftp' => [
|
||||||
@@ -62,7 +62,7 @@ class PhotoboothStatusResource extends JsonResource
|
|||||||
'username' => $mode === 'sparkbooth' ? $eventSetting?->username : null,
|
'username' => $mode === 'sparkbooth' ? $eventSetting?->username : null,
|
||||||
'password' => $mode === 'sparkbooth' ? $password : null,
|
'password' => $mode === 'sparkbooth' ? $password : null,
|
||||||
'expires_at' => $mode === 'sparkbooth' ? optional($eventSetting?->expires_at)->toIso8601String() : null,
|
'expires_at' => $mode === 'sparkbooth' ? optional($eventSetting?->expires_at)->toIso8601String() : null,
|
||||||
'upload_url' => route('api.v1.photobooth.sparkbooth.upload'),
|
'upload_url' => route('api.v1.photobooth.upload'),
|
||||||
'response_format' => ($eventSetting?->metadata ?? [])['sparkbooth_response_format'] ?? config('photobooth.sparkbooth.response_format', 'json'),
|
'response_format' => ($eventSetting?->metadata ?? [])['sparkbooth_response_format'] ?? config('photobooth.sparkbooth.response_format', 'json'),
|
||||||
'metrics' => $sparkMetrics,
|
'metrics' => $sparkMetrics,
|
||||||
],
|
],
|
||||||
|
|||||||
50
app/Mail/PhotoboothUploaderDownload.php
Normal file
50
app/Mail/PhotoboothUploaderDownload.php
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Mail;
|
||||||
|
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Mail\Mailable;
|
||||||
|
use Illuminate\Mail\Mailables\Content;
|
||||||
|
use Illuminate\Mail\Mailables\Envelope;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
class PhotoboothUploaderDownload extends Mailable implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable, SerializesModels;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{windows:string, macos:string, linux:string} $links
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public string $recipientName,
|
||||||
|
public string $eventName,
|
||||||
|
public array $links,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function envelope(): Envelope
|
||||||
|
{
|
||||||
|
return new Envelope(
|
||||||
|
subject: __('emails.photobooth_uploader.subject', [
|
||||||
|
'event' => $this->eventName,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function content(): Content
|
||||||
|
{
|
||||||
|
return new Content(
|
||||||
|
view: 'emails.photobooth-uploader-download',
|
||||||
|
with: [
|
||||||
|
'recipientName' => $this->recipientName,
|
||||||
|
'eventName' => $this->eventName,
|
||||||
|
'links' => $this->links,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function attachments(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ class Package extends Model
|
|||||||
'name_translations',
|
'name_translations',
|
||||||
'slug',
|
'slug',
|
||||||
'type',
|
'type',
|
||||||
|
'included_package_slug',
|
||||||
'price',
|
'price',
|
||||||
'max_photos',
|
'max_photos',
|
||||||
'max_guests',
|
'max_guests',
|
||||||
|
|||||||
@@ -100,7 +100,14 @@ class Tenant extends Model
|
|||||||
|
|
||||||
public function activeResellerPackage(): HasOne
|
public function activeResellerPackage(): HasOne
|
||||||
{
|
{
|
||||||
return $this->hasOne(TenantPackage::class)->where('active', true);
|
return $this->hasOne(TenantPackage::class)
|
||||||
|
->where('active', true)
|
||||||
|
->where(function ($query) {
|
||||||
|
$query->whereNull('expires_at')->orWhere('expires_at', '>', now());
|
||||||
|
})
|
||||||
|
->whereHas('package', fn ($query) => $query->withTrashed()->where('type', 'reseller'))
|
||||||
|
->orderBy('purchased_at')
|
||||||
|
->orderBy('id');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function notificationLogs(): HasMany
|
public function notificationLogs(): HasMany
|
||||||
@@ -151,6 +158,13 @@ class Tenant extends Model
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function hasEventAllowanceFor(?string $includedPackageSlug): bool
|
||||||
|
{
|
||||||
|
$package = $this->getActiveResellerPackageFor($includedPackageSlug);
|
||||||
|
|
||||||
|
return $package !== null && $package->canCreateEvent();
|
||||||
|
}
|
||||||
|
|
||||||
public function consumeEventAllowance(int $amount = 1, string $reason = 'event.create', ?string $note = null): bool
|
public function consumeEventAllowance(int $amount = 1, string $reason = 'event.create', ?string $note = null): bool
|
||||||
{
|
{
|
||||||
$package = $this->getActiveResellerPackage();
|
$package = $this->getActiveResellerPackage();
|
||||||
@@ -183,13 +197,68 @@ class Tenant extends Model
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function consumeEventAllowanceFor(?string $includedPackageSlug, int $amount = 1, string $reason = 'event.create', ?string $note = null): bool
|
||||||
|
{
|
||||||
|
$package = $this->getActiveResellerPackageFor($includedPackageSlug);
|
||||||
|
|
||||||
|
if ($package && $package->canCreateEvent()) {
|
||||||
|
$previousUsed = (int) $package->used_events;
|
||||||
|
$package->increment('used_events', $amount);
|
||||||
|
$package->refresh();
|
||||||
|
|
||||||
|
app(\App\Services\Packages\TenantUsageTracker::class)->recordEventUsage(
|
||||||
|
$package,
|
||||||
|
$previousUsed,
|
||||||
|
$amount
|
||||||
|
);
|
||||||
|
|
||||||
|
Log::info('Tenant package usage recorded', [
|
||||||
|
'tenant_id' => $this->id,
|
||||||
|
'tenant_package_id' => $package->id,
|
||||||
|
'used_events' => $package->used_events,
|
||||||
|
'amount' => $amount,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::warning('Event allowance missing for tenant', [
|
||||||
|
'tenant_id' => $this->id,
|
||||||
|
'reason' => $reason,
|
||||||
|
'included_package_slug' => $includedPackageSlug,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
public function getActiveResellerPackage(): ?TenantPackage
|
public function getActiveResellerPackage(): ?TenantPackage
|
||||||
{
|
{
|
||||||
return $this->activeResellerPackage()
|
return $this->activeResellerPackage()->with('package')->first();
|
||||||
->whereHas('package', fn ($query) => $query->withTrashed()->where('type', 'reseller'))
|
}
|
||||||
|
|
||||||
|
public function getActiveResellerPackageFor(?string $includedPackageSlug): ?TenantPackage
|
||||||
|
{
|
||||||
|
$query = $this->tenantPackages()
|
||||||
|
->with('package')
|
||||||
->where('active', true)
|
->where('active', true)
|
||||||
->orderByDesc('expires_at')
|
->where(function ($query) {
|
||||||
->first();
|
$query->whereNull('expires_at')->orWhere('expires_at', '>', now());
|
||||||
|
})
|
||||||
|
->whereHas('package', fn ($query) => $query->withTrashed()->where('type', 'reseller'))
|
||||||
|
->orderBy('purchased_at')
|
||||||
|
->orderBy('id');
|
||||||
|
|
||||||
|
if (is_string($includedPackageSlug) && $includedPackageSlug !== '') {
|
||||||
|
$query->whereHas('package', function ($query) use ($includedPackageSlug) {
|
||||||
|
$query->where('included_package_slug', $includedPackageSlug);
|
||||||
|
|
||||||
|
if ($includedPackageSlug === 'standard') {
|
||||||
|
$query->orWhereNull('included_package_slug');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query->first();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function activeSubscription(): Attribute
|
public function activeSubscription(): Attribute
|
||||||
|
|||||||
@@ -66,18 +66,30 @@ class TenantPackage extends Model
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$maxEvents = $this->package->max_events_per_year ?? 0;
|
$maxEvents = $this->package->max_events_per_year;
|
||||||
|
|
||||||
|
if ($maxEvents === null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$maxEvents = max(0, (int) $maxEvents);
|
||||||
|
|
||||||
return $this->used_events < $maxEvents;
|
return $this->used_events < $maxEvents;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getRemainingEventsAttribute(): int
|
public function getRemainingEventsAttribute(): ?int
|
||||||
{
|
{
|
||||||
if (! $this->package->isReseller()) {
|
if (! $this->package->isReseller()) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
$max = $this->package->max_events_per_year ?? 0;
|
$max = $this->package->max_events_per_year;
|
||||||
|
|
||||||
|
if ($max === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$max = max(0, (int) $max);
|
||||||
|
|
||||||
return max(0, $max - $this->used_events);
|
return max(0, $max - $this->used_events);
|
||||||
}
|
}
|
||||||
@@ -94,9 +106,7 @@ class TenantPackage extends Model
|
|||||||
$package = $tenantPackage->package;
|
$package = $tenantPackage->package;
|
||||||
|
|
||||||
if ($package && $package->isReseller()) {
|
if ($package && $package->isReseller()) {
|
||||||
if (! $tenantPackage->expires_at) {
|
// Reseller packages represent prepaid Event-Kontingente and should not expire by default.
|
||||||
$tenantPackage->expires_at = now()->addYear();
|
|
||||||
}
|
|
||||||
} elseif (! $tenantPackage->expires_at) {
|
} elseif (! $tenantPackage->expires_at) {
|
||||||
$tenantPackage->expires_at = now()->addYear();
|
$tenantPackage->expires_at = now()->addYear();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,18 +94,34 @@ class CheckoutAssignmentService
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
$tenantPackage = TenantPackage::updateOrCreate(
|
if ($package->type === 'reseller') {
|
||||||
[
|
$tenantPackage = null;
|
||||||
'tenant_id' => $tenant->id,
|
|
||||||
'package_id' => $package->id,
|
if ($purchase->wasRecentlyCreated) {
|
||||||
],
|
$tenantPackage = TenantPackage::create([
|
||||||
[
|
'tenant_id' => $tenant->id,
|
||||||
'price' => round($price, 2),
|
'package_id' => $package->id,
|
||||||
'active' => true,
|
'price' => round($price, 2),
|
||||||
'purchased_at' => now(),
|
'active' => true,
|
||||||
'expires_at' => $this->resolveExpiry($package, $tenant),
|
'purchased_at' => now(),
|
||||||
]
|
'expires_at' => null,
|
||||||
);
|
'used_events' => 0,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$tenantPackage = TenantPackage::updateOrCreate(
|
||||||
|
[
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'package_id' => $package->id,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'price' => round($price, 2),
|
||||||
|
'active' => true,
|
||||||
|
'purchased_at' => now(),
|
||||||
|
'expires_at' => $this->resolveExpiry($package, $tenant),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if ($package->type !== 'reseller') {
|
if ($package->type !== 'reseller') {
|
||||||
$tenant->forceFill([
|
$tenant->forceFill([
|
||||||
@@ -188,11 +204,7 @@ class CheckoutAssignmentService
|
|||||||
protected function resolveExpiry(Package $package, Tenant $tenant)
|
protected function resolveExpiry(Package $package, Tenant $tenant)
|
||||||
{
|
{
|
||||||
if ($package->type === 'reseller') {
|
if ($package->type === 'reseller') {
|
||||||
$hasActive = TenantPackage::where('tenant_id', $tenant->id)
|
return null;
|
||||||
->where('active', true)
|
|
||||||
->exists();
|
|
||||||
|
|
||||||
return $hasActive ? now()->addYear() : now()->addDays(14);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return now()->addYear();
|
return now()->addYear();
|
||||||
|
|||||||
@@ -77,6 +77,8 @@ class HelpSyncService
|
|||||||
foreach ($articles->groupBy(fn ($article) => $article['audience'].'::'.$article['locale']) as $key => $group) {
|
foreach ($articles->groupBy(fn ($article) => $article['audience'].'::'.$article['locale']) as $key => $group) {
|
||||||
[$audience, $locale] = explode('::', $key);
|
[$audience, $locale] = explode('::', $key);
|
||||||
$path = sprintf('%s/%s/%s/articles.json', $compiledPath, $audience, $locale);
|
$path = sprintf('%s/%s/%s/articles.json', $compiledPath, $audience, $locale);
|
||||||
|
$directory = sprintf('%s/%s/%s', $compiledPath, $audience, $locale);
|
||||||
|
Storage::disk($disk)->makeDirectory($directory);
|
||||||
Storage::disk($disk)->put($path, $group->values()->toJson(JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
Storage::disk($disk)->put($path, $group->values()->toJson(JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||||
Cache::forget($this->cacheKey($audience, $locale));
|
Cache::forget($this->cacheKey($audience, $locale));
|
||||||
$written[$audience][$locale] = $group->count();
|
$written[$audience][$locale] = $group->count();
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class PackageLimitEvaluator
|
|||||||
{
|
{
|
||||||
public function __construct(private readonly TenantUsageService $tenantUsageService) {}
|
public function __construct(private readonly TenantUsageService $tenantUsageService) {}
|
||||||
|
|
||||||
public function assessEventCreation(Tenant $tenant): ?array
|
public function assessEventCreation(Tenant $tenant, ?string $includedPackageSlug = null): ?array
|
||||||
{
|
{
|
||||||
$hasEndcustomerPackage = $tenant->tenantPackages()
|
$hasEndcustomerPackage = $tenant->tenantPackages()
|
||||||
->where('active', true)
|
->where('active', true)
|
||||||
@@ -22,17 +22,66 @@ class PackageLimitEvaluator
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($tenant->hasEventAllowance()) {
|
if ($tenant->hasEventAllowanceFor($includedPackageSlug)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$package = $tenant->getActiveResellerPackage();
|
$package = $tenant->getActiveResellerPackageFor($includedPackageSlug);
|
||||||
|
|
||||||
if (! $package) {
|
if (! $package) {
|
||||||
|
if ($includedPackageSlug) {
|
||||||
|
$hasAnyActive = $tenant->tenantPackages()
|
||||||
|
->where('active', true)
|
||||||
|
->where(function ($query) {
|
||||||
|
$query->whereNull('expires_at')->orWhere('expires_at', '>', now());
|
||||||
|
})
|
||||||
|
->whereHas('package', fn ($query) => $query->withTrashed()->where('type', 'reseller'))
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if ($hasAnyActive) {
|
||||||
|
return [
|
||||||
|
'code' => 'event_tier_unavailable',
|
||||||
|
'title' => __('api.packages.event_tier_unavailable.title'),
|
||||||
|
'message' => __('api.packages.event_tier_unavailable.message'),
|
||||||
|
'status' => 402,
|
||||||
|
'meta' => [
|
||||||
|
'scope' => 'events',
|
||||||
|
'requested_tier' => $includedPackageSlug,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$latestResellerPackage = $tenant->tenantPackages()
|
||||||
|
->with('package')
|
||||||
|
->whereHas('package', fn ($query) => $query->withTrashed()->where('type', 'reseller'))
|
||||||
|
->orderByDesc('purchased_at')
|
||||||
|
->orderByDesc('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($latestResellerPackage && $latestResellerPackage->package) {
|
||||||
|
$limit = $latestResellerPackage->package->max_events_per_year ?? 0;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'code' => 'event_limit_exceeded',
|
||||||
|
'title' => __('api.packages.event_limit_exceeded.title'),
|
||||||
|
'message' => __('api.packages.event_limit_exceeded.message'),
|
||||||
|
'status' => 402,
|
||||||
|
'meta' => [
|
||||||
|
'scope' => 'events',
|
||||||
|
'used' => (int) $latestResellerPackage->used_events,
|
||||||
|
'limit' => $limit,
|
||||||
|
'remaining' => max(0, $limit - $latestResellerPackage->used_events),
|
||||||
|
'tenant_package_id' => $latestResellerPackage->id,
|
||||||
|
'package_id' => $latestResellerPackage->package_id,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'code' => 'event_limit_missing',
|
'code' => 'event_limit_missing',
|
||||||
'title' => 'No package assigned',
|
'title' => __('api.packages.event_limit_missing.title'),
|
||||||
'message' => 'Assign a package or addon to create events.',
|
'message' => __('api.packages.event_limit_missing.message'),
|
||||||
'status' => 402,
|
'status' => 402,
|
||||||
'meta' => [
|
'meta' => [
|
||||||
'scope' => 'events',
|
'scope' => 'events',
|
||||||
@@ -49,8 +98,8 @@ class PackageLimitEvaluator
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
'code' => 'event_limit_exceeded',
|
'code' => 'event_limit_exceeded',
|
||||||
'title' => 'Event quota reached',
|
'title' => __('api.packages.event_limit_exceeded.title'),
|
||||||
'message' => 'Your current package has no remaining event slots. Please upgrade or renew your subscription.',
|
'message' => __('api.packages.event_limit_exceeded.message'),
|
||||||
'status' => 402,
|
'status' => 402,
|
||||||
'meta' => [
|
'meta' => [
|
||||||
'scope' => 'events',
|
'scope' => 'events',
|
||||||
@@ -74,8 +123,8 @@ class PackageLimitEvaluator
|
|||||||
if (! $event) {
|
if (! $event) {
|
||||||
return [
|
return [
|
||||||
'code' => 'event_not_found',
|
'code' => 'event_not_found',
|
||||||
'title' => 'Event not accessible',
|
'title' => __('api.packages.event_not_found.title'),
|
||||||
'message' => 'The selected event could not be found or belongs to another tenant.',
|
'message' => __('api.packages.event_not_found.message'),
|
||||||
'status' => 404,
|
'status' => 404,
|
||||||
'meta' => [
|
'meta' => [
|
||||||
'scope' => 'photos',
|
'scope' => 'photos',
|
||||||
@@ -87,8 +136,8 @@ class PackageLimitEvaluator
|
|||||||
if (! $eventPackage || ! $eventPackage->package) {
|
if (! $eventPackage || ! $eventPackage->package) {
|
||||||
return [
|
return [
|
||||||
'code' => 'event_package_missing',
|
'code' => 'event_package_missing',
|
||||||
'title' => 'Event package missing',
|
'title' => __('api.packages.event_package_missing.title'),
|
||||||
'message' => 'No package is attached to this event. Assign a package to enable uploads.',
|
'message' => __('api.packages.event_package_missing.message'),
|
||||||
'status' => 409,
|
'status' => 409,
|
||||||
'meta' => [
|
'meta' => [
|
||||||
'scope' => 'photos',
|
'scope' => 'photos',
|
||||||
@@ -102,8 +151,8 @@ class PackageLimitEvaluator
|
|||||||
if ($maxPhotos !== null && $eventPackage->used_photos >= $maxPhotos) {
|
if ($maxPhotos !== null && $eventPackage->used_photos >= $maxPhotos) {
|
||||||
return [
|
return [
|
||||||
'code' => 'photo_limit_exceeded',
|
'code' => 'photo_limit_exceeded',
|
||||||
'title' => 'Photo upload limit reached',
|
'title' => __('api.packages.photo_limit_exceeded.title'),
|
||||||
'message' => 'This event has reached its photo allowance. Upgrade the event package to accept more uploads.',
|
'message' => __('api.packages.photo_limit_exceeded.message'),
|
||||||
'status' => 402,
|
'status' => 402,
|
||||||
'meta' => [
|
'meta' => [
|
||||||
'scope' => 'photos',
|
'scope' => 'photos',
|
||||||
@@ -122,8 +171,8 @@ class PackageLimitEvaluator
|
|||||||
if ($eventPackage->used_photos >= $tenantPhotoLimit) {
|
if ($eventPackage->used_photos >= $tenantPhotoLimit) {
|
||||||
return [
|
return [
|
||||||
'code' => 'tenant_photo_limit_exceeded',
|
'code' => 'tenant_photo_limit_exceeded',
|
||||||
'title' => 'Tenant photo limit reached',
|
'title' => __('api.packages.tenant_photo_limit_exceeded.title'),
|
||||||
'message' => 'This tenant has reached its photo allowance for the event.',
|
'message' => __('api.packages.tenant_photo_limit_exceeded.message'),
|
||||||
'status' => 402,
|
'status' => 402,
|
||||||
'meta' => [
|
'meta' => [
|
||||||
'scope' => 'photos',
|
'scope' => 'photos',
|
||||||
@@ -146,8 +195,8 @@ class PackageLimitEvaluator
|
|||||||
if ($projectedBytes >= $storageLimitBytes) {
|
if ($projectedBytes >= $storageLimitBytes) {
|
||||||
return [
|
return [
|
||||||
'code' => 'tenant_storage_limit_exceeded',
|
'code' => 'tenant_storage_limit_exceeded',
|
||||||
'title' => 'Tenant storage limit reached',
|
'title' => __('api.packages.tenant_storage_limit_exceeded.title'),
|
||||||
'message' => 'This tenant has reached its storage allowance.',
|
'message' => __('api.packages.tenant_storage_limit_exceeded.message'),
|
||||||
'status' => 402,
|
'status' => 402,
|
||||||
'meta' => [
|
'meta' => [
|
||||||
'scope' => 'storage',
|
'scope' => 'storage',
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ namespace App\Services\Packages;
|
|||||||
|
|
||||||
use App\Events\Packages\TenantPackageEventLimitReached;
|
use App\Events\Packages\TenantPackageEventLimitReached;
|
||||||
use App\Events\Packages\TenantPackageEventThresholdReached;
|
use App\Events\Packages\TenantPackageEventThresholdReached;
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\TenantPackage;
|
use App\Models\TenantPackage;
|
||||||
use Illuminate\Contracts\Events\Dispatcher;
|
use Illuminate\Contracts\Events\Dispatcher;
|
||||||
|
|
||||||
@@ -63,6 +62,12 @@ class TenantUsageTracker
|
|||||||
}
|
}
|
||||||
|
|
||||||
$this->dispatcher->dispatch(new TenantPackageEventLimitReached($tenantPackage, $limit));
|
$this->dispatcher->dispatch(new TenantPackageEventLimitReached($tenantPackage, $limit));
|
||||||
|
|
||||||
|
if ($tenantPackage->active) {
|
||||||
|
$tenantPackage->forceFill([
|
||||||
|
'active' => false,
|
||||||
|
])->save();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ trait PresentsPackages
|
|||||||
'name' => $name,
|
'name' => $name,
|
||||||
'slug' => $package->slug,
|
'slug' => $package->slug,
|
||||||
'type' => $package->type,
|
'type' => $package->type,
|
||||||
|
'included_package_slug' => $package->included_package_slug,
|
||||||
'price' => $package->price,
|
'price' => $package->price,
|
||||||
'paddle_product_id' => $package->paddle_product_id,
|
'paddle_product_id' => $package->paddle_product_id,
|
||||||
'paddle_price_id' => $package->paddle_price_id,
|
'paddle_price_id' => $package->paddle_price_id,
|
||||||
|
|||||||
174
app/Support/TenantMemberPermissions.php
Normal file
174
app/Support/TenantMemberPermissions.php
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
use App\Models\Event;
|
||||||
|
use App\Models\EventMember;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\Exceptions\HttpResponseException;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class TenantMemberPermissions
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public static function resolveEventPermissions(Request $request, Event $event): array
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self::isTenantAdmin($user)) {
|
||||||
|
return ['*'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$member = self::resolveEventMember($user, $event);
|
||||||
|
|
||||||
|
if (! $member) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::normalizePermissions($member->permissions);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function ensureEventPermission(Request $request, Event $event, string $permission): void
|
||||||
|
{
|
||||||
|
if (self::allowsPermission(self::resolveEventPermissions($request, $event), $permission)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new HttpResponseException(ApiError::response(
|
||||||
|
'insufficient_permission',
|
||||||
|
'Insufficient permission',
|
||||||
|
'You are not allowed to perform this action.',
|
||||||
|
Response::HTTP_FORBIDDEN,
|
||||||
|
['required_permission' => $permission]
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function allowsEventPermission(Request $request, Event $event, string $permission): bool
|
||||||
|
{
|
||||||
|
return self::allowsPermission(self::resolveEventPermissions($request, $event), $permission);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function ensureTenantPermission(Request $request, string $permission): void
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
throw new HttpResponseException(ApiError::response(
|
||||||
|
'unauthenticated',
|
||||||
|
'Unauthenticated',
|
||||||
|
'You must be authenticated to perform this action.',
|
||||||
|
Response::HTTP_UNAUTHORIZED
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self::isTenantAdmin($user)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$permissions = self::resolveTenantMemberPermissions($user);
|
||||||
|
|
||||||
|
if (self::allowsPermission($permissions, $permission)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new HttpResponseException(ApiError::response(
|
||||||
|
'insufficient_permission',
|
||||||
|
'Insufficient permission',
|
||||||
|
'You are not allowed to perform this action.',
|
||||||
|
Response::HTTP_FORBIDDEN,
|
||||||
|
['required_permission' => $permission]
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private static function resolveTenantMemberPermissions(User $user): array
|
||||||
|
{
|
||||||
|
if (! $user->tenant_id) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$memberships = EventMember::query()
|
||||||
|
->where('tenant_id', $user->tenant_id)
|
||||||
|
->whereIn('status', ['active', 'invited'])
|
||||||
|
->where(function ($query) use ($user) {
|
||||||
|
$query->where('user_id', $user->id)
|
||||||
|
->orWhere('email', $user->email);
|
||||||
|
})
|
||||||
|
->get(['permissions']);
|
||||||
|
|
||||||
|
$permissions = [];
|
||||||
|
|
||||||
|
foreach ($memberships as $member) {
|
||||||
|
$permissions = array_merge($permissions, self::normalizePermissions($member->permissions));
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($permissions));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function isTenantAdmin(User $user): bool
|
||||||
|
{
|
||||||
|
return in_array($user->role, ['tenant_admin', 'admin', 'super_admin', 'superadmin'], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function resolveEventMember(User $user, Event $event): ?EventMember
|
||||||
|
{
|
||||||
|
return EventMember::query()
|
||||||
|
->where('tenant_id', $event->tenant_id)
|
||||||
|
->where('event_id', $event->id)
|
||||||
|
->whereIn('status', ['active', 'invited'])
|
||||||
|
->where(function ($query) use ($user) {
|
||||||
|
$query->where('user_id', $user->id)
|
||||||
|
->orWhere('email', $user->email);
|
||||||
|
})
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string>|string|null $permissions
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private static function normalizePermissions(mixed $permissions): array
|
||||||
|
{
|
||||||
|
if (is_array($permissions)) {
|
||||||
|
return array_values(array_filter(array_map('strval', $permissions)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($permissions) && $permissions !== '') {
|
||||||
|
return array_values(array_filter(array_map('trim', explode(',', $permissions))));
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $permissions
|
||||||
|
*/
|
||||||
|
private static function allowsPermission(array $permissions, string $permission): bool
|
||||||
|
{
|
||||||
|
foreach ($permissions as $entry) {
|
||||||
|
if ($entry === '*' || $entry === $permission) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Str::endsWith($entry, ':*')) {
|
||||||
|
$prefix = Str::beforeLast($entry, '*');
|
||||||
|
|
||||||
|
if (Str::startsWith($permission, $prefix)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,5 +6,85 @@
|
|||||||
|
|
||||||
<Application.Styles>
|
<Application.Styles>
|
||||||
<FluentTheme />
|
<FluentTheme />
|
||||||
|
<Style>
|
||||||
|
<Style.Resources>
|
||||||
|
<Color x:Key="BrandRose">#FFB6C1</Color>
|
||||||
|
<Color x:Key="BrandRoseStrong">#FF69B4</Color>
|
||||||
|
<Color x:Key="BrandRoseSoft">#FFE5EC</Color>
|
||||||
|
<Color x:Key="BrandGold">#FFD700</Color>
|
||||||
|
<Color x:Key="BrandSky">#87CEEB</Color>
|
||||||
|
<Color x:Key="BrandSkySoft">#E0F5FF</Color>
|
||||||
|
<Color x:Key="BrandNavy">#0F4C75</Color>
|
||||||
|
<Color x:Key="BrandSlate">#1F2937</Color>
|
||||||
|
<Color x:Key="BrandCream">#FFF8F5</Color>
|
||||||
|
|
||||||
|
<SolidColorBrush x:Key="TextPrimaryBrush" Color="{DynamicResource BrandSlate}" />
|
||||||
|
<SolidColorBrush x:Key="TextMutedBrush" Color="#6B7280" />
|
||||||
|
<SolidColorBrush x:Key="CardBorderBrush" Color="{DynamicResource BrandRoseSoft}" />
|
||||||
|
<SolidColorBrush x:Key="CardBackgroundBrush" Color="#FFFFFF" />
|
||||||
|
<SolidColorBrush x:Key="AccentBackgroundBrush" Color="{DynamicResource BrandSkySoft}" />
|
||||||
|
<SolidColorBrush x:Key="InputBorderBrush" Color="{DynamicResource BrandRoseSoft}" />
|
||||||
|
<SolidColorBrush x:Key="InputBackgroundBrush" Color="#FFFFFF" />
|
||||||
|
<SolidColorBrush x:Key="PrimaryButtonBrush" Color="{DynamicResource BrandRoseStrong}" />
|
||||||
|
<SolidColorBrush x:Key="PrimaryButtonTextBrush" Color="#FFFFFF" />
|
||||||
|
<SolidColorBrush x:Key="SecondaryButtonBrush" Color="{DynamicResource BrandSky}" />
|
||||||
|
<SolidColorBrush x:Key="SecondaryButtonTextBrush" Color="{DynamicResource BrandNavy}" />
|
||||||
|
|
||||||
|
<LinearGradientBrush x:Key="WindowBackgroundBrush" StartPoint="0,0" EndPoint="1,1">
|
||||||
|
<GradientStop Color="{DynamicResource BrandCream}" Offset="0" />
|
||||||
|
<GradientStop Color="{DynamicResource BrandRoseSoft}" Offset="0.5" />
|
||||||
|
<GradientStop Color="{DynamicResource BrandSkySoft}" Offset="1" />
|
||||||
|
</LinearGradientBrush>
|
||||||
|
</Style.Resources>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Window">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource WindowBackgroundBrush}" />
|
||||||
|
<Setter Property="FontFamily" Value="Inter" />
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="TextBlock.title">
|
||||||
|
<Setter Property="FontSize" Value="20" />
|
||||||
|
<Setter Property="FontWeight" Value="SemiBold" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="TextBlock.subtitle">
|
||||||
|
<Setter Property="FontSize" Value="12" />
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource TextMutedBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Border.card">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource CardBackgroundBrush}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource CardBorderBrush}" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
<Setter Property="CornerRadius" Value="12" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Border.card.accent">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource AccentBackgroundBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="TextBox">
|
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource InputBorderBrush}" />
|
||||||
|
<Setter Property="Background" Value="{DynamicResource InputBackgroundBrush}" />
|
||||||
|
<Setter Property="CornerRadius" Value="8" />
|
||||||
|
<Setter Property="Padding" Value="10,8" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Button">
|
||||||
|
<Setter Property="CornerRadius" Value="8" />
|
||||||
|
<Setter Property="Padding" Value="12,8" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Button.primary">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource PrimaryButtonBrush}" />
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource PrimaryButtonTextBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Button.secondary">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource SecondaryButtonBrush}" />
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource SecondaryButtonTextBrush}" />
|
||||||
|
</Style>
|
||||||
</Application.Styles>
|
</Application.Styles>
|
||||||
</Application>
|
</Application>
|
||||||
|
|||||||
BIN
clients/photobooth-uploader/PhotoboothUploader/Assets/app.ico
Normal file
BIN
clients/photobooth-uploader/PhotoboothUploader/Assets/app.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.1 KiB |
BIN
clients/photobooth-uploader/PhotoboothUploader/Assets/logo.png
Normal file
BIN
clients/photobooth-uploader/PhotoboothUploader/Assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 67 B |
@@ -4,13 +4,27 @@
|
|||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
mc:Ignorable="d" d:DesignWidth="520" d:DesignHeight="360"
|
mc:Ignorable="d" d:DesignWidth="520" d:DesignHeight="360"
|
||||||
x:Class="PhotoboothUploader.MainWindow"
|
x:Class="PhotoboothUploader.MainWindow"
|
||||||
Width="520" Height="360"
|
Width="560" Height="420"
|
||||||
Title="Fotospiel Photobooth Uploader">
|
MinWidth="520" MinHeight="400"
|
||||||
<Grid Margin="24" ColumnDefinitions="*,8,*">
|
Title="Die Fotospiel.App - Photobooth Uploader">
|
||||||
<StackPanel Grid.Column="0" Spacing="12" MaxWidth="420">
|
<Grid Margin="24,32,24,24" RowDefinitions="Auto,*">
|
||||||
<TextBlock Text="Fotospiel Photobooth Uploader" FontSize="20" FontWeight="SemiBold" />
|
<StackPanel Grid.Row="0" Orientation="Horizontal" Spacing="12" VerticalAlignment="Center">
|
||||||
|
<Border Width="40" Height="40" Classes="card accent" VerticalAlignment="Center" HorizontalAlignment="Left">
|
||||||
|
<Image Source="avares://PhotoboothUploader/Assets/logo.png" Width="28" Height="28" HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||||
|
</Border>
|
||||||
|
<StackPanel Spacing="2">
|
||||||
|
<TextBlock x:Name="TitleText"
|
||||||
|
Text="Die Fotospiel.App - Photobooth Uploader"
|
||||||
|
Classes="title"
|
||||||
|
PointerPressed="TitleText_PointerPressed" />
|
||||||
|
<TextBlock Text="Sicherer Upload der Fotobox-Fotos ins Event." Classes="subtitle" />
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
<Border Background="#1F000000" Padding="12" CornerRadius="8">
|
<Grid Grid.Row="1" ColumnDefinitions="*,16,*">
|
||||||
|
<StackPanel Grid.Column="0" Spacing="16" MaxWidth="420">
|
||||||
|
|
||||||
|
<Border Padding="14" Classes="card">
|
||||||
<StackPanel Spacing="6">
|
<StackPanel Spacing="6">
|
||||||
<TextBlock Text="Schritte" FontWeight="SemiBold" />
|
<TextBlock Text="Schritte" FontWeight="SemiBold" />
|
||||||
<TextBlock x:Name="StepCodeText" Text="1. Code eingeben" />
|
<TextBlock x:Name="StepCodeText" Text="1. Code eingeben" />
|
||||||
@@ -19,45 +33,128 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<TextBlock Text="Gib den 6-stelligen Verbindungscode ein." TextWrapping="Wrap" />
|
<Border Padding="14" Classes="card">
|
||||||
<TextBox x:Name="CodeBox" MaxLength="6" Watermark="123456" />
|
<StackPanel Spacing="10">
|
||||||
<Button x:Name="ConnectButton" Content="Verbinden" Click="ConnectButton_Click" />
|
<TextBlock Text="Verbindungscode" FontWeight="SemiBold" />
|
||||||
|
<TextBlock Text="Gib den 6-stelligen Verbindungscode ein." TextWrapping="Wrap" Classes="subtitle" />
|
||||||
|
<TextBox x:Name="CodeBox" MaxLength="6" Watermark="123456" TextChanged="CodeBox_TextChanged" />
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
|
<Button x:Name="ConnectButton" Content="Verbinden" Click="ConnectButton_Click" Classes="primary" />
|
||||||
|
<Button x:Name="ReconnectButton" Content="Erneut verbinden" Click="ReconnectButton_Click" IsEnabled="False" Classes="secondary" />
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
<StackPanel Spacing="6">
|
<Border Padding="14" Classes="card">
|
||||||
<TextBlock Text="Upload-Ordner" FontWeight="SemiBold" />
|
<StackPanel Spacing="8">
|
||||||
<TextBlock x:Name="FolderText" Text="Noch nicht ausgewählt." TextWrapping="Wrap" />
|
<TextBlock Text="Upload-Ordner" FontWeight="SemiBold" />
|
||||||
<Button x:Name="PickFolderButton" Content="Ordner auswählen" Click="PickFolderButton_Click" IsEnabled="False" />
|
<TextBlock x:Name="FolderText" Text="Noch nicht ausgewählt." TextWrapping="Wrap" Classes="subtitle" />
|
||||||
</StackPanel>
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
|
<Button x:Name="DslrBoothPresetButton" Content="DSLrBooth" Click="DslrBoothPresetButton_Click" Classes="secondary" IsVisible="False" />
|
||||||
|
<Button x:Name="SparkboothPresetButton" Content="Sparkbooth" Click="SparkboothPresetButton_Click" Classes="secondary" IsVisible="False" />
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
|
<Button x:Name="PickFolderButton" Content="Ordner auswählen" Click="PickFolderButton_Click" IsEnabled="False" Classes="primary" />
|
||||||
|
<Button x:Name="TestUploadButton" Content="Test-Upload senden" Click="TestUploadButton_Click" IsEnabled="False" Classes="secondary" />
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
<ToggleSwitch x:Name="QuietToggle" Content="Ruhiger Modus (nur Fehler anzeigen)" />
|
<ToggleSwitch x:Name="QuietToggle" Content="Ruhiger Modus (nur Fehler anzeigen)" />
|
||||||
|
|
||||||
|
<Border x:Name="AdvancedPanel" Padding="12" Classes="card accent" IsVisible="False">
|
||||||
|
<StackPanel Spacing="6">
|
||||||
|
<TextBlock Text="Erweiterte Einstellungen" FontWeight="SemiBold" />
|
||||||
|
<ToggleSwitch x:Name="SettingsUnlockToggle" Content="Einstellungen entsperren" Checked="SettingsUnlockToggle_Changed" Unchecked="SettingsUnlockToggle_Changed" />
|
||||||
|
<TextBlock Text="Profile" />
|
||||||
|
<ComboBox x:Name="ProfilesBox" />
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
|
<Button x:Name="LoadProfileButton" Content="Profil laden" Click="LoadProfileButton_Click" Classes="secondary" />
|
||||||
|
<Button x:Name="SaveProfileButton" Content="Profil speichern" Click="SaveProfileButton_Click" Classes="secondary" />
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock Text="Basis-URL" />
|
||||||
|
<TextBox x:Name="BaseUrlBox" Watermark="https://fotospiel.app" />
|
||||||
|
<TextBlock Text="Max. parallele Uploads" />
|
||||||
|
<TextBox x:Name="MaxUploadsBox" Watermark="2" />
|
||||||
|
<TextBlock Text="Upload-Tempo" />
|
||||||
|
<ComboBox x:Name="UploadTempoBox" SelectedIndex="1">
|
||||||
|
<ComboBoxItem Content="Schnell (ohne Pause)" />
|
||||||
|
<ComboBoxItem Content="Normal" />
|
||||||
|
<ComboBoxItem Content="Sanft (schont Netzwerk)" />
|
||||||
|
</ComboBox>
|
||||||
|
<TextBlock Text="Nur diese Dateien (optional)" />
|
||||||
|
<TextBox x:Name="IncludePatternsBox" Watermark="*.jpg;*.jpeg;*.png" />
|
||||||
|
<TextBlock Text="Dateien ausschliessen (optional)" />
|
||||||
|
<TextBox x:Name="ExcludePatternsBox" Watermark="*_preview*;*.tmp" />
|
||||||
|
<TextBlock Text="Antwort-Format (optional)" />
|
||||||
|
<ComboBox x:Name="ResponseFormatBox" SelectedIndex="0">
|
||||||
|
<ComboBoxItem Content="Auto" />
|
||||||
|
<ComboBoxItem Content="JSON" />
|
||||||
|
<ComboBoxItem Content="XML" />
|
||||||
|
</ComboBox>
|
||||||
|
<TextBlock Text="Manuelle Zugangsdaten (optional)" FontWeight="SemiBold" Margin="0,8,0,0" />
|
||||||
|
<TextBlock Text="Diese Felder ueberschreiben den Verbindungscode." Classes="subtitle" TextWrapping="Wrap" />
|
||||||
|
<TextBlock Text="Upload-URL" />
|
||||||
|
<TextBox x:Name="ManualUploadUrlBox" Watermark="https://fotospiel.app/api/v1/photobooth/upload" />
|
||||||
|
<TextBlock Text="Benutzername" />
|
||||||
|
<TextBox x:Name="ManualUsernameBox" />
|
||||||
|
<TextBlock Text="Passwort" />
|
||||||
|
<TextBox x:Name="ManualPasswordBox" PasswordChar="•" />
|
||||||
|
<Button x:Name="TestConnectionButton" Content="Verbindung testen" Click="TestConnectionButton_Click" Classes="secondary" />
|
||||||
|
<Button x:Name="SaveAdvancedButton" Content="Speichern" Click="SaveAdvancedButton_Click" Classes="primary" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<StackPanel Grid.Column="2" Spacing="12" MaxWidth="380">
|
<StackPanel Grid.Column="2" Spacing="16" MaxWidth="380" Margin="0,6,0,0">
|
||||||
<Border Background="#1F000000" Padding="12" CornerRadius="8">
|
<Border Padding="14" Classes="card accent">
|
||||||
<StackPanel Spacing="6">
|
<StackPanel Spacing="6">
|
||||||
<TextBlock Text="Status" FontWeight="SemiBold" />
|
<TextBlock Text="Status" FontWeight="SemiBold" />
|
||||||
<TextBlock x:Name="StatusText" Text="Nicht verbunden." TextWrapping="Wrap" />
|
<TextBlock x:Name="StatusText" Text="Nicht verbunden." TextWrapping="Wrap" />
|
||||||
<TextBlock x:Name="LastUploadText" Text="Letzter Upload: —" />
|
<TextBlock x:Name="LastUploadText" Text="Letzter Upload: —" />
|
||||||
|
<TextBlock x:Name="QueueStatusText" Text="Warteschlange: 0 · Läuft: 0 · Fehlgeschlagen: 0" />
|
||||||
|
<TextBlock x:Name="LiveStatusText" Text="Live: —" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<StackPanel Spacing="6">
|
<Border Padding="14" Classes="card">
|
||||||
<TextBlock Text="Letzte Uploads" FontWeight="SemiBold" />
|
<StackPanel Spacing="6">
|
||||||
<ItemsControl x:Name="RecentUploadsList" ItemsSource="{Binding RecentUploads}">
|
<TextBlock Text="Details" FontWeight="SemiBold" />
|
||||||
<ItemsControl.ItemTemplate>
|
<TextBlock x:Name="EventNameText" Text="Event: —" TextWrapping="Wrap" />
|
||||||
<DataTemplate>
|
<TextBlock x:Name="BaseUrlText" Text="Basis-URL: —" TextWrapping="Wrap" />
|
||||||
<Border Background="#14000000" Padding="8" CornerRadius="6" Margin="0,0,0,6">
|
<TextBlock x:Name="VersionText" Text="App-Version: —" />
|
||||||
<Grid ColumnDefinitions="*,Auto" RowDefinitions="Auto,Auto">
|
<TextBlock x:Name="ConnectExpiryText" Text="Verbindungscode: —" TextWrapping="Wrap" />
|
||||||
<TextBlock Grid.Column="0" Grid.Row="0" Text="{Binding FileName}" />
|
<TextBlock x:Name="FolderHealthText" Text="Ordner: —" TextWrapping="Wrap" />
|
||||||
<TextBlock Grid.Column="1" Grid.Row="0" Text="{Binding StatusLabel}" />
|
<TextBlock x:Name="DiskFreeText" Text="Freier Speicher: —" TextWrapping="Wrap" />
|
||||||
<TextBlock Grid.Column="0" Grid.Row="1" Text="{Binding UpdatedLabel}" Opacity="0.7" FontSize="11" />
|
<TextBlock x:Name="LastSeenText" Text="Letzte Datei: —" TextWrapping="Wrap" />
|
||||||
</Grid>
|
<TextBlock x:Name="LastErrorText" Text="Letzter Fehler: —" TextWrapping="Wrap" />
|
||||||
</Border>
|
<Button x:Name="LogCopyButton" Content="Log kopieren" Click="LogCopyButton_Click" Classes="secondary" />
|
||||||
</DataTemplate>
|
</StackPanel>
|
||||||
</ItemsControl.ItemTemplate>
|
</Border>
|
||||||
</ItemsControl>
|
|
||||||
<Button x:Name="RetryFailedButton" Content="Fehlgeschlagene erneut senden" Click="RetryFailedButton_Click" IsEnabled="False" />
|
<Border Padding="14" Classes="card">
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
<TextBlock Text="Letzte Uploads" FontWeight="SemiBold" />
|
||||||
|
<ItemsControl x:Name="RecentUploadsList" ItemsSource="{Binding RecentUploads}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<Border Background="#14FFFFFF" Padding="10" CornerRadius="8" Margin="0,0,0,8">
|
||||||
|
<Grid ColumnDefinitions="*,Auto" RowDefinitions="Auto,Auto">
|
||||||
|
<TextBlock Grid.Column="0" Grid.Row="0" Text="{Binding FileName}" />
|
||||||
|
<TextBlock Grid.Column="1" Grid.Row="0" Text="{Binding StatusLabel}" />
|
||||||
|
<TextBlock Grid.Column="0" Grid.Row="1" Text="{Binding UpdatedLabel}" Opacity="0.7" FontSize="11" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
|
<Button x:Name="RetryFailedButton" Content="Fehlgeschlagene erneut senden" Click="RetryFailedButton_Click" IsEnabled="False" Classes="secondary" />
|
||||||
|
<Button x:Name="ClearFailedButton" Content="Fehlerliste leeren" Click="ClearFailedButton_Click" IsEnabled="False" Classes="secondary" />
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</StackPanel>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Window>
|
</Window>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,9 @@ public sealed class PhotoboothConnectResponse
|
|||||||
|
|
||||||
public sealed class PhotoboothConnectPayload
|
public sealed class PhotoboothConnectPayload
|
||||||
{
|
{
|
||||||
|
[JsonPropertyName("event_name")]
|
||||||
|
public string? EventName { get; set; }
|
||||||
|
|
||||||
[JsonPropertyName("upload_url")]
|
[JsonPropertyName("upload_url")]
|
||||||
public string? UploadUrl { get; set; }
|
public string? UploadUrl { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace PhotoboothUploader.Models;
|
||||||
|
|
||||||
|
public sealed class PhotoboothProfile
|
||||||
|
{
|
||||||
|
public string? Label { get; set; }
|
||||||
|
public string? EventName { get; set; }
|
||||||
|
public string? BaseUrl { get; set; }
|
||||||
|
public string? UploadUrl { get; set; }
|
||||||
|
public string? Username { get; set; }
|
||||||
|
public string? Password { get; set; }
|
||||||
|
public string? ResponseFormat { get; set; }
|
||||||
|
public string? WatchFolder { get; set; }
|
||||||
|
public string? IncludePatterns { get; set; }
|
||||||
|
public string? ExcludePatterns { get; set; }
|
||||||
|
public int MaxConcurrentUploads { get; set; } = 2;
|
||||||
|
public int UploadDelayMs { get; set; } = 500;
|
||||||
|
|
||||||
|
public string DisplayName
|
||||||
|
=> !string.IsNullOrWhiteSpace(Label)
|
||||||
|
? Label
|
||||||
|
: !string.IsNullOrWhiteSpace(EventName)
|
||||||
|
? EventName
|
||||||
|
: UploadUrl ?? BaseUrl ?? "Profil";
|
||||||
|
}
|
||||||
@@ -1,11 +1,29 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
namespace PhotoboothUploader.Models;
|
namespace PhotoboothUploader.Models;
|
||||||
|
|
||||||
public sealed class PhotoboothSettings
|
public sealed class PhotoboothSettings
|
||||||
{
|
{
|
||||||
public string? BaseUrl { get; set; }
|
public string? BaseUrl { get; set; }
|
||||||
|
public string? EventName { get; set; }
|
||||||
public string? UploadUrl { get; set; }
|
public string? UploadUrl { get; set; }
|
||||||
public string? Username { get; set; }
|
public string? Username { get; set; }
|
||||||
public string? Password { get; set; }
|
public string? Password { get; set; }
|
||||||
public string? ResponseFormat { get; set; }
|
public string? ResponseFormat { get; set; }
|
||||||
public string? WatchFolder { get; set; }
|
public string? WatchFolder { get; set; }
|
||||||
|
public string? IncludePatterns { get; set; }
|
||||||
|
public string? ExcludePatterns { get; set; }
|
||||||
|
public List<string> PendingUploads { get; set; } = new();
|
||||||
|
public Dictionary<string, string> UploadedFiles { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
public List<PhotoboothProfile> Profiles { get; set; } = new();
|
||||||
|
public string? ConnectExpiresAt { get; set; }
|
||||||
|
public string? LastSeenFile { get; set; }
|
||||||
|
public string? LastSeenAt { get; set; }
|
||||||
|
public string? LastError { get; set; }
|
||||||
|
public string? LastErrorAt { get; set; }
|
||||||
|
public int MaxConcurrentUploads { get; set; } = 2;
|
||||||
|
public int UploadDelayMs { get; set; } = 500;
|
||||||
|
public double WindowWidth { get; set; }
|
||||||
|
public double WindowHeight { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
<TargetFramework>net9.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
|
<ApplicationIcon>Assets\app.ico</ApplicationIcon>
|
||||||
<AvaloniaUseCompiledBindingsByDefault>false</AvaloniaUseCompiledBindingsByDefault>
|
<AvaloniaUseCompiledBindingsByDefault>false</AvaloniaUseCompiledBindingsByDefault>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
@@ -18,4 +19,10 @@
|
|||||||
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
|
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Include="Assets\app.ico" />
|
||||||
|
<AvaloniaResource Include="Assets\logo.png" />
|
||||||
|
<AvaloniaResource Include="Assets\sample-upload.png" />
|
||||||
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Net;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
@@ -10,41 +12,111 @@ namespace PhotoboothUploader.Services;
|
|||||||
|
|
||||||
public sealed class PhotoboothConnectClient
|
public sealed class PhotoboothConnectClient
|
||||||
{
|
{
|
||||||
|
private const int MaxRetries = 2;
|
||||||
|
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(10);
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
private readonly JsonSerializerOptions _jsonOptions = new()
|
private readonly JsonSerializerOptions _jsonOptions = new()
|
||||||
{
|
{
|
||||||
PropertyNameCaseInsensitive = true,
|
PropertyNameCaseInsensitive = true,
|
||||||
};
|
};
|
||||||
|
|
||||||
public PhotoboothConnectClient(string baseUrl)
|
public PhotoboothConnectClient(string baseUrl, string userAgent)
|
||||||
{
|
{
|
||||||
_httpClient = new HttpClient
|
_httpClient = new HttpClient
|
||||||
{
|
{
|
||||||
BaseAddress = new Uri(baseUrl),
|
BaseAddress = new Uri(baseUrl),
|
||||||
|
Timeout = DefaultTimeout,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
_httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||||
|
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd(userAgent);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<PhotoboothConnectResponse> RedeemAsync(string code, CancellationToken cancellationToken = default)
|
public async Task<PhotoboothConnectResponse> RedeemAsync(string code, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var response = await _httpClient.PostAsJsonAsync("/api/v1/photobooth/connect", new { code }, cancellationToken);
|
var request = new { code };
|
||||||
var payload = await response.Content.ReadFromJsonAsync<PhotoboothConnectResponse>(_jsonOptions, cancellationToken);
|
|
||||||
|
|
||||||
if (payload is null)
|
for (var attempt = 0; attempt <= MaxRetries; attempt++)
|
||||||
{
|
{
|
||||||
return new PhotoboothConnectResponse
|
try
|
||||||
{
|
{
|
||||||
Message = response.ReasonPhrase ?? "Verbindung fehlgeschlagen.",
|
using var response = await _httpClient.PostAsJsonAsync("/api/v1/photobooth/connect", request, cancellationToken);
|
||||||
};
|
var payload = await ReadPayloadAsync(response, cancellationToken);
|
||||||
|
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
return payload ?? Fail(response.ReasonPhrase ?? "Verbindung fehlgeschlagen.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.StatusCode is HttpStatusCode.UnprocessableEntity or HttpStatusCode.Conflict or HttpStatusCode.Unauthorized)
|
||||||
|
{
|
||||||
|
return payload ?? Fail(response.ReasonPhrase ?? "Verbindung fehlgeschlagen.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt < MaxRetries && IsTransientStatus(response.StatusCode))
|
||||||
|
{
|
||||||
|
await Task.Delay(GetRetryDelay(attempt), cancellationToken);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload ?? Fail(response.ReasonPhrase ?? "Verbindung fehlgeschlagen.");
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
if (attempt < MaxRetries)
|
||||||
|
{
|
||||||
|
await Task.Delay(GetRetryDelay(attempt), cancellationToken);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Fail("Zeitüberschreitung bei der Verbindung.");
|
||||||
|
}
|
||||||
|
catch (HttpRequestException)
|
||||||
|
{
|
||||||
|
if (attempt < MaxRetries)
|
||||||
|
{
|
||||||
|
await Task.Delay(GetRetryDelay(attempt), cancellationToken);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Fail("Netzwerkfehler. Bitte Verbindung prüfen.");
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return Fail("Serverantwort konnte nicht gelesen werden.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
return Fail("Verbindung fehlgeschlagen.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<PhotoboothConnectResponse?> ReadPayloadAsync(HttpResponseMessage response, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (response.Content.Headers.ContentLength == 0)
|
||||||
{
|
{
|
||||||
return new PhotoboothConnectResponse
|
return null;
|
||||||
{
|
|
||||||
Message = payload.Message ?? "Verbindung fehlgeschlagen.",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return payload;
|
return await response.Content.ReadFromJsonAsync<PhotoboothConnectResponse>(_jsonOptions, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsTransientStatus(HttpStatusCode statusCode)
|
||||||
|
{
|
||||||
|
return statusCode is HttpStatusCode.RequestTimeout or HttpStatusCode.TooManyRequests
|
||||||
|
or HttpStatusCode.BadGateway or HttpStatusCode.ServiceUnavailable or HttpStatusCode.GatewayTimeout
|
||||||
|
or HttpStatusCode.InternalServerError;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TimeSpan GetRetryDelay(int attempt)
|
||||||
|
{
|
||||||
|
return TimeSpan.FromMilliseconds(500 * (attempt + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PhotoboothConnectResponse Fail(string message)
|
||||||
|
{
|
||||||
|
return new PhotoboothConnectResponse
|
||||||
|
{
|
||||||
|
Message = message,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ public sealed class SettingsStore
|
|||||||
};
|
};
|
||||||
|
|
||||||
public string SettingsPath { get; }
|
public string SettingsPath { get; }
|
||||||
|
public string LogPath { get; }
|
||||||
|
|
||||||
public SettingsStore()
|
public SettingsStore()
|
||||||
{
|
{
|
||||||
@@ -24,6 +25,7 @@ public sealed class SettingsStore
|
|||||||
|
|
||||||
Directory.CreateDirectory(basePath);
|
Directory.CreateDirectory(basePath);
|
||||||
SettingsPath = Path.Combine(basePath, "settings.json");
|
SettingsPath = Path.Combine(basePath, "settings.json");
|
||||||
|
LogPath = Path.Combine(basePath, "uploader.log");
|
||||||
}
|
}
|
||||||
|
|
||||||
public PhotoboothSettings Load()
|
public PhotoboothSettings Load()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
@@ -12,21 +13,38 @@ namespace PhotoboothUploader.Services;
|
|||||||
|
|
||||||
public sealed class UploadService
|
public sealed class UploadService
|
||||||
{
|
{
|
||||||
|
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(20);
|
||||||
|
private static readonly TimeSpan RetryBaseDelay = TimeSpan.FromSeconds(2);
|
||||||
|
private const int MaxRetries = 2;
|
||||||
private readonly Channel<string> _queue = Channel.CreateUnbounded<string>();
|
private readonly Channel<string> _queue = Channel.CreateUnbounded<string>();
|
||||||
private readonly ConcurrentDictionary<string, byte> _pending = new(StringComparer.OrdinalIgnoreCase);
|
private readonly ConcurrentDictionary<string, byte> _pending = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private string _userAgent = "FotospielPhotoboothUploader";
|
||||||
private CancellationTokenSource? _cts;
|
private CancellationTokenSource? _cts;
|
||||||
|
private readonly List<Task> _workers = new();
|
||||||
|
|
||||||
|
public void Configure(string userAgent)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(userAgent))
|
||||||
|
{
|
||||||
|
_userAgent = userAgent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void Start(
|
public void Start(
|
||||||
PhotoboothSettings settings,
|
PhotoboothSettings settings,
|
||||||
Action<string> onQueued,
|
Action<string> onQueued,
|
||||||
Action<string> onUploading,
|
Action<string> onUploading,
|
||||||
Action<string> onSuccess,
|
Action<string> onSuccess,
|
||||||
Action<string> onFailure)
|
Action<string, string> onFailure)
|
||||||
{
|
{
|
||||||
Stop();
|
Stop();
|
||||||
|
|
||||||
_cts = new CancellationTokenSource();
|
_cts = new CancellationTokenSource();
|
||||||
_ = Task.Run(() => WorkerAsync(settings, onQueued, onUploading, onSuccess, onFailure, _cts.Token));
|
var workerCount = GetWorkerCount(settings);
|
||||||
|
for (var i = 0; i < workerCount; i++)
|
||||||
|
{
|
||||||
|
_workers.Add(Task.Run(() => WorkerAsync(settings, onQueued, onUploading, onSuccess, onFailure, _cts.Token)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Stop()
|
public void Stop()
|
||||||
@@ -34,6 +52,7 @@ public sealed class UploadService
|
|||||||
_cts?.Cancel();
|
_cts?.Cancel();
|
||||||
_cts = null;
|
_cts = null;
|
||||||
_pending.Clear();
|
_pending.Clear();
|
||||||
|
_workers.Clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Enqueue(string path, Action<string> onQueued)
|
public void Enqueue(string path, Action<string> onQueued)
|
||||||
@@ -52,7 +71,7 @@ public sealed class UploadService
|
|||||||
Action<string> onQueued,
|
Action<string> onQueued,
|
||||||
Action<string> onUploading,
|
Action<string> onUploading,
|
||||||
Action<string> onSuccess,
|
Action<string> onSuccess,
|
||||||
Action<string> onFailure,
|
Action<string, string> onFailure,
|
||||||
CancellationToken token)
|
CancellationToken token)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(settings.UploadUrl))
|
if (string.IsNullOrWhiteSpace(settings.UploadUrl))
|
||||||
@@ -61,6 +80,9 @@ public sealed class UploadService
|
|||||||
}
|
}
|
||||||
|
|
||||||
using var client = new HttpClient();
|
using var client = new HttpClient();
|
||||||
|
client.Timeout = DefaultTimeout;
|
||||||
|
client.DefaultRequestHeaders.UserAgent.ParseAdd(_userAgent);
|
||||||
|
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||||
|
|
||||||
while (await _queue.Reader.WaitToReadAsync(token))
|
while (await _queue.Reader.WaitToReadAsync(token))
|
||||||
{
|
{
|
||||||
@@ -69,58 +91,72 @@ public sealed class UploadService
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
onUploading(path);
|
onUploading(path);
|
||||||
await WaitForFileReadyAsync(path, token);
|
var error = await UploadWithRetryAsync(client, settings, path, token);
|
||||||
await UploadAsync(client, settings, path, token);
|
if (error is null)
|
||||||
onSuccess(path);
|
{
|
||||||
|
onSuccess(path);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
onFailure(path, error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
catch
|
|
||||||
{
|
|
||||||
onFailure(path);
|
|
||||||
}
|
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
_pending.TryRemove(path, out _);
|
_pending.TryRemove(path, out _);
|
||||||
|
if (settings.UploadDelayMs > 0)
|
||||||
|
{
|
||||||
|
await Task.Delay(settings.UploadDelayMs, token);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task WaitForFileReadyAsync(string path, CancellationToken token)
|
private static async Task<string?> UploadWithRetryAsync(
|
||||||
|
HttpClient client,
|
||||||
|
PhotoboothSettings settings,
|
||||||
|
string path,
|
||||||
|
CancellationToken token)
|
||||||
{
|
{
|
||||||
var lastSize = -1L;
|
for (var attempt = 0; attempt <= MaxRetries; attempt++)
|
||||||
|
|
||||||
for (var attempts = 0; attempts < 10; attempts++)
|
|
||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
var attemptError = await UploadOnceAsync(client, settings, path, token);
|
||||||
|
if (attemptError.Success)
|
||||||
if (!File.Exists(path))
|
|
||||||
{
|
{
|
||||||
await Task.Delay(500, token);
|
return null;
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var info = new FileInfo(path);
|
if (!attemptError.Retryable || attempt >= MaxRetries)
|
||||||
var size = info.Length;
|
|
||||||
|
|
||||||
if (size > 0 && size == lastSize)
|
|
||||||
{
|
{
|
||||||
return;
|
return attemptError.Error ?? "Upload fehlgeschlagen.";
|
||||||
}
|
}
|
||||||
|
|
||||||
lastSize = size;
|
await Task.Delay(GetRetryDelay(attempt), token);
|
||||||
await Task.Delay(700, token);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return "Upload fehlgeschlagen.";
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task UploadAsync(HttpClient client, PhotoboothSettings settings, string path, CancellationToken token)
|
private static async Task<UploadAttempt> UploadOnceAsync(
|
||||||
|
HttpClient client,
|
||||||
|
PhotoboothSettings settings,
|
||||||
|
string path,
|
||||||
|
CancellationToken token)
|
||||||
{
|
{
|
||||||
|
var readyError = await WaitForFileReadyAsync(path, token);
|
||||||
|
if (readyError is not null)
|
||||||
|
{
|
||||||
|
return UploadAttempt.Fail(readyError, retryable: false);
|
||||||
|
}
|
||||||
|
|
||||||
if (!File.Exists(path))
|
if (!File.Exists(path))
|
||||||
{
|
{
|
||||||
return;
|
return UploadAttempt.Fail("Datei nicht gefunden.", retryable: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
using var content = new MultipartFormDataContent();
|
using var content = new MultipartFormDataContent();
|
||||||
@@ -145,8 +181,61 @@ public sealed class UploadService
|
|||||||
fileContent.Headers.ContentType = new MediaTypeHeaderValue(ResolveContentType(path));
|
fileContent.Headers.ContentType = new MediaTypeHeaderValue(ResolveContentType(path));
|
||||||
content.Add(fileContent, "media", Path.GetFileName(path));
|
content.Add(fileContent, "media", Path.GetFileName(path));
|
||||||
|
|
||||||
var response = await client.PostAsync(settings.UploadUrl, content, token);
|
try
|
||||||
response.EnsureSuccessStatusCode();
|
{
|
||||||
|
var response = await client.PostAsync(settings.UploadUrl, content, token);
|
||||||
|
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
return UploadAttempt.Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
var body = await ReadResponseBodyAsync(response, token);
|
||||||
|
var status = $"{(int)response.StatusCode} {response.ReasonPhrase}".Trim();
|
||||||
|
var message = string.IsNullOrWhiteSpace(body) ? status : $"{status} – {body}";
|
||||||
|
return UploadAttempt.Fail(message, IsRetryableStatus(response.StatusCode));
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException) when (!token.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return UploadAttempt.Fail("Zeitüberschreitung beim Upload.", retryable: true);
|
||||||
|
}
|
||||||
|
catch (HttpRequestException)
|
||||||
|
{
|
||||||
|
return UploadAttempt.Fail("Netzwerkfehler beim Upload.", retryable: true);
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
return UploadAttempt.Fail("Datei konnte nicht gelesen werden.", retryable: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string?> WaitForFileReadyAsync(string path, CancellationToken token)
|
||||||
|
{
|
||||||
|
var lastSize = -1L;
|
||||||
|
|
||||||
|
for (var attempts = 0; attempts < 10; attempts++)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
if (!File.Exists(path))
|
||||||
|
{
|
||||||
|
await Task.Delay(500, token);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var info = new FileInfo(path);
|
||||||
|
var size = info.Length;
|
||||||
|
|
||||||
|
if (size > 0 && size == lastSize)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastSize = size;
|
||||||
|
await Task.Delay(700, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Datei ist noch in Bearbeitung.";
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string ResolveContentType(string path)
|
private static string ResolveContentType(string path)
|
||||||
@@ -158,4 +247,51 @@ public sealed class UploadService
|
|||||||
_ => "image/jpeg",
|
_ => "image/jpeg",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool IsRetryableStatus(System.Net.HttpStatusCode statusCode)
|
||||||
|
{
|
||||||
|
var numeric = (int)statusCode;
|
||||||
|
return numeric >= 500 || statusCode is System.Net.HttpStatusCode.RequestTimeout or System.Net.HttpStatusCode.TooManyRequests;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TimeSpan GetRetryDelay(int attempt)
|
||||||
|
{
|
||||||
|
var jitter = TimeSpan.FromMilliseconds(Random.Shared.Next(100, 350));
|
||||||
|
return TimeSpan.FromMilliseconds(RetryBaseDelay.TotalMilliseconds * Math.Pow(2, attempt)) + jitter;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string?> ReadResponseBodyAsync(HttpResponseMessage response, CancellationToken token)
|
||||||
|
{
|
||||||
|
if (response.Content is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var body = await response.Content.ReadAsStringAsync(token);
|
||||||
|
if (string.IsNullOrWhiteSpace(body))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
body = body.Trim();
|
||||||
|
return body.Length > 200 ? body[..200] + "…" : body;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int GetWorkerCount(PhotoboothSettings settings)
|
||||||
|
{
|
||||||
|
var count = settings.MaxConcurrentUploads;
|
||||||
|
if (count < 1)
|
||||||
|
{
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return count > 5 ? 5 : count;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly record struct UploadAttempt(bool Success, bool Retryable, string? Error)
|
||||||
|
{
|
||||||
|
public static UploadAttempt Ok() => new(true, false, null);
|
||||||
|
|
||||||
|
public static UploadAttempt Fail(string error, bool retryable) => new(false, retryable, error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,8 @@
|
|||||||
"simplesoftwareio/simple-qrcode": "^4.2",
|
"simplesoftwareio/simple-qrcode": "^4.2",
|
||||||
"spatie/laravel-translatable": "^6.11",
|
"spatie/laravel-translatable": "^6.11",
|
||||||
"staudenmeir/belongs-to-through": "^2.17",
|
"staudenmeir/belongs-to-through": "^2.17",
|
||||||
"stripe/stripe-php": "*"
|
"stripe/stripe-php": "*",
|
||||||
|
"symfony/yaml": "^7.0"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"fakerphp/faker": "^1.23",
|
"fakerphp/faker": "^1.23",
|
||||||
|
|||||||
154
composer.lock
generated
154
composer.lock
generated
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "c1a772e5fe6f8d5c92fdbbea232f9f78",
|
"content-hash": "5e1d60e650853d6113b01e1adaf49d65",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "anourvalar/eloquent-serialize",
|
"name": "anourvalar/eloquent-serialize",
|
||||||
@@ -10043,6 +10043,82 @@
|
|||||||
],
|
],
|
||||||
"time": "2025-10-27T20:36:44+00:00"
|
"time": "2025-10-27T20:36:44+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "symfony/yaml",
|
||||||
|
"version": "v7.4.1",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/symfony/yaml.git",
|
||||||
|
"reference": "24dd4de28d2e3988b311751ac49e684d783e2345"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/symfony/yaml/zipball/24dd4de28d2e3988b311751ac49e684d783e2345",
|
||||||
|
"reference": "24dd4de28d2e3988b311751ac49e684d783e2345",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.2",
|
||||||
|
"symfony/deprecation-contracts": "^2.5|^3",
|
||||||
|
"symfony/polyfill-ctype": "^1.8"
|
||||||
|
},
|
||||||
|
"conflict": {
|
||||||
|
"symfony/console": "<6.4"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"symfony/console": "^6.4|^7.0|^8.0"
|
||||||
|
},
|
||||||
|
"bin": [
|
||||||
|
"Resources/bin/yaml-lint"
|
||||||
|
],
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Symfony\\Component\\Yaml\\": ""
|
||||||
|
},
|
||||||
|
"exclude-from-classmap": [
|
||||||
|
"/Tests/"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Fabien Potencier",
|
||||||
|
"email": "fabien@symfony.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Symfony Community",
|
||||||
|
"homepage": "https://symfony.com/contributors"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Loads and dumps YAML files",
|
||||||
|
"homepage": "https://symfony.com",
|
||||||
|
"support": {
|
||||||
|
"source": "https://github.com/symfony/yaml/tree/v7.4.1"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://symfony.com/sponsor",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/fabpot",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/nicolas-grekas",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||||
|
"type": "tidelift"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2025-12-04T18:11:45+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "tijsverkoyen/css-to-inline-styles",
|
"name": "tijsverkoyen/css-to-inline-styles",
|
||||||
"version": "v2.3.0",
|
"version": "v2.3.0",
|
||||||
@@ -12852,82 +12928,6 @@
|
|||||||
],
|
],
|
||||||
"time": "2024-10-20T05:08:20+00:00"
|
"time": "2024-10-20T05:08:20+00:00"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "symfony/yaml",
|
|
||||||
"version": "v7.4.1",
|
|
||||||
"source": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/symfony/yaml.git",
|
|
||||||
"reference": "24dd4de28d2e3988b311751ac49e684d783e2345"
|
|
||||||
},
|
|
||||||
"dist": {
|
|
||||||
"type": "zip",
|
|
||||||
"url": "https://api.github.com/repos/symfony/yaml/zipball/24dd4de28d2e3988b311751ac49e684d783e2345",
|
|
||||||
"reference": "24dd4de28d2e3988b311751ac49e684d783e2345",
|
|
||||||
"shasum": ""
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"php": ">=8.2",
|
|
||||||
"symfony/deprecation-contracts": "^2.5|^3",
|
|
||||||
"symfony/polyfill-ctype": "^1.8"
|
|
||||||
},
|
|
||||||
"conflict": {
|
|
||||||
"symfony/console": "<6.4"
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"symfony/console": "^6.4|^7.0|^8.0"
|
|
||||||
},
|
|
||||||
"bin": [
|
|
||||||
"Resources/bin/yaml-lint"
|
|
||||||
],
|
|
||||||
"type": "library",
|
|
||||||
"autoload": {
|
|
||||||
"psr-4": {
|
|
||||||
"Symfony\\Component\\Yaml\\": ""
|
|
||||||
},
|
|
||||||
"exclude-from-classmap": [
|
|
||||||
"/Tests/"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"notification-url": "https://packagist.org/downloads/",
|
|
||||||
"license": [
|
|
||||||
"MIT"
|
|
||||||
],
|
|
||||||
"authors": [
|
|
||||||
{
|
|
||||||
"name": "Fabien Potencier",
|
|
||||||
"email": "fabien@symfony.com"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Symfony Community",
|
|
||||||
"homepage": "https://symfony.com/contributors"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "Loads and dumps YAML files",
|
|
||||||
"homepage": "https://symfony.com",
|
|
||||||
"support": {
|
|
||||||
"source": "https://github.com/symfony/yaml/tree/v7.4.1"
|
|
||||||
},
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"url": "https://symfony.com/sponsor",
|
|
||||||
"type": "custom"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://github.com/fabpot",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://github.com/nicolas-grekas",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
|
||||||
"type": "tidelift"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"time": "2025-12-04T18:11:45+00:00"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "theseer/tokenizer",
|
"name": "theseer/tokenizer",
|
||||||
"version": "1.3.1",
|
"version": "1.3.1",
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ class TenantFactory extends Factory
|
|||||||
'settings' => json_encode([
|
'settings' => json_encode([
|
||||||
'branding' => [
|
'branding' => [
|
||||||
'logo_url' => null,
|
'logo_url' => null,
|
||||||
'primary_color' => '#3B82F6',
|
'primary_color' => '#FF5A5F',
|
||||||
'secondary_color' => '#1F2937',
|
'secondary_color' => '#FFF8F5',
|
||||||
'font_family' => 'Inter, sans-serif',
|
'font_family' => 'Inter, sans-serif',
|
||||||
],
|
],
|
||||||
'features' => [
|
'features' => [
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('packages', function (Blueprint $table) {
|
||||||
|
if (! Schema::hasColumn('packages', 'included_package_slug')) {
|
||||||
|
$table->string('included_package_slug')->nullable()->after('type');
|
||||||
|
$table->index(['type', 'included_package_slug']);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('packages', function (Blueprint $table) {
|
||||||
|
if (Schema::hasColumn('packages', 'included_package_slug')) {
|
||||||
|
$table->dropIndex(['type', 'included_package_slug']);
|
||||||
|
$table->dropColumn('included_package_slug');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -46,9 +46,9 @@ class DemoEventSeeder extends Seeder
|
|||||||
'collection_slugs' => ['wedding-classics-2025'],
|
'collection_slugs' => ['wedding-classics-2025'],
|
||||||
'task_slug_prefix' => 'wedding-',
|
'task_slug_prefix' => 'wedding-',
|
||||||
'branding' => [
|
'branding' => [
|
||||||
'primary_color' => '#f43f5e',
|
'primary_color' => '#FF5A5F',
|
||||||
'secondary_color' => '#fb7185',
|
'secondary_color' => '#FFF8F5',
|
||||||
'background_color' => '#fff7f4',
|
'background_color' => '#FFF8F5',
|
||||||
'font_family' => 'Playfair Display, serif',
|
'font_family' => 'Playfair Display, serif',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -76,8 +76,8 @@ class DemoTenantSeeder extends Seeder
|
|||||||
'contact_email' => $user->email,
|
'contact_email' => $user->email,
|
||||||
'branding' => [
|
'branding' => [
|
||||||
'logo_url' => null,
|
'logo_url' => null,
|
||||||
'primary_color' => '#f43f5e',
|
'primary_color' => '#FF5A5F',
|
||||||
'secondary_color' => '#1f2937',
|
'secondary_color' => '#FFF8F5',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
namespace Database\Seeders;
|
namespace Database\Seeders;
|
||||||
|
|
||||||
use Illuminate\Database\Seeder;
|
|
||||||
use App\Models\Package;
|
|
||||||
use App\Enums\PackageType;
|
use App\Enums\PackageType;
|
||||||
|
use App\Models\Package;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
class PackageSeeder extends Seeder
|
class PackageSeeder extends Seeder
|
||||||
{
|
{
|
||||||
@@ -14,7 +14,7 @@ class PackageSeeder extends Seeder
|
|||||||
public function run(): void
|
public function run(): void
|
||||||
{
|
{
|
||||||
$packages = [
|
$packages = [
|
||||||
|
|
||||||
[
|
[
|
||||||
'slug' => 'starter',
|
'slug' => 'starter',
|
||||||
'name' => 'Starter',
|
'name' => 'Starter',
|
||||||
@@ -28,12 +28,13 @@ class PackageSeeder extends Seeder
|
|||||||
'max_guests' => 100,
|
'max_guests' => 100,
|
||||||
'gallery_days' => 180,
|
'gallery_days' => 180,
|
||||||
'max_tasks' => 30,
|
'max_tasks' => 30,
|
||||||
|
'max_events_per_year' => 1,
|
||||||
'watermark_allowed' => true,
|
'watermark_allowed' => true,
|
||||||
'branding_allowed' => false,
|
'branding_allowed' => false,
|
||||||
'features' => ['basic_uploads', 'limited_sharing', 'custom_tasks'],
|
'features' => ['basic_uploads', 'limited_sharing', 'custom_tasks'],
|
||||||
'paddle_product_id' => 'pro_01k8jcxx2g1vj9snqbga4283ej',
|
'paddle_product_id' => 'pro_01k8jcxx2g1vj9snqbga4283ej',
|
||||||
'paddle_price_id' => 'pri_01k8jcxx8qktxvqzzv0nkjjj27',
|
'paddle_price_id' => 'pri_01k8jcxx8qktxvqzzv0nkjjj27',
|
||||||
'description' => <<<TEXT
|
'description' => <<<'TEXT'
|
||||||
Ideal für Geburtstage, Gartenpartys oder Polterabende! {{max_guests}} Gäste teilen ihre besten Schnappschüsse, lösen {{max_tasks}} Fotoaufgaben und haben {{gallery_duration}} Zugriff auf die Online-Galerie. {{max_photos}} Bilder sind inklusive – genug Platz für jede Menge Lieblingsmomente.
|
Ideal für Geburtstage, Gartenpartys oder Polterabende! {{max_guests}} Gäste teilen ihre besten Schnappschüsse, lösen {{max_tasks}} Fotoaufgaben und haben {{gallery_duration}} Zugriff auf die Online-Galerie. {{max_photos}} Bilder sind inklusive – genug Platz für jede Menge Lieblingsmomente.
|
||||||
TEXT,
|
TEXT,
|
||||||
'description_translations' => [
|
'description_translations' => [
|
||||||
@@ -61,12 +62,12 @@ TEXT,
|
|||||||
'max_guests' => 250,
|
'max_guests' => 250,
|
||||||
'gallery_days' => 365,
|
'gallery_days' => 365,
|
||||||
'max_tasks' => 100,
|
'max_tasks' => 100,
|
||||||
'watermark_allowed' => false,
|
'watermark_allowed' => true,
|
||||||
'branding_allowed' => true,
|
'branding_allowed' => true,
|
||||||
'features' => ['basic_uploads', 'unlimited_sharing', 'custom_branding', 'custom_tasks', 'live_slideshow'],
|
'features' => ['basic_uploads', 'unlimited_sharing', 'custom_branding', 'custom_tasks', 'live_slideshow'],
|
||||||
'paddle_product_id' => 'pro_01k8jcxwjv4ne8vf9pvd9bye3j',
|
'paddle_product_id' => 'pro_01k8jcxwjv4ne8vf9pvd9bye3j',
|
||||||
'paddle_price_id' => 'pri_01k8jcxws51pze5xc3vj2ea0yc',
|
'paddle_price_id' => 'pri_01k8jcxws51pze5xc3vj2ea0yc',
|
||||||
'description' => <<<TEXT
|
'description' => <<<'TEXT'
|
||||||
Das Rundum-Sorglos-Paket für Hochzeiten, Firmenfeiern oder Jubiläen. {{max_photos}} Bilder, {{max_guests}} Gäste und {{max_tasks}} Fotoaufgaben – dazu eine Galerie, die {{gallery_duration}} online bleibt. Eigenes Logo oder Wasserzeichen inklusive.
|
Das Rundum-Sorglos-Paket für Hochzeiten, Firmenfeiern oder Jubiläen. {{max_photos}} Bilder, {{max_guests}} Gäste und {{max_tasks}} Fotoaufgaben – dazu eine Galerie, die {{gallery_duration}} online bleibt. Eigenes Logo oder Wasserzeichen inklusive.
|
||||||
TEXT,
|
TEXT,
|
||||||
'description_translations' => [
|
'description_translations' => [
|
||||||
@@ -99,12 +100,12 @@ TEXT,
|
|||||||
'features' => ['basic_uploads', 'unlimited_sharing', 'custom_branding', 'custom_tasks', 'live_slideshow', 'advanced_analytics', 'priority_support'],
|
'features' => ['basic_uploads', 'unlimited_sharing', 'custom_branding', 'custom_tasks', 'live_slideshow', 'advanced_analytics', 'priority_support'],
|
||||||
'paddle_product_id' => 'pro_01k8jcxvwp38gay6jj2akjg76s',
|
'paddle_product_id' => 'pro_01k8jcxvwp38gay6jj2akjg76s',
|
||||||
'paddle_price_id' => 'pri_01k8jcxw5sap4r306wcvc0ephy',
|
'paddle_price_id' => 'pri_01k8jcxw5sap4r306wcvc0ephy',
|
||||||
'description' => <<<TEXT
|
'description' => <<<'TEXT'
|
||||||
Das volle Erlebnis für alle, die keine Kompromisse machen wollen. {{max_photos}} Bilder, unbegrenzt viele Gäste, {{gallery_duration}} Galerie-Zugang und {{max_tasks}} Aufgaben – dazu kein Wasserzeichen, Live-Slideshow und Premium-Support.
|
Das volle Erlebnis für alle, die keine Kompromisse machen wollen. {{max_photos}} Bilder, unbegrenzt viele Gäste, {{gallery_duration}} Galerie-Zugang und {{max_tasks}} Aufgaben – dazu eigenes Wasserzeichen, Live-Slideshow und Premium-Support.
|
||||||
TEXT,
|
TEXT,
|
||||||
'description_translations' => [
|
'description_translations' => [
|
||||||
'de' => 'Das volle Erlebnis für alle, die keine Kompromisse machen wollen. {{max_photos}} Bilder, unbegrenzt viele Gäste, {{gallery_duration}} Galerie-Zugang und {{max_tasks}} Aufgaben – dazu kein Wasserzeichen, Live-Slideshow und Premium-Support.',
|
'de' => 'Das volle Erlebnis für alle, die keine Kompromisse machen wollen. {{max_photos}} Bilder, unbegrenzt viele Gäste, {{gallery_duration}} Galerie-Zugang und {{max_tasks}} Aufgaben – dazu eigenes Wasserzeichen, Live-Slideshow und Premium-Support.',
|
||||||
'en' => 'The full experience for anyone who refuses to compromise. {{max_photos}} photos, unlimited guests, {{gallery_duration}} of gallery access and {{max_tasks}} challenges—no watermark, live slideshow and premium support included.',
|
'en' => 'The full experience for anyone who refuses to compromise. {{max_photos}} photos, unlimited guests, {{gallery_duration}} of gallery access and {{max_tasks}} challenges—custom watermark, live slideshow and premium support included.',
|
||||||
],
|
],
|
||||||
'description_table' => [
|
'description_table' => [
|
||||||
['title' => 'Fotos', 'value' => '{{max_photos}}'],
|
['title' => 'Fotos', 'value' => '{{max_photos}}'],
|
||||||
@@ -116,111 +117,149 @@ TEXT,
|
|||||||
],
|
],
|
||||||
[
|
[
|
||||||
'slug' => 's-small-reseller',
|
'slug' => 's-small-reseller',
|
||||||
'name' => 'Reseller S',
|
'name' => 'Partner Start',
|
||||||
'name_translations' => [
|
'name_translations' => [
|
||||||
'de' => 'Reseller S',
|
'de' => 'Partner Start',
|
||||||
'en' => 'Reseller S',
|
'en' => 'Partner Start',
|
||||||
],
|
],
|
||||||
'type' => PackageType::RESELLER,
|
'type' => PackageType::RESELLER,
|
||||||
|
'included_package_slug' => 'starter',
|
||||||
'price' => 149.00,
|
'price' => 149.00,
|
||||||
'max_photos' => 1000,
|
'max_photos' => null,
|
||||||
'max_guests' => null,
|
'max_guests' => null,
|
||||||
'gallery_days' => 30,
|
'gallery_days' => null,
|
||||||
'max_tasks' => null,
|
'max_tasks' => null,
|
||||||
'watermark_allowed' => true,
|
'watermark_allowed' => true,
|
||||||
'branding_allowed' => true,
|
'branding_allowed' => true,
|
||||||
'max_events_per_year' => 5,
|
'max_events_per_year' => 5,
|
||||||
'expires_after' => now()->copy()->addYear(),
|
'expires_after' => null,
|
||||||
'features' => ['reseller_dashboard', 'custom_branding', 'priority_support'],
|
'features' => ['reseller_dashboard', 'custom_branding', 'priority_support'],
|
||||||
'paddle_product_id' => 'pro_01k8jcxvax48mhmwsfydw8ha9y',
|
'paddle_product_id' => 'pro_01k8jcxvax48mhmwsfydw8ha9y',
|
||||||
'paddle_price_id' => 'pri_01k8jcxvhe0bfasg9gg1rw70sy',
|
'paddle_price_id' => 'pri_01k8jcxvhe0bfasg9gg1rw70sy',
|
||||||
'description' => <<<TEXT
|
'description' => <<<'TEXT'
|
||||||
Das perfekte Paket für Fotografen oder Planer, die erste Erfahrungen mit Fotospiel sammeln wollen. Enthalten sind {{max_events_per_year}} Events pro Jahr mit Standard-Leistung – Branding-Optionen inklusive.
|
Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf Starter‑Niveau. Empfohlen innerhalb von 24 Monaten zu nutzen.
|
||||||
TEXT,
|
TEXT,
|
||||||
'description_translations' => [
|
'description_translations' => [
|
||||||
'de' => 'Das perfekte Paket für Fotografen oder Planer, die erste Erfahrungen mit Fotospiel sammeln wollen. Enthalten sind {{max_events_per_year}} Events pro Jahr mit Standard-Leistung – Branding-Optionen inklusive.',
|
'de' => 'Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf Starter‑Niveau. Empfohlen innerhalb von 24 Monaten zu nutzen.',
|
||||||
'en' => 'Perfect for photographers or planners getting started with Fotospiel. Includes {{max_events_per_year}} events per year with the standard feature set—branding options included.',
|
'en' => 'Event-Kontingent for Partner / Agencies: {{max_events_per_year}} events at Starter level. Recommended to use within 24 months.',
|
||||||
],
|
],
|
||||||
'description_table' => [
|
'description_table' => [
|
||||||
['title' => 'Events/Jahr', 'value' => '{{max_events_per_year}} Events'],
|
['title' => 'Events', 'value' => '{{max_events_per_year}} Events'],
|
||||||
['title' => 'Galerie', 'value' => '{{gallery_duration}}'],
|
['title' => 'Inklusive Event-Level', 'value' => 'Starter'],
|
||||||
['title' => 'Branding', 'value' => 'Logo & Farben pro Event'],
|
['title' => 'Empfehlung', 'value' => 'Empfohlen innerhalb von 24 Monaten zu nutzen.'],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'slug' => 'm-medium-reseller',
|
'slug' => 'm-medium-reseller',
|
||||||
'name' => 'Reseller M',
|
'name' => 'Partner Standard',
|
||||||
'name_translations' => [
|
'name_translations' => [
|
||||||
'de' => 'Reseller M',
|
'de' => 'Partner Standard',
|
||||||
'en' => 'Reseller M',
|
'en' => 'Partner Standard',
|
||||||
],
|
],
|
||||||
'type' => PackageType::RESELLER,
|
'type' => PackageType::RESELLER,
|
||||||
|
'included_package_slug' => 'standard',
|
||||||
'price' => 349.00,
|
'price' => 349.00,
|
||||||
'max_photos' => 1500,
|
'max_photos' => null,
|
||||||
'max_guests' => null,
|
'max_guests' => null,
|
||||||
'gallery_days' => 60,
|
'gallery_days' => null,
|
||||||
'max_tasks' => null,
|
'max_tasks' => null,
|
||||||
'watermark_allowed' => true,
|
'watermark_allowed' => true,
|
||||||
'branding_allowed' => true,
|
'branding_allowed' => true,
|
||||||
'max_events_per_year' => 15,
|
'max_events_per_year' => 15,
|
||||||
'expires_after' => now()->copy()->addYear(),
|
'expires_after' => null,
|
||||||
'features' => ['reseller_dashboard', 'custom_branding', 'priority_support', 'advanced_reporting'],
|
'features' => ['reseller_dashboard', 'custom_branding', 'priority_support', 'advanced_reporting'],
|
||||||
'paddle_product_id' => 'pro_01k8jcxtrxw7jsew52jnax901q',
|
'paddle_product_id' => 'pro_01k8jcxtrxw7jsew52jnax901q',
|
||||||
'paddle_price_id' => 'pri_01k8jcxv06nsgy8ym8mnfrfm5v',
|
'paddle_price_id' => 'pri_01k8jcxv06nsgy8ym8mnfrfm5v',
|
||||||
'description' => <<<TEXT
|
'description' => <<<'TEXT'
|
||||||
Wenn du regelmäßig Hochzeiten, Firmenfeste oder private Events betreust, ist dieses Paket ideal. {{max_events_per_year}} Events pro Jahr mit Branding-Optionen, verlängerter Galerie-Laufzeit und Reporting inklusive.
|
Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf Standard‑Niveau. Empfohlen innerhalb von 24 Monaten zu nutzen.
|
||||||
TEXT,
|
TEXT,
|
||||||
'description_translations' => [
|
'description_translations' => [
|
||||||
'de' => 'Wenn du regelmäßig Hochzeiten, Firmenfeste oder private Events betreust, ist dieses Paket ideal. {{max_events_per_year}} Events pro Jahr mit Branding-Optionen, verlängerter Galerie-Laufzeit und Reporting inklusive.',
|
'de' => 'Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf Standard‑Niveau. Empfohlen innerhalb von 24 Monaten zu nutzen.',
|
||||||
'en' => 'Designed for professionals who regularly support weddings, corporate events or private parties. {{max_events_per_year}} events per year with branding options, extended gallery runtime and reporting included.',
|
'en' => 'Event-Kontingent for Partner / Agencies: {{max_events_per_year}} events at Standard level. Recommended to use within 24 months.',
|
||||||
],
|
],
|
||||||
'description_table' => [
|
'description_table' => [
|
||||||
['title' => 'Events/Jahr', 'value' => '{{max_events_per_year}} Events'],
|
['title' => 'Events', 'value' => '{{max_events_per_year}} Events'],
|
||||||
['title' => 'Galerie', 'value' => '{{gallery_duration}}'],
|
['title' => 'Inklusive Event-Level', 'value' => 'Standard'],
|
||||||
['title' => 'Reporting', 'value' => 'Erweiterte Auswertungen'],
|
['title' => 'Empfehlung', 'value' => 'Empfohlen innerhalb von 24 Monaten zu nutzen.'],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'slug' => 'l-large-reseller',
|
'slug' => 'l-large-reseller',
|
||||||
'name' => 'Reseller L',
|
'name' => 'Partner Premium',
|
||||||
'name_translations' => [
|
'name_translations' => [
|
||||||
'de' => 'Reseller L',
|
'de' => 'Partner Premium',
|
||||||
'en' => 'Reseller L',
|
'en' => 'Partner Premium',
|
||||||
],
|
],
|
||||||
'type' => PackageType::RESELLER,
|
'type' => PackageType::RESELLER,
|
||||||
'price' => 699.00,
|
'included_package_slug' => 'pro',
|
||||||
'max_photos' => 3000,
|
'price' => 1999.00,
|
||||||
|
'max_photos' => null,
|
||||||
'max_guests' => null,
|
'max_guests' => null,
|
||||||
'gallery_days' => 90,
|
'gallery_days' => null,
|
||||||
'max_tasks' => null,
|
'max_tasks' => null,
|
||||||
'watermark_allowed' => false,
|
'watermark_allowed' => false,
|
||||||
'branding_allowed' => true,
|
'branding_allowed' => true,
|
||||||
'max_events_per_year' => 40,
|
'max_events_per_year' => 35,
|
||||||
'expires_after' => now()->copy()->addYear(),
|
'expires_after' => null,
|
||||||
'features' => ['reseller_dashboard', 'custom_branding', 'priority_support', 'advanced_reporting', 'live_slideshow'],
|
'features' => ['reseller_dashboard', 'custom_branding', 'priority_support', 'advanced_reporting', 'live_slideshow'],
|
||||||
'paddle_product_id' => 'pro_01k8jcxt7gc6g6ddavmq65txzz',
|
'paddle_product_id' => 'pro_01k8jcxt7gc6g6ddavmq65txzz',
|
||||||
'paddle_price_id' => 'pri_01k8jcxtfa07gvq43kpvpe0t8z',
|
'paddle_price_id' => 'pri_01k8jcxtfa07gvq43kpvpe0t8z',
|
||||||
'description' => <<<TEXT
|
'description' => <<<'TEXT'
|
||||||
Ideal für Agenturen, Fotografen oder Eventdienstleister mit vielen Veranstaltungen im Jahr. {{max_events_per_year}} Events inklusive, White-Label-Branding und alle Premium-Funktionen sorgen für maximale Flexibilität.
|
Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf Premium‑Niveau. Empfohlen innerhalb von 24 Monaten zu nutzen.
|
||||||
TEXT,
|
TEXT,
|
||||||
'description_translations' => [
|
'description_translations' => [
|
||||||
'de' => 'Ideal für Agenturen, Fotografen oder Eventdienstleister mit vielen Veranstaltungen im Jahr. {{max_events_per_year}} Events inklusive, White-Label-Branding und alle Premium-Funktionen sorgen für maximale Flexibilität.',
|
'de' => 'Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf Premium‑Niveau. Empfohlen innerhalb von 24 Monaten zu nutzen.',
|
||||||
'en' => 'Ideal for agencies, photographers or event providers with a packed calendar. {{max_events_per_year}} events included, white-label branding and all premium features for maximum flexibility.',
|
'en' => 'Event-Kontingent for Partner / Agencies: {{max_events_per_year}} events at Premium level. Recommended to use within 24 months.',
|
||||||
],
|
],
|
||||||
'description_table' => [
|
'description_table' => [
|
||||||
['title' => 'Events/Jahr', 'value' => '{{max_events_per_year}} Events'],
|
['title' => 'Events', 'value' => '{{max_events_per_year}} Events'],
|
||||||
['title' => 'Branding', 'value' => 'White-Label & eigene Domains'],
|
['title' => 'Inklusive Event-Level', 'value' => 'Premium'],
|
||||||
['title' => 'Extras', 'value' => 'Live-Slideshow & Premium-Features'],
|
['title' => 'Empfehlung', 'value' => 'Empfohlen innerhalb von 24 Monaten zu nutzen.'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'slug' => 'partner-premium-5',
|
||||||
|
'name' => 'Partner Premium-Kontingent (5 Events)',
|
||||||
|
'name_translations' => [
|
||||||
|
'de' => 'Partner Premium-Kontingent (5 Events)',
|
||||||
|
'en' => 'Partner Premium kontingent (5 events)',
|
||||||
|
],
|
||||||
|
'type' => PackageType::RESELLER,
|
||||||
|
'included_package_slug' => 'pro',
|
||||||
|
'price' => 549.00,
|
||||||
|
'max_photos' => null,
|
||||||
|
'max_guests' => null,
|
||||||
|
'gallery_days' => null,
|
||||||
|
'max_tasks' => null,
|
||||||
|
'watermark_allowed' => false,
|
||||||
|
'branding_allowed' => true,
|
||||||
|
'max_events_per_year' => 5,
|
||||||
|
'expires_after' => null,
|
||||||
|
'features' => ['reseller_dashboard', 'custom_branding', 'priority_support'],
|
||||||
|
'paddle_product_id' => 'pro_01kf16ttp0fph79j59x0z1cdqc',
|
||||||
|
'paddle_price_id' => 'pri_01kf16v0v2z4hse5cxq5wnah4b',
|
||||||
|
'description' => <<<'TEXT'
|
||||||
|
Premium Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf Premium‑Niveau. Empfohlen innerhalb von 24 Monaten zu nutzen.
|
||||||
|
TEXT,
|
||||||
|
'description_translations' => [
|
||||||
|
'de' => 'Premium Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf Premium‑Niveau. Empfohlen innerhalb von 24 Monaten zu nutzen.',
|
||||||
|
'en' => 'Premium Event-Kontingent for Partner / Agencies: {{max_events_per_year}} events at Premium level. Recommended to use within 24 months.',
|
||||||
|
],
|
||||||
|
'description_table' => [
|
||||||
|
['title' => 'Events', 'value' => '{{max_events_per_year}} Events'],
|
||||||
|
['title' => 'Inklusive Event-Level', 'value' => 'Premium'],
|
||||||
|
['title' => 'Empfehlung', 'value' => 'Empfohlen innerhalb von 24 Monaten zu nutzen.'],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'slug' => 'studio-annual',
|
'slug' => 'studio-annual',
|
||||||
'name' => 'Studio Jahrespaket',
|
'name' => 'Partner Jahreskontingent (24 Events)',
|
||||||
'name_translations' => [
|
'name_translations' => [
|
||||||
'de' => 'Studio Jahrespaket',
|
'de' => 'Partner Jahreskontingent (24 Events)',
|
||||||
'en' => 'Studio Annual',
|
'en' => 'Partner annual kontingent (24 events)',
|
||||||
],
|
],
|
||||||
'type' => PackageType::RESELLER,
|
'type' => PackageType::RESELLER,
|
||||||
|
'included_package_slug' => 'standard',
|
||||||
'price' => 1299.00,
|
'price' => 1299.00,
|
||||||
'max_photos' => null,
|
'max_photos' => null,
|
||||||
'max_guests' => null,
|
'max_guests' => null,
|
||||||
@@ -230,42 +269,20 @@ TEXT,
|
|||||||
'branding_allowed' => false,
|
'branding_allowed' => false,
|
||||||
'max_events_per_year' => 24,
|
'max_events_per_year' => 24,
|
||||||
'expires_after' => null,
|
'expires_after' => null,
|
||||||
'features' => ['basic_uploads', 'unlimited_sharing', 'custom_branding'],
|
'features' => ['reseller_dashboard', 'custom_branding', 'priority_support', 'advanced_reporting'],
|
||||||
'paddle_product_id' => 'pro_01k8jct3gz9ks5mg6z61q6nrxb',
|
'paddle_product_id' => 'pro_01k8jct3gz9ks5mg6z61q6nrxb',
|
||||||
'paddle_price_id' => 'pri_01k8jcxsa8axwpjnybhjbcrb06',
|
'paddle_price_id' => 'pri_01k8jcxsa8axwpjnybhjbcrb06',
|
||||||
'description' => null,
|
'description' => <<<'TEXT'
|
||||||
'description_translations' => null,
|
Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf Standard‑Niveau. Empfohlen innerhalb von 24 Monaten zu nutzen.
|
||||||
'description_table' => [],
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'slug' => 'enterprise-unlimited',
|
|
||||||
'name' => 'Enterprise / Unlimited',
|
|
||||||
'name_translations' => [
|
|
||||||
'de' => 'Enterprise / Unlimited',
|
|
||||||
'en' => 'Enterprise / Unlimited',
|
|
||||||
],
|
|
||||||
'type' => PackageType::RESELLER,
|
|
||||||
'price' => 1999.00,
|
|
||||||
'max_photos' => null,
|
|
||||||
'max_guests' => null,
|
|
||||||
'gallery_days' => null,
|
|
||||||
'max_tasks' => null,
|
|
||||||
'watermark_allowed' => false,
|
|
||||||
'branding_allowed' => true,
|
|
||||||
'max_events_per_year' => null,
|
|
||||||
'expires_after' => now()->copy()->addYear(),
|
|
||||||
'features' => ['reseller_dashboard', 'custom_branding', 'priority_support', 'advanced_reporting', 'live_slideshow', 'unlimited_sharing'],
|
|
||||||
'description' => <<<TEXT
|
|
||||||
Das Rundum-Paket für Unternehmen und Agenturen, die maximale Flexibilität brauchen. {{max_events_per_year}} Events, volles White-Label-Branding, eigene Subdomain oder App-Branding – alles individuell anpassbar, inklusive persönlicher Betreuung.
|
|
||||||
TEXT,
|
TEXT,
|
||||||
'description_translations' => [
|
'description_translations' => [
|
||||||
'de' => 'Das Rundum-Paket für Unternehmen und Agenturen, die maximale Flexibilität brauchen. {{max_events_per_year}} Events, volles White-Label-Branding, eigene Subdomain oder App-Branding – alles individuell anpassbar, inklusive persönlicher Betreuung.',
|
'de' => 'Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf Standard‑Niveau. Empfohlen innerhalb von 24 Monaten zu nutzen.',
|
||||||
'en' => 'The all-round package for enterprises and agencies needing maximum flexibility. {{max_events_per_year}} events, full white-label branding, your own subdomain or app branding—fully customisable with dedicated support.',
|
'en' => 'Event-Kontingent for Partner / Agencies: {{max_events_per_year}} events at Standard level. Recommended to use within 24 months.',
|
||||||
],
|
],
|
||||||
'description_table' => [
|
'description_table' => [
|
||||||
['title' => 'Events/Jahr', 'value' => '{{max_events_per_year}} Events'],
|
['title' => 'Events', 'value' => '{{max_events_per_year}} Events'],
|
||||||
['title' => 'Branding', 'value' => 'Eigene Subdomain oder App'],
|
['title' => 'Inklusive Event-Level', 'value' => 'Standard'],
|
||||||
['title' => 'Support', 'value' => 'Persönliche Betreuung'],
|
['title' => 'Empfehlung', 'value' => 'Empfohlen innerhalb von 24 Monaten zu nutzen.'],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
@@ -279,5 +296,7 @@ TEXT,
|
|||||||
])
|
])
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Package::where('slug', 'enterprise-unlimited')->delete();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,6 +106,24 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
restart: "no"
|
restart: "no"
|
||||||
|
|
||||||
|
photobooth-uploader-build:
|
||||||
|
image: mcr.microsoft.com/dotnet/sdk:10.0
|
||||||
|
working_dir: /var/www/html
|
||||||
|
command:
|
||||||
|
- bash
|
||||||
|
- -lc
|
||||||
|
- /var/www/html/scripts/build-photobooth-uploader.sh
|
||||||
|
environment:
|
||||||
|
DOTNET_CLI_TELEMETRY_OPTOUT: "1"
|
||||||
|
NUGET_PACKAGES: /root/.nuget/packages
|
||||||
|
volumes:
|
||||||
|
- app-code:/var/www/html
|
||||||
|
- nuget-cache:/root/.nuget/packages
|
||||||
|
depends_on:
|
||||||
|
app:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
help-sync:
|
help-sync:
|
||||||
image: ${APP_IMAGE_REPO:-fotospiel-app}:${APP_IMAGE_TAG:-latest}
|
image: ${APP_IMAGE_REPO:-fotospiel-app}:${APP_IMAGE_TAG:-latest}
|
||||||
env_file:
|
env_file:
|
||||||
@@ -340,6 +358,7 @@ volumes:
|
|||||||
external: true
|
external: true
|
||||||
name: fotospiel-${APP_ENV:-prod}-storage
|
name: fotospiel-${APP_ENV:-prod}-storage
|
||||||
app-bootstrap-cache:
|
app-bootstrap-cache:
|
||||||
|
nuget-cache:
|
||||||
photobooth-import:
|
photobooth-import:
|
||||||
photobooth-ftp-auth:
|
photobooth-ftp-auth:
|
||||||
mysql-data:
|
mysql-data:
|
||||||
|
|||||||
@@ -53,6 +53,23 @@ refresh_config_cache() {
|
|||||||
php artisan view:clear >/dev/null 2>&1 || true
|
php artisan view:clear >/dev/null 2>&1 || true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ensure_help_cache() {
|
||||||
|
cd "$APP_TARGET"
|
||||||
|
|
||||||
|
if [[ "${HELP_SYNC_ON_BOOT:-auto}" == "0" ]]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${HELP_SYNC_ON_BOOT:-auto}" == "1" ]]; then
|
||||||
|
php artisan help:sync >/dev/null 2>&1 || true
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! compgen -G "$APP_TARGET/storage/app/help/*/*/articles.json" > /dev/null; then
|
||||||
|
php artisan help:sync >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
wait_for_service() {
|
wait_for_service() {
|
||||||
local name="$1" host="$2" port="$3" timeout="$4"
|
local name="$1" host="$2" port="$3" timeout="$4"
|
||||||
local start
|
local start
|
||||||
@@ -120,6 +137,7 @@ ensure_helper_scripts
|
|||||||
prepare_storage
|
prepare_storage
|
||||||
refresh_config_cache
|
refresh_config_cache
|
||||||
wait_for_dependencies
|
wait_for_dependencies
|
||||||
|
ensure_help_cache
|
||||||
|
|
||||||
cd "$APP_TARGET"
|
cd "$APP_TARGET"
|
||||||
exec "$@"
|
exec "$@"
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ server {
|
|||||||
fastcgi_pass app:9000;
|
fastcgi_pass app:9000;
|
||||||
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||||
fastcgi_param PATH_INFO $fastcgi_path_info;
|
fastcgi_param PATH_INFO $fastcgi_path_info;
|
||||||
|
fastcgi_param HTTP_X_FORWARDED_PROTO $http_x_forwarded_proto;
|
||||||
|
fastcgi_param HTTP_X_FORWARDED_HOST $http_x_forwarded_host;
|
||||||
|
fastcgi_param HTTP_X_FORWARDED_FOR $proxy_add_x_forwarded_for;
|
||||||
|
fastcgi_param HTTP_HOST $host;
|
||||||
|
fastcgi_param HTTP_X_FORWARDED_PORT $server_port;
|
||||||
|
fastcgi_param HTTPS $http_x_forwarded_proto;
|
||||||
fastcgi_buffer_size 32k;
|
fastcgi_buffer_size 32k;
|
||||||
fastcgi_buffers 8 16k;
|
fastcgi_buffers 8 16k;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ php artisan photobooth:ingest --event=123 --max-files=20
|
|||||||
|
|
||||||
Use this when Sparkbooth runs in “Custom Upload” mode instead of FTP.
|
Use this when Sparkbooth runs in “Custom Upload” mode instead of FTP.
|
||||||
|
|
||||||
- Endpoint: `POST /api/v1/photobooth/sparkbooth/upload`
|
- Endpoint: `POST /api/v1/photobooth/upload`
|
||||||
- Auth: per-event username/password (set in Event Admin → Fotobox-Uploads; switch mode to “Sparkbooth”).
|
- Auth: per-event username/password (set in Event Admin → Fotobox-Uploads; switch mode to “Sparkbooth”).
|
||||||
- Body (multipart/form-data): `media` (file or base64), `username`, `password`, optionally `name`, `email`, `message`.
|
- Body (multipart/form-data): `media` (file or base64), `username`, `password`, optionally `name`, `email`, `message`.
|
||||||
- Response:
|
- Response:
|
||||||
@@ -99,7 +99,7 @@ Use this when Sparkbooth runs in “Custom Upload” mode instead of FTP.
|
|||||||
Example cURL (JSON response):
|
Example cURL (JSON response):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST https://app.example.com/api/v1/photobooth/sparkbooth/upload \
|
curl -X POST https://app.example.com/api/v1/photobooth/upload \
|
||||||
-F "media=@/path/to/photo.jpg" \
|
-F "media=@/path/to/photo.jpg" \
|
||||||
-F "username=PB123" \
|
-F "username=PB123" \
|
||||||
-F "password=SECRET" \
|
-F "password=SECRET" \
|
||||||
@@ -109,7 +109,7 @@ curl -X POST https://app.example.com/api/v1/photobooth/sparkbooth/upload \
|
|||||||
Example cURL (request XML response):
|
Example cURL (request XML response):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST https://app.example.com/api/v1/photobooth/sparkbooth/upload \
|
curl -X POST https://app.example.com/api/v1/photobooth/upload \
|
||||||
-F "media=@/path/to/photo.jpg" \
|
-F "media=@/path/to/photo.jpg" \
|
||||||
-F "username=PB123" \
|
-F "username=PB123" \
|
||||||
-F "password=SECRET" \
|
-F "password=SECRET" \
|
||||||
|
|||||||
@@ -65,6 +65,25 @@ return [
|
|||||||
'benefit4' => 'Unterstuetzung, wenn du sie brauchst',
|
'benefit4' => 'Unterstuetzung, wenn du sie brauchst',
|
||||||
'footer' => 'Brauchst du Hilfe? Antworte einfach auf diese E-Mail.',
|
'footer' => 'Brauchst du Hilfe? Antworte einfach auf diese E-Mail.',
|
||||||
],
|
],
|
||||||
|
'photobooth_uploader' => [
|
||||||
|
'subject' => 'Fotospiel Uploader App fuer :event',
|
||||||
|
'preheader' => 'Download-Links fuer die Fotospiel Photobooth Uploader App.',
|
||||||
|
'hero_title' => 'Hallo :name,',
|
||||||
|
'hero_subtitle' => 'Deine Uploader App fuer :event ist bereit.',
|
||||||
|
'body' => 'Hier findest du die Download-Links fuer die Fotospiel Photobooth Uploader App. Installiere die passende Version auf dem Photobooth-PC, bevor dein Event startet.',
|
||||||
|
'downloads_title' => 'Download-Links',
|
||||||
|
'downloads' => [
|
||||||
|
'windows' => 'Windows (x64)',
|
||||||
|
'macos' => 'macOS (x64)',
|
||||||
|
'linux' => 'Linux (x64)',
|
||||||
|
],
|
||||||
|
'cta_windows' => 'Download fuer Windows',
|
||||||
|
'cta_macos' => 'Download fuer macOS',
|
||||||
|
'cta_linux' => 'Download fuer Linux',
|
||||||
|
'credentials_hint' => 'Die Zugangsdaten bleiben im Admin-Dashboard. Erstelle einen Verbindungscode, sobald du die App koppeln moechtest.',
|
||||||
|
'footer' => 'Fragen? Antworte einfach auf diese E-Mail.',
|
||||||
|
'event_fallback' => 'dein Event',
|
||||||
|
],
|
||||||
'package_limits' => [
|
'package_limits' => [
|
||||||
'package_fallback' => 'Paket',
|
'package_fallback' => 'Paket',
|
||||||
'team_fallback' => 'dein Team',
|
'team_fallback' => 'dein Team',
|
||||||
|
|||||||
@@ -65,6 +65,25 @@ return [
|
|||||||
'benefit4' => 'Friendly support whenever you need help',
|
'benefit4' => 'Friendly support whenever you need help',
|
||||||
'footer' => 'Need help? Reply to this email.',
|
'footer' => 'Need help? Reply to this email.',
|
||||||
],
|
],
|
||||||
|
'photobooth_uploader' => [
|
||||||
|
'subject' => 'Fotospiel Uploader App for :event',
|
||||||
|
'preheader' => 'Download links for the Fotospiel Photobooth Uploader.',
|
||||||
|
'hero_title' => 'Hi :name,',
|
||||||
|
'hero_subtitle' => 'Your uploader app for :event is ready.',
|
||||||
|
'body' => 'Here are the download links for the Fotospiel Photobooth Uploader. Install the right version on the photobooth PC before your event starts.',
|
||||||
|
'downloads_title' => 'Download links',
|
||||||
|
'downloads' => [
|
||||||
|
'windows' => 'Windows (x64)',
|
||||||
|
'macos' => 'macOS (x64)',
|
||||||
|
'linux' => 'Linux (x64)',
|
||||||
|
],
|
||||||
|
'cta_windows' => 'Download for Windows',
|
||||||
|
'cta_macos' => 'Download for macOS',
|
||||||
|
'cta_linux' => 'Download for Linux',
|
||||||
|
'credentials_hint' => 'Connection credentials stay in the admin dashboard. Generate a connect code when you are ready to pair the app.',
|
||||||
|
'footer' => 'Questions? Reply to this email and we will help.',
|
||||||
|
'event_fallback' => 'your event',
|
||||||
|
],
|
||||||
'package_limits' => [
|
'package_limits' => [
|
||||||
'package_fallback' => 'package',
|
'package_fallback' => 'package',
|
||||||
'team_fallback' => 'your team',
|
'team_fallback' => 'your team',
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
BIN
public/fonts/google/archivo-black/ArchivoBlack-400-normal.ttf
Normal file
BIN
public/fonts/google/archivo-black/ArchivoBlack-400-normal.ttf
Normal file
Binary file not shown.
@@ -1,490 +1,106 @@
|
|||||||
/* Auto-generated by fonts:sync-google */
|
/* Auto-generated by fonts:sync-google */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Roboto';
|
font-family: 'Manifest Font';
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url('/fonts/google/roboto/Roboto-400-normal.ttf') format('truetype');
|
src: url('/fonts/google/manifest-font/regular.woff2') format('woff2');
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Roboto';
|
font-family: 'Plus Jakarta Sans';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('/fonts/google/plus-jakarta-sans/PlusJakartaSans-400-normal.ttf') format('truetype');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Plus Jakarta Sans';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('/fonts/google/plus-jakarta-sans/PlusJakartaSans-400-italic.ttf') format('truetype');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Plus Jakarta Sans';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('/fonts/google/plus-jakarta-sans/PlusJakartaSans-500-normal.ttf') format('truetype');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Plus Jakarta Sans';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 500;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('/fonts/google/plus-jakarta-sans/PlusJakartaSans-500-italic.ttf') format('truetype');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Plus Jakarta Sans';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('/fonts/google/plus-jakarta-sans/PlusJakartaSans-600-normal.ttf') format('truetype');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Plus Jakarta Sans';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('/fonts/google/plus-jakarta-sans/PlusJakartaSans-600-italic.ttf') format('truetype');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Plus Jakarta Sans';
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url('/fonts/google/roboto/Roboto-700-normal.ttf') format('truetype');
|
src: url('/fonts/google/plus-jakarta-sans/PlusJakartaSans-700-normal.ttf') format('truetype');
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Open Sans';
|
font-family: 'Plus Jakarta Sans';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 700;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('/fonts/google/plus-jakarta-sans/PlusJakartaSans-700-italic.ttf') format('truetype');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Space Grotesk';
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url('/fonts/google/open-sans/OpenSans-400-normal.ttf') format('truetype');
|
src: url('/fonts/google/space-grotesk/SpaceGrotesk-400-normal.ttf') format('truetype');
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Open Sans';
|
font-family: 'Space Grotesk';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('/fonts/google/space-grotesk/SpaceGrotesk-500-normal.ttf') format('truetype');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Space Grotesk';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('/fonts/google/space-grotesk/SpaceGrotesk-600-normal.ttf') format('truetype');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Space Grotesk';
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url('/fonts/google/open-sans/OpenSans-700-normal.ttf') format('truetype');
|
src: url('/fonts/google/space-grotesk/SpaceGrotesk-700-normal.ttf') format('truetype');
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Noto Sans JP';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/noto-sans-jp/NotoSansJp-400-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Noto Sans JP';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/noto-sans-jp/NotoSansJp-700-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Lato';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/lato/Lato-400-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Lato';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/lato/Lato-700-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Montserrat';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/montserrat/Montserrat-400-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Montserrat';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/montserrat/Montserrat-700-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Inter';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/inter/Inter-400-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Inter';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/inter/Inter-700-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Poppins';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/poppins/Poppins-400-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Poppins';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/poppins/Poppins-700-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Material Icons';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/material-icons/MaterialIcons-400-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Roboto Condensed';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/roboto-condensed/RobotoCondensed-400-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Roboto Condensed';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/roboto-condensed/RobotoCondensed-700-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Roboto Mono';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/roboto-mono/RobotoMono-400-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Roboto Mono';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/roboto-mono/RobotoMono-700-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Arimo';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/arimo/Arimo-400-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Arimo';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/arimo/Arimo-700-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Oswald';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/oswald/Oswald-400-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Oswald';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/oswald/Oswald-700-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Noto Sans';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/noto-sans/NotoSans-400-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Noto Sans';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/noto-sans/NotoSans-700-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Raleway';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/raleway/Raleway-400-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Raleway';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/raleway/Raleway-700-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Nunito Sans';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/nunito-sans/NunitoSans-400-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Nunito Sans';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/nunito-sans/NunitoSans-700-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Nunito';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/nunito/Nunito-400-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Nunito';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/nunito/Nunito-700-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Playfair Display';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/playfair-display/PlayfairDisplay-400-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Playfair Display';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/playfair-display/PlayfairDisplay-700-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Ubuntu';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/ubuntu/Ubuntu-400-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Ubuntu';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/ubuntu/Ubuntu-700-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Rubik';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/rubik/Rubik-400-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Rubik';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/rubik/Rubik-700-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Noto Sans KR';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/noto-sans-kr/NotoSansKr-400-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Noto Sans KR';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/noto-sans-kr/NotoSansKr-700-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Roboto Slab';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/roboto-slab/RobotoSlab-400-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Roboto Slab';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/roboto-slab/RobotoSlab-700-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'DM Sans';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/dm-sans/DmSans-400-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'DM Sans';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/dm-sans/DmSans-700-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Kanit';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/kanit/Kanit-400-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Kanit';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/kanit/Kanit-700-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Merriweather';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/merriweather/Merriweather-400-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Merriweather';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/merriweather/Merriweather-700-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Work Sans';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/work-sans/WorkSans-400-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Work Sans';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/work-sans/WorkSans-700-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'PT Sans';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/pt-sans/PtSans-400-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'PT Sans';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/pt-sans/PtSans-700-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Material Symbols Outlined';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/material-symbols-outlined/MaterialSymbolsOutlined-400-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Material Symbols Outlined';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/material-symbols-outlined/MaterialSymbolsOutlined-700-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Lora';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/lora/Lora-400-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Lora';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/lora/Lora-700-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Quicksand';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/quicksand/Quicksand-400-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Quicksand';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/quicksand/Quicksand-700-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Mulish';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/mulish/Mulish-400-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Mulish';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/mulish/Mulish-700-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Noto Sans TC';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/noto-sans-tc/NotoSansTc-400-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Noto Sans TC';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/noto-sans-tc/NotoSansTc-700-normal.ttf') format('truetype');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
@@ -520,337 +136,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Figtree';
|
font-family: 'Archivo Black';
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url('/fonts/google/figtree/Figtree-400-normal.ttf') format('truetype');
|
src: url('/fonts/google/archivo-black/ArchivoBlack-400-normal.ttf') format('truetype');
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Figtree';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/figtree/Figtree-700-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Inconsolata';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/inconsolata/Inconsolata-400-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Inconsolata';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/inconsolata/Inconsolata-700-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'IBM Plex Sans';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/ibm-plex-sans/IbmPlexSans-400-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'IBM Plex Sans';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/ibm-plex-sans/IbmPlexSans-700-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Fira Sans';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/fira-sans/FiraSans-400-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Fira Sans';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/fira-sans/FiraSans-700-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Barlow';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/barlow/Barlow-400-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Barlow';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/barlow/Barlow-700-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Outfit';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/outfit/Outfit-400-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Outfit';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/outfit/Outfit-700-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Source Sans 3';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/source-sans-3/SourceSans3-400-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Source Sans 3';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/source-sans-3/SourceSans3-700-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Bebas Neue';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/bebas-neue/BebasNeue-400-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Titillium Web';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/titillium-web/TitilliumWeb-400-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Titillium Web';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/titillium-web/TitilliumWeb-700-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Karla';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/karla/Karla-400-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Karla';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/karla/Karla-700-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Material Icons Outlined';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/material-icons-outlined/MaterialIconsOutlined-400-normal.otf') format('opentype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'PT Serif';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/pt-serif/PtSerif-400-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'PT Serif';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/pt-serif/PtSerif-700-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Noto Serif';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/noto-serif/NotoSerif-400-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Noto Serif';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/noto-serif/NotoSerif-700-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Jost';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/jost/Jost-400-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Jost';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/jost/Jost-700-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Prompt';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/prompt/Prompt-400-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Prompt';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/prompt/Prompt-700-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Heebo';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/heebo/Heebo-400-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Heebo';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/heebo/Heebo-700-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Saira';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/saira/Saira-400-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Saira';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/saira/Saira-700-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Archivo';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/archivo/Archivo-400-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Archivo';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/archivo/Archivo-700-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Fraunces';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/fraunces/Fraunces-400-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Fraunces';
|
|
||||||
font-style: italic;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/fraunces/Fraunces-400-italic.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Fraunces';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 500;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/fraunces/Fraunces-500-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Fraunces';
|
|
||||||
font-style: italic;
|
|
||||||
font-weight: 500;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/fraunces/Fraunces-500-italic.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Fraunces';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 600;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/fraunces/Fraunces-600-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Fraunces';
|
|
||||||
font-style: italic;
|
|
||||||
font-weight: 600;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/fraunces/Fraunces-600-italic.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Fraunces';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/fraunces/Fraunces-700-normal.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Fraunces';
|
|
||||||
font-style: italic;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/google/fraunces/Fraunces-700-italic.ttf') format('truetype');
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1,149 @@
|
|||||||
{"fonts":[{"family":"Manifest Font","category":"sans-serif","variants":[{"variant":"regular","weight":400,"style":"normal","url":"\/fonts\/google\/manifest-font\/regular.woff2"}]}]}
|
{
|
||||||
|
"generated_at": "2026-01-15T21:06:13+01:00",
|
||||||
|
"source": "google-webfonts",
|
||||||
|
"count": 5,
|
||||||
|
"fonts": [
|
||||||
|
{
|
||||||
|
"family": "Manifest Font",
|
||||||
|
"category": "sans-serif",
|
||||||
|
"variants": [
|
||||||
|
{
|
||||||
|
"variant": "regular",
|
||||||
|
"weight": 400,
|
||||||
|
"style": "normal",
|
||||||
|
"url": "/fonts/google/manifest-font/regular.woff2"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"family": "Plus Jakarta Sans",
|
||||||
|
"slug": "plus-jakarta-sans",
|
||||||
|
"category": "sans-serif",
|
||||||
|
"variants": [
|
||||||
|
{
|
||||||
|
"variant": "regular",
|
||||||
|
"weight": 400,
|
||||||
|
"style": "normal",
|
||||||
|
"url": "/fonts/google/plus-jakarta-sans/PlusJakartaSans-400-normal.ttf"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"variant": "italic",
|
||||||
|
"weight": 400,
|
||||||
|
"style": "italic",
|
||||||
|
"url": "/fonts/google/plus-jakarta-sans/PlusJakartaSans-400-italic.ttf"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"variant": 500,
|
||||||
|
"weight": 500,
|
||||||
|
"style": "normal",
|
||||||
|
"url": "/fonts/google/plus-jakarta-sans/PlusJakartaSans-500-normal.ttf"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"variant": "500italic",
|
||||||
|
"weight": 500,
|
||||||
|
"style": "italic",
|
||||||
|
"url": "/fonts/google/plus-jakarta-sans/PlusJakartaSans-500-italic.ttf"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"variant": 600,
|
||||||
|
"weight": 600,
|
||||||
|
"style": "normal",
|
||||||
|
"url": "/fonts/google/plus-jakarta-sans/PlusJakartaSans-600-normal.ttf"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"variant": "600italic",
|
||||||
|
"weight": 600,
|
||||||
|
"style": "italic",
|
||||||
|
"url": "/fonts/google/plus-jakarta-sans/PlusJakartaSans-600-italic.ttf"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"variant": 700,
|
||||||
|
"weight": 700,
|
||||||
|
"style": "normal",
|
||||||
|
"url": "/fonts/google/plus-jakarta-sans/PlusJakartaSans-700-normal.ttf"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"variant": "700italic",
|
||||||
|
"weight": 700,
|
||||||
|
"style": "italic",
|
||||||
|
"url": "/fonts/google/plus-jakarta-sans/PlusJakartaSans-700-italic.ttf"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"family": "Space Grotesk",
|
||||||
|
"slug": "space-grotesk",
|
||||||
|
"category": "sans-serif",
|
||||||
|
"variants": [
|
||||||
|
{
|
||||||
|
"variant": "regular",
|
||||||
|
"weight": 400,
|
||||||
|
"style": "normal",
|
||||||
|
"url": "/fonts/google/space-grotesk/SpaceGrotesk-400-normal.ttf"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"variant": 500,
|
||||||
|
"weight": 500,
|
||||||
|
"style": "normal",
|
||||||
|
"url": "/fonts/google/space-grotesk/SpaceGrotesk-500-normal.ttf"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"variant": 600,
|
||||||
|
"weight": 600,
|
||||||
|
"style": "normal",
|
||||||
|
"url": "/fonts/google/space-grotesk/SpaceGrotesk-600-normal.ttf"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"variant": 700,
|
||||||
|
"weight": 700,
|
||||||
|
"style": "normal",
|
||||||
|
"url": "/fonts/google/space-grotesk/SpaceGrotesk-700-normal.ttf"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"family": "Manrope",
|
||||||
|
"slug": "manrope",
|
||||||
|
"category": "sans-serif",
|
||||||
|
"variants": [
|
||||||
|
{
|
||||||
|
"variant": "regular",
|
||||||
|
"weight": 400,
|
||||||
|
"style": "normal",
|
||||||
|
"url": "/fonts/google/manrope/Manrope-400-normal.ttf"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"variant": 500,
|
||||||
|
"weight": 500,
|
||||||
|
"style": "normal",
|
||||||
|
"url": "/fonts/google/manrope/Manrope-500-normal.ttf"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"variant": 600,
|
||||||
|
"weight": 600,
|
||||||
|
"style": "normal",
|
||||||
|
"url": "/fonts/google/manrope/Manrope-600-normal.ttf"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"variant": 700,
|
||||||
|
"weight": 700,
|
||||||
|
"style": "normal",
|
||||||
|
"url": "/fonts/google/manrope/Manrope-700-normal.ttf"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"family": "Archivo Black",
|
||||||
|
"slug": "archivo-black",
|
||||||
|
"category": "sans-serif",
|
||||||
|
"variants": [
|
||||||
|
{
|
||||||
|
"variant": "regular",
|
||||||
|
"weight": 400,
|
||||||
|
"style": "normal",
|
||||||
|
"url": "/fonts/google/archivo-black/ArchivoBlack-400-normal.ttf"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
public/fonts/google/space-grotesk/SpaceGrotesk-400-normal.ttf
Normal file
BIN
public/fonts/google/space-grotesk/SpaceGrotesk-400-normal.ttf
Normal file
Binary file not shown.
BIN
public/fonts/google/space-grotesk/SpaceGrotesk-500-normal.ttf
Normal file
BIN
public/fonts/google/space-grotesk/SpaceGrotesk-500-normal.ttf
Normal file
Binary file not shown.
BIN
public/fonts/google/space-grotesk/SpaceGrotesk-600-normal.ttf
Normal file
BIN
public/fonts/google/space-grotesk/SpaceGrotesk-600-normal.ttf
Normal file
Binary file not shown.
BIN
public/fonts/google/space-grotesk/SpaceGrotesk-700-normal.ttf
Normal file
BIN
public/fonts/google/space-grotesk/SpaceGrotesk-700-normal.ttf
Normal file
Binary file not shown.
@@ -10,19 +10,44 @@
|
|||||||
"register": "Registrieren",
|
"register": "Registrieren",
|
||||||
"home": "Startseite",
|
"home": "Startseite",
|
||||||
"packages": "Pakete",
|
"packages": "Pakete",
|
||||||
|
"how_it_works": "So geht's",
|
||||||
"blog": "Blog",
|
"blog": "Blog",
|
||||||
"occasions": {
|
"occasions": {
|
||||||
|
"label": "Anlässe",
|
||||||
"wedding": "Hochzeit",
|
"wedding": "Hochzeit",
|
||||||
"birthday": "Geburtstag",
|
"birthday": "Geburtstag",
|
||||||
|
"confirmation": "Konfirmation/Jugendweihe",
|
||||||
"corporate": "Firmenevent"
|
"corporate": "Firmenevent"
|
||||||
},
|
},
|
||||||
"contact": "Kontakt"
|
"contact": "Kontakt",
|
||||||
|
"cta": "Jetzt ausprobieren",
|
||||||
|
"utility": "Darstellung und Sprache öffnen",
|
||||||
|
"appearance": "Darstellung",
|
||||||
|
"appearance_light": "Hell",
|
||||||
|
"appearance_dark": "Dunkel",
|
||||||
|
"language": "Sprache"
|
||||||
},
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"title": "Die Fotospiel App",
|
"title": "Die Fotospiel App",
|
||||||
"description": "Melde dich mit deinem Fotospiel-Zugang an und steuere deine Events zentral in einem Dashboard.",
|
"description": "Melde dich mit deinem Fotospiel-Zugang an und steuere deine Events zentral in einem Dashboard.",
|
||||||
"brand": "Die Fotospiel App",
|
"brand": "Die Fotospiel App",
|
||||||
"logo_alt": "Logo Die Fotospiel App",
|
"logo_alt": "Logo Die Fotospiel App",
|
||||||
|
"hero_tagline": "Event-Tech mit Herz",
|
||||||
|
"hero_heading": "Willkommen zurück bei der Fotospiel App",
|
||||||
|
"hero_subheading": "Verwalte Events, Galerien und Gästelisten in einem liebevoll gestalteten Dashboard.",
|
||||||
|
"hero_footer": {
|
||||||
|
"headline": "Noch kein Account?",
|
||||||
|
"subline": "Entdecke unsere Packages und erlebe Fotospiel live.",
|
||||||
|
"cta": "Packages entdecken"
|
||||||
|
},
|
||||||
|
"highlights": {
|
||||||
|
"moments": "Momente in Echtzeit teilen",
|
||||||
|
"moments_description": "Uploads landen sofort in der Event-Galerie – ohne App-Download.",
|
||||||
|
"branding": "Branding & Slideshows, die begeistern",
|
||||||
|
"branding_description": "Konfiguriere Slideshow, Wasserzeichen und Aufgaben für dein Event.",
|
||||||
|
"privacy": "Sicherer Zugang über Tokens",
|
||||||
|
"privacy_description": "Eventzugänge bleiben geschützt – DSGVO-konform mit Join Tokens."
|
||||||
|
},
|
||||||
"identifier": "E-Mail oder Username",
|
"identifier": "E-Mail oder Username",
|
||||||
"identifier_placeholder": "z. B. name@beispiel.de oder hochzeit_julia",
|
"identifier_placeholder": "z. B. name@beispiel.de oder hochzeit_julia",
|
||||||
"username_or_email": "Username oder E-Mail",
|
"username_or_email": "Username oder E-Mail",
|
||||||
@@ -39,6 +64,20 @@
|
|||||||
"no_account": "Noch keinen Zugang?",
|
"no_account": "Noch keinen Zugang?",
|
||||||
"sign_up": "Jetzt registrieren"
|
"sign_up": "Jetzt registrieren"
|
||||||
},
|
},
|
||||||
|
"forgot": {
|
||||||
|
"title": "Passwort zurücksetzen",
|
||||||
|
"description": "Gib deine E-Mail-Adresse ein, um einen Reset-Link zu erhalten.",
|
||||||
|
"email_label": "E-Mail-Adresse",
|
||||||
|
"email_placeholder": "name@beispiel.de",
|
||||||
|
"submit": "Reset-Link per E-Mail senden",
|
||||||
|
"back_prefix": "Oder zurück zu",
|
||||||
|
"back": "Login"
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"ui": {
|
||||||
|
"language_select": "Sprache auswählen"
|
||||||
|
}
|
||||||
|
},
|
||||||
"register": {
|
"register": {
|
||||||
"title": "Registrieren",
|
"title": "Registrieren",
|
||||||
"name": "Vollständiger Name",
|
"name": "Vollständiger Name",
|
||||||
|
|||||||
@@ -91,18 +91,18 @@
|
|||||||
"title": "Unsere Packages",
|
"title": "Unsere Packages",
|
||||||
"price": "Preis",
|
"price": "Preis",
|
||||||
"features": "Features",
|
"features": "Features",
|
||||||
"subscription_annual": "Jährliches Abonnement",
|
"subscription_annual": "Event-Kontingent",
|
||||||
"auto_renew": "automatische Verlängerung",
|
"auto_renew": "automatische Verlängerung",
|
||||||
"cancel_anytime": "kündbar jederzeit",
|
"cancel_anytime": "kündbar jederzeit",
|
||||||
"trial_start": "Kostenloser Trial für :days Tage",
|
"trial_start": "Kostenloser Trial für :days Tage",
|
||||||
"reseller_benefits": "Vorteile für Reseller",
|
"reseller_benefits": "Vorteile für Partner / Agenturen",
|
||||||
"unlimited_events": "Unbegrenzte Events",
|
"unlimited_events": "Unbegrenzte Events",
|
||||||
"custom_branding": "Benutzerdefiniertes Branding",
|
"custom_branding": "Benutzerdefiniertes Branding",
|
||||||
"available": "Verfügbar",
|
"available": "Verfügbar",
|
||||||
"not_available": "Nicht verfügbar",
|
"not_available": "Nicht verfügbar",
|
||||||
"standard_support": "Standard-Support",
|
"standard_support": "Standard-Support",
|
||||||
"priority_support": "Priorisierter Support",
|
"priority_support": "Priorisierter Support",
|
||||||
"cancel_link": "Abo kündigen: :link",
|
"cancel_link": "Paket verwalten: :link",
|
||||||
"hero_title": "Entdecken Sie unsere flexiblen Packages",
|
"hero_title": "Entdecken Sie unsere flexiblen Packages",
|
||||||
"hero_description": "Von kostenlosem Einstieg bis Premium-Features: Passen Sie Ihr Event-Paket an Ihre Bedürfnisse an. Einfach, sicher und skalierbar.",
|
"hero_description": "Von kostenlosem Einstieg bis Premium-Features: Passen Sie Ihr Event-Paket an Ihre Bedürfnisse an. Einfach, sicher und skalierbar.",
|
||||||
"hero_secondary": "Teste den kompletten Gäste-Flow in unserer Live-Demo – kein Login, kein App-Store.",
|
"hero_secondary": "Teste den kompletten Gäste-Flow in unserer Live-Demo – kein Login, kein App-Store.",
|
||||||
@@ -110,21 +110,24 @@
|
|||||||
"cta_explore": "Pakete entdecken",
|
"cta_explore": "Pakete entdecken",
|
||||||
"cta_explore_highlight": "Lieblingspaket sichern",
|
"cta_explore_highlight": "Lieblingspaket sichern",
|
||||||
"tab_endcustomer": "Endkunden",
|
"tab_endcustomer": "Endkunden",
|
||||||
"tab_reseller": "Reseller & Agenturen",
|
"tab_reseller": "Partner / Agentur",
|
||||||
"section_endcustomer": "Packages für Endkunden (Einmalkauf pro Event)",
|
"section_endcustomer": "Packages für Endkunden (Einmalkauf pro Event)",
|
||||||
"section_reseller": "Packages für Reseller (Jährliches Abo)",
|
"section_reseller": "Packages für Partner / Agenturen (Event-Kontingent)",
|
||||||
"free": "Kostenlos",
|
"free": "Kostenlos",
|
||||||
"one_time": "Einmalkauf",
|
"one_time": "Einmalkauf",
|
||||||
"subscription": "Abo",
|
"subscription": "Event-Kontingent",
|
||||||
"year": "Jahr",
|
"year": "Jahr",
|
||||||
"max_photos": "Fotos",
|
"max_photos": "Fotos",
|
||||||
"max_guests": "Gäste",
|
"max_guests": "Gäste",
|
||||||
"gallery_days": "Tage Galerie",
|
"gallery_days": "Tage Galerie",
|
||||||
"max_events_year": "Events/Jahr",
|
"max_events_year": "Events enthalten",
|
||||||
|
"included_package_label": "Inklusive Event-Level",
|
||||||
|
"recommended_usage_label": "Empfehlung",
|
||||||
|
"recommended_usage_window": "Empfohlen innerhalb von 24 Monaten zu nutzen.",
|
||||||
"buy_now": "Jetzt kaufen",
|
"buy_now": "Jetzt kaufen",
|
||||||
"subscribe_now": "Jetzt abonnieren",
|
"subscribe_now": "Event-Kontingent kaufen",
|
||||||
"register_buy": "Registrieren und kaufen",
|
"register_buy": "Registrieren und kaufen",
|
||||||
"register_subscribe": "Registrieren und abonnieren",
|
"register_subscribe": "Registrieren und kaufen",
|
||||||
"faq_title": "Häufige Fragen zu Packages",
|
"faq_title": "Häufige Fragen zu Packages",
|
||||||
"faq_lead": "Antworten auf die wichtigsten Fragen – mehr Details findest du im Guide „So funktioniert’s“.",
|
"faq_lead": "Antworten auf die wichtigsten Fragen – mehr Details findest du im Guide „So funktioniert’s“.",
|
||||||
"faq_q1": "Was ist ein Package?",
|
"faq_q1": "Was ist ein Package?",
|
||||||
@@ -140,6 +143,8 @@
|
|||||||
"feature_live_slideshow": "Live-Slideshow",
|
"feature_live_slideshow": "Live-Slideshow",
|
||||||
"feature_analytics": "Analytics",
|
"feature_analytics": "Analytics",
|
||||||
"feature_watermark": "Wasserzeichen",
|
"feature_watermark": "Wasserzeichen",
|
||||||
|
"feature_watermark_base": "Unser Wasserzeichen",
|
||||||
|
"feature_watermark_custom": "Eigenes Wasserzeichen",
|
||||||
"feature_branding": "Branding",
|
"feature_branding": "Branding",
|
||||||
"feature_support": "Support",
|
"feature_support": "Support",
|
||||||
"feature_basic_uploads": "Basis-Uploads",
|
"feature_basic_uploads": "Basis-Uploads",
|
||||||
@@ -151,7 +156,7 @@
|
|||||||
"feature_limited_sharing": "Begrenztes Teilen",
|
"feature_limited_sharing": "Begrenztes Teilen",
|
||||||
"feature_no_branding": "Kein Branding",
|
"feature_no_branding": "Kein Branding",
|
||||||
"feature_0": "Basis-Feature",
|
"feature_0": "Basis-Feature",
|
||||||
"feature_reseller_dashboard": "Reseller-Dashboard",
|
"feature_reseller_dashboard": "Partner-Dashboard",
|
||||||
"feature_custom_branding": "Benutzerdefiniertes Branding",
|
"feature_custom_branding": "Benutzerdefiniertes Branding",
|
||||||
"feature_advanced_reporting": "Erweiterte Berichterstattung",
|
"feature_advanced_reporting": "Erweiterte Berichterstattung",
|
||||||
"badge_most_popular": "Beliebteste Wahl",
|
"badge_most_popular": "Beliebteste Wahl",
|
||||||
@@ -159,6 +164,7 @@
|
|||||||
"badge_starter": "Perfekt für den Start",
|
"badge_starter": "Perfekt für den Start",
|
||||||
"billing_per_event": "pro Event",
|
"billing_per_event": "pro Event",
|
||||||
"billing_per_year": "pro Jahr",
|
"billing_per_year": "pro Jahr",
|
||||||
|
"billing_per_kontingent": "pro Kontingent",
|
||||||
"more_features": "+{{count}} weitere Features",
|
"more_features": "+{{count}} weitere Features",
|
||||||
"feature_overview": "Feature-Überblick",
|
"feature_overview": "Feature-Überblick",
|
||||||
"order_hint": "Sofort startklar – keine versteckten Kosten, sichere Zahlung über Paddle.",
|
"order_hint": "Sofort startklar – keine versteckten Kosten, sichere Zahlung über Paddle.",
|
||||||
@@ -171,7 +177,7 @@
|
|||||||
"tasks": "Aufgaben",
|
"tasks": "Aufgaben",
|
||||||
"gallery": "Galerie",
|
"gallery": "Galerie",
|
||||||
"branding": "Branding",
|
"branding": "Branding",
|
||||||
"events_per_year": "Events pro Jahr"
|
"events_per_year": "Events enthalten"
|
||||||
},
|
},
|
||||||
"more_details_tab": "Mehr Details",
|
"more_details_tab": "Mehr Details",
|
||||||
"quick_facts": "Schnelle Fakten",
|
"quick_facts": "Schnelle Fakten",
|
||||||
@@ -183,7 +189,7 @@
|
|||||||
"limits_label": "Limits & Kapazitäten",
|
"limits_label": "Limits & Kapazitäten",
|
||||||
"limits_label_hint": "Alle Kennzahlen auf einen Blick – ideal für Planung und Freigaben.",
|
"limits_label_hint": "Alle Kennzahlen auf einen Blick – ideal für Planung und Freigaben.",
|
||||||
"for_endcustomers": "Für Endkunden",
|
"for_endcustomers": "Für Endkunden",
|
||||||
"for_resellers": "Für Reseller",
|
"for_resellers": "Für Partner / Agenturen",
|
||||||
"view_details": "Details ansehen",
|
"view_details": "Details ansehen",
|
||||||
"details_show": "Details anzeigen",
|
"details_show": "Details anzeigen",
|
||||||
"comparison_title": "Packages vergleichen",
|
"comparison_title": "Packages vergleichen",
|
||||||
@@ -197,14 +203,14 @@
|
|||||||
"watermark_label": "Wasserzeichen",
|
"watermark_label": "Wasserzeichen",
|
||||||
"no_watermark": "Kein Wasserzeichen",
|
"no_watermark": "Kein Wasserzeichen",
|
||||||
"max_tenants": "Max. Tenants",
|
"max_tenants": "Max. Tenants",
|
||||||
"max_events": "Max. Events/Jahr",
|
"max_events": "Events enthalten",
|
||||||
"faq_free": "Was ist das Free Package?",
|
"faq_free": "Was ist das Free Package?",
|
||||||
"faq_upgrade": "Kann ich upgraden?",
|
"faq_upgrade": "Kann ich upgraden?",
|
||||||
"faq_reseller": "Was für Reseller?",
|
"faq_reseller": "Was für Partner / Agenturen?",
|
||||||
"faq_payment": "Zahlung sicher?",
|
"faq_payment": "Zahlung sicher?",
|
||||||
"faq_free_desc": "Das Free Package bietet grundlegende Features für kleine Events mit begrenzter Anzahl an Fotos und Gästen.",
|
"faq_free_desc": "Das Free Package bietet grundlegende Features für kleine Events mit begrenzter Anzahl an Fotos und Gästen.",
|
||||||
"faq_upgrade_desc": "Ja, Sie können jederzeit upgraden, um mehr Features und Limits zu erhalten. Der Upgrade ist nahtlos und Ihre Daten bleiben erhalten.",
|
"faq_upgrade_desc": "Ja, Sie können jederzeit upgraden, um mehr Features und Limits zu erhalten. Der Upgrade ist nahtlos und Ihre Daten bleiben erhalten.",
|
||||||
"faq_reseller_desc": "Reseller-Packages sind jährliche Abos für Agenturen, die mehrere Events verwalten. Inklusive Dashboard und Branding-Optionen.",
|
"faq_reseller_desc": "Partner-Pakete sind Event-Kontingente für Agenturen, die mehrere Events verwalten. Inklusive Dashboard und Branding-Optionen.",
|
||||||
"faq_payment_desc": "Alle Zahlungen werden über sichere Provider wie Paddle abgewickelt. Ihre Daten sind GDPR-konform geschützt.",
|
"faq_payment_desc": "Alle Zahlungen werden über sichere Provider wie Paddle abgewickelt. Ihre Daten sind GDPR-konform geschützt.",
|
||||||
"testimonials": {
|
"testimonials": {
|
||||||
"anna": "Fotospiel hat unsere Hochzeit perfekt gemacht! Die Gäste konnten einfach Fotos teilen, und die Galerie war ein Hit.",
|
"anna": "Fotospiel hat unsere Hochzeit perfekt gemacht! Die Gäste konnten einfach Fotos teilen, und die Galerie war ein Hit.",
|
||||||
@@ -350,7 +356,7 @@
|
|||||||
"purchase_complete_desc": "Melden Sie sich an, um fortzufahren.",
|
"purchase_complete_desc": "Melden Sie sich an, um fortzufahren.",
|
||||||
"login": "Anmelden",
|
"login": "Anmelden",
|
||||||
"no_account": "Kein Konto? Registrieren",
|
"no_account": "Kein Konto? Registrieren",
|
||||||
"manage_subscription": "Abo verwalten",
|
"manage_subscription": "Kontingent verwalten",
|
||||||
"stripe_dashboard": "Stripe-Dashboard",
|
"stripe_dashboard": "Stripe-Dashboard",
|
||||||
"trial_activated": "Trial aktiviert für 14 Tage!"
|
"trial_activated": "Trial aktiviert für 14 Tage!"
|
||||||
},
|
},
|
||||||
@@ -486,7 +492,7 @@
|
|||||||
"summary_title": "Ihre Bestellung",
|
"summary_title": "Ihre Bestellung",
|
||||||
"package_label": "Ausgewähltes Paket",
|
"package_label": "Ausgewähltes Paket",
|
||||||
"billing_type_one_time": "Einmalkauf (pro Event)",
|
"billing_type_one_time": "Einmalkauf (pro Event)",
|
||||||
"billing_type_subscription": "Abo (wiederkehrend)",
|
"billing_type_subscription": "Einmalkauf (Kontingent)",
|
||||||
"legal_links_intro": "Details zur Belehrung:",
|
"legal_links_intro": "Details zur Belehrung:",
|
||||||
"link_terms": "AGB",
|
"link_terms": "AGB",
|
||||||
"link_privacy": "Datenschutzerklärung",
|
"link_privacy": "Datenschutzerklärung",
|
||||||
@@ -495,7 +501,7 @@
|
|||||||
"checkbox_terms_error": "Bitte bestätigen Sie, dass Sie AGB, Datenschutzerklärung und Widerrufsbelehrung gelesen haben.",
|
"checkbox_terms_error": "Bitte bestätigen Sie, dass Sie AGB, Datenschutzerklärung und Widerrufsbelehrung gelesen haben.",
|
||||||
"checkbox_digital_content_label": "Ich verlange ausdrücklich, dass Sie vor Ablauf der Widerrufsfrist mit der Ausführung der digitalen Dienstleistungen (Freischaltung meines Event-Packages inkl. Galerie und Hosting) beginnen. Mir ist bekannt, dass ich bei vollständiger Vertragserfüllung mein Widerrufsrecht verliere.",
|
"checkbox_digital_content_label": "Ich verlange ausdrücklich, dass Sie vor Ablauf der Widerrufsfrist mit der Ausführung der digitalen Dienstleistungen (Freischaltung meines Event-Packages inkl. Galerie und Hosting) beginnen. Mir ist bekannt, dass ich bei vollständiger Vertragserfüllung mein Widerrufsrecht verliere.",
|
||||||
"checkbox_digital_content_error": "Bitte bestätigen Sie, dass Sie dem sofortigen Beginn der digitalen Dienstleistung und dem damit verbundenen vorzeitigen Erlöschen des Widerrufsrechts zustimmen.",
|
"checkbox_digital_content_error": "Bitte bestätigen Sie, dass Sie dem sofortigen Beginn der digitalen Dienstleistung und dem damit verbundenen vorzeitigen Erlöschen des Widerrufsrechts zustimmen.",
|
||||||
"hint_subscription_withdrawal": "Bei Abonnements haben Verbraucher ein 14-tägiges Widerrufsrecht ab Vertragsschluss. Im Falle eines Widerrufs nach Leistungsbeginn behalten wir uns angemessenen Wertersatz für bereits erbrachte Leistungen vor.",
|
"hint_subscription_withdrawal": "Bei Einmalkäufen haben Verbraucher ein 14-tägiges Widerrufsrecht ab Vertragsschluss. Im Falle eines Widerrufs nach Leistungsbeginn behalten wir uns angemessenen Wertersatz für bereits erbrachte Leistungen vor.",
|
||||||
"open_withdrawal": "Widerrufsbelehrung anzeigen",
|
"open_withdrawal": "Widerrufsbelehrung anzeigen",
|
||||||
"modal_description": "So informieren wir über das Widerrufsrecht. Der volle Text gilt für deinen Kauf.",
|
"modal_description": "So informieren wir über das Widerrufsrecht. Der volle Text gilt für deinen Kauf.",
|
||||||
"modal_loading": "Widerrufsbelehrung wird geladen…",
|
"modal_loading": "Widerrufsbelehrung wird geladen…",
|
||||||
@@ -768,7 +774,7 @@
|
|||||||
"timeline": [
|
"timeline": [
|
||||||
{
|
{
|
||||||
"title": "Event vorbereiten",
|
"title": "Event vorbereiten",
|
||||||
"body": "Account registrieren, Paket wählen und Branding setzen. Abos laufen über Paddle, Mobile-Apps über RevenueCat.",
|
"body": "Account registrieren, Paket wählen und Branding setzen. Kontingente laufen über Paddle, Mobile-Apps über RevenueCat.",
|
||||||
"tips": [
|
"tips": [
|
||||||
"Testevent anlegen, um Upload-Flow vorab zu prüfen",
|
"Testevent anlegen, um Upload-Flow vorab zu prüfen",
|
||||||
"Trauzeug:innen oder Kolleg:innen als Co-Hosts einladen"
|
"Trauzeug:innen oder Kolleg:innen als Co-Hosts einladen"
|
||||||
|
|||||||
@@ -10,19 +10,44 @@
|
|||||||
"register": "Register",
|
"register": "Register",
|
||||||
"home": "Home",
|
"home": "Home",
|
||||||
"packages": "Packages",
|
"packages": "Packages",
|
||||||
|
"how_it_works": "How it works",
|
||||||
"blog": "Blog",
|
"blog": "Blog",
|
||||||
"occasions": {
|
"occasions": {
|
||||||
|
"label": "Occasions",
|
||||||
"wedding": "Wedding",
|
"wedding": "Wedding",
|
||||||
"birthday": "Birthday",
|
"birthday": "Birthday",
|
||||||
|
"confirmation": "Confirmation/Youth dedication",
|
||||||
"corporate": "Corporate Event"
|
"corporate": "Corporate Event"
|
||||||
},
|
},
|
||||||
"contact": "Contact"
|
"contact": "Contact",
|
||||||
|
"cta": "Try now",
|
||||||
|
"utility": "Open appearance and language",
|
||||||
|
"appearance": "Appearance",
|
||||||
|
"appearance_light": "Light",
|
||||||
|
"appearance_dark": "Dark",
|
||||||
|
"language": "Language"
|
||||||
},
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"title": "Die Fotospiel App",
|
"title": "Die Fotospiel App",
|
||||||
"description": "Sign in with your Fotospiel account to manage every event in one place.",
|
"description": "Sign in with your Fotospiel account to manage every event in one place.",
|
||||||
"brand": "Die Fotospiel App",
|
"brand": "Die Fotospiel App",
|
||||||
"logo_alt": "Fotospiel App logo",
|
"logo_alt": "Fotospiel App logo",
|
||||||
|
"hero_tagline": "Event tech with heart",
|
||||||
|
"hero_heading": "Welcome back to the Fotospiel App",
|
||||||
|
"hero_subheading": "Manage events, galleries, and guest lists in one beautifully crafted dashboard.",
|
||||||
|
"hero_footer": {
|
||||||
|
"headline": "No account yet?",
|
||||||
|
"subline": "Explore our packages and experience Fotospiel live.",
|
||||||
|
"cta": "Discover packages"
|
||||||
|
},
|
||||||
|
"highlights": {
|
||||||
|
"moments": "Share moments in real time",
|
||||||
|
"moments_description": "Uploads land instantly in the event gallery — no app download needed.",
|
||||||
|
"branding": "Branding & slideshows that impress",
|
||||||
|
"branding_description": "Configure slideshows, watermarks, and tasks for your event.",
|
||||||
|
"privacy": "Secure access via tokens",
|
||||||
|
"privacy_description": "Event access stays protected — GDPR-compliant with join tokens."
|
||||||
|
},
|
||||||
"identifier": "Email or Username",
|
"identifier": "Email or Username",
|
||||||
"identifier_placeholder": "you@example.com or username",
|
"identifier_placeholder": "you@example.com or username",
|
||||||
"username_or_email": "Username or Email",
|
"username_or_email": "Username or Email",
|
||||||
@@ -39,6 +64,20 @@
|
|||||||
"no_account": "Don't have access yet?",
|
"no_account": "Don't have access yet?",
|
||||||
"sign_up": "Create an account"
|
"sign_up": "Create an account"
|
||||||
},
|
},
|
||||||
|
"forgot": {
|
||||||
|
"title": "Reset your password",
|
||||||
|
"description": "Enter your email address to receive a password reset link.",
|
||||||
|
"email_label": "Email address",
|
||||||
|
"email_placeholder": "name@example.com",
|
||||||
|
"submit": "Email password reset link",
|
||||||
|
"back_prefix": "Or, return to",
|
||||||
|
"back": "Login"
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"ui": {
|
||||||
|
"language_select": "Select language"
|
||||||
|
}
|
||||||
|
},
|
||||||
"register": {
|
"register": {
|
||||||
"title": "Register",
|
"title": "Register",
|
||||||
"name": "Full Name",
|
"name": "Full Name",
|
||||||
|
|||||||
@@ -82,14 +82,14 @@
|
|||||||
"packages": {
|
"packages": {
|
||||||
"title": "Our Packages",
|
"title": "Our Packages",
|
||||||
"features": "Features",
|
"features": "Features",
|
||||||
"subscription_annual": "Annual Subscription",
|
"subscription_annual": "Event bundle",
|
||||||
"auto_renew": "auto-renew",
|
"auto_renew": "auto-renew",
|
||||||
"cancel_anytime": "cancel anytime",
|
"cancel_anytime": "cancel anytime",
|
||||||
"trial_start": "Free Trial for :days days",
|
"trial_start": "Free Trial for :days days",
|
||||||
"reseller_benefits": "Benefits for Resellers",
|
"reseller_benefits": "Benefits for Partner / Agencies",
|
||||||
"unlimited_events": "Unlimited Events",
|
"unlimited_events": "Unlimited Events",
|
||||||
"priority_support": "Priority Support",
|
"priority_support": "Priority Support",
|
||||||
"cancel_link": "Cancel Subscription: :link",
|
"cancel_link": "Manage package: :link",
|
||||||
"hero_title": "Discover our flexible Packages",
|
"hero_title": "Discover our flexible Packages",
|
||||||
"hero_description": "From free entry to premium features: Tailor your event package to your needs. Simple, secure, and scalable.",
|
"hero_description": "From free entry to premium features: Tailor your event package to your needs. Simple, secure, and scalable.",
|
||||||
"hero_secondary": "Experience the full guest flow in our live demo – no login, no install.",
|
"hero_secondary": "Experience the full guest flow in our live demo – no login, no install.",
|
||||||
@@ -97,21 +97,24 @@
|
|||||||
"cta_explore": "Discover Packages",
|
"cta_explore": "Discover Packages",
|
||||||
"cta_explore_highlight": "Explore top packages",
|
"cta_explore_highlight": "Explore top packages",
|
||||||
"tab_endcustomer": "End Customers",
|
"tab_endcustomer": "End Customers",
|
||||||
"tab_reseller": "Resellers & Agencies",
|
"tab_reseller": "Partner / Agency",
|
||||||
"section_endcustomer": "Packages for End Customers (One-time purchase per event)",
|
"section_endcustomer": "Packages for End Customers (One-time purchase per event)",
|
||||||
"section_reseller": "Packages for Resellers (Annual Subscription)",
|
"section_reseller": "Packages for Partner / Agencies (Event bundle)",
|
||||||
"free": "Free",
|
"free": "Free",
|
||||||
"one_time": "One-time purchase",
|
"one_time": "One-time purchase",
|
||||||
"subscription": "Subscription",
|
"subscription": "Event bundle",
|
||||||
"year": "Year",
|
"year": "Year",
|
||||||
"max_photos": "Photos",
|
"max_photos": "Photos",
|
||||||
"max_guests": "Guests",
|
"max_guests": "Guests",
|
||||||
"gallery_days": "Gallery Days",
|
"gallery_days": "Gallery Days",
|
||||||
"max_events_year": "Events/Year",
|
"max_events_year": "Events included",
|
||||||
|
"included_package_label": "Included event tier",
|
||||||
|
"recommended_usage_label": "Recommendation",
|
||||||
|
"recommended_usage_window": "Recommended to use within 24 months.",
|
||||||
"buy_now": "Buy Now",
|
"buy_now": "Buy Now",
|
||||||
"subscribe_now": "Subscribe Now",
|
"subscribe_now": "Buy event bundle",
|
||||||
"register_buy": "Register and Buy",
|
"register_buy": "Register and Buy",
|
||||||
"register_subscribe": "Register and Subscribe",
|
"register_subscribe": "Register and buy",
|
||||||
"faq_title": "Frequently Asked Questions about Packages",
|
"faq_title": "Frequently Asked Questions about Packages",
|
||||||
"faq_lead": "Quick answers to the essentials – check “How it works” for the full deep dive.",
|
"faq_lead": "Quick answers to the essentials – check “How it works” for the full deep dive.",
|
||||||
"faq_q1": "What is a Package?",
|
"faq_q1": "What is a Package?",
|
||||||
@@ -127,6 +130,8 @@
|
|||||||
"feature_live_slideshow": "Live Slideshow",
|
"feature_live_slideshow": "Live Slideshow",
|
||||||
"feature_analytics": "Analytics",
|
"feature_analytics": "Analytics",
|
||||||
"feature_watermark": "Watermark",
|
"feature_watermark": "Watermark",
|
||||||
|
"feature_watermark_base": "Our watermark",
|
||||||
|
"feature_watermark_custom": "Custom watermark",
|
||||||
"feature_branding": "Branding",
|
"feature_branding": "Branding",
|
||||||
"feature_support": "Support",
|
"feature_support": "Support",
|
||||||
"feature_basic_uploads": "Basic Uploads",
|
"feature_basic_uploads": "Basic Uploads",
|
||||||
@@ -138,7 +143,7 @@
|
|||||||
"feature_limited_sharing": "Limited Sharing",
|
"feature_limited_sharing": "Limited Sharing",
|
||||||
"feature_no_branding": "No Branding",
|
"feature_no_branding": "No Branding",
|
||||||
"feature_0": "Basic Feature",
|
"feature_0": "Basic Feature",
|
||||||
"feature_reseller_dashboard": "Reseller Dashboard",
|
"feature_reseller_dashboard": "Partner dashboard",
|
||||||
"feature_custom_branding": "Custom Branding",
|
"feature_custom_branding": "Custom Branding",
|
||||||
"feature_advanced_reporting": "Advanced Reporting",
|
"feature_advanced_reporting": "Advanced Reporting",
|
||||||
"badge_most_popular": "Most Popular",
|
"badge_most_popular": "Most Popular",
|
||||||
@@ -146,6 +151,7 @@
|
|||||||
"badge_starter": "Perfect Starter",
|
"badge_starter": "Perfect Starter",
|
||||||
"billing_per_event": "per event",
|
"billing_per_event": "per event",
|
||||||
"billing_per_year": "per year",
|
"billing_per_year": "per year",
|
||||||
|
"billing_per_bundle": "per bundle",
|
||||||
"more_features": "+{{count}} more features",
|
"more_features": "+{{count}} more features",
|
||||||
"feature_overview": "Feature overview",
|
"feature_overview": "Feature overview",
|
||||||
"order_hint": "Launch instantly – secure Paddle checkout, no hidden fees.",
|
"order_hint": "Launch instantly – secure Paddle checkout, no hidden fees.",
|
||||||
@@ -157,7 +163,7 @@
|
|||||||
"tasks": "Challenges",
|
"tasks": "Challenges",
|
||||||
"gallery": "Gallery",
|
"gallery": "Gallery",
|
||||||
"branding": "Branding",
|
"branding": "Branding",
|
||||||
"events_per_year": "Events per year"
|
"events_per_year": "Events included"
|
||||||
},
|
},
|
||||||
"more_details_tab": "More Details",
|
"more_details_tab": "More Details",
|
||||||
"quick_facts": "Quick Facts",
|
"quick_facts": "Quick Facts",
|
||||||
@@ -169,7 +175,7 @@
|
|||||||
"limits_label": "Limits & Capacity",
|
"limits_label": "Limits & Capacity",
|
||||||
"limits_label_hint": "Understand the exact limits for planning and approvals.",
|
"limits_label_hint": "Understand the exact limits for planning and approvals.",
|
||||||
"for_endcustomers": "For End Customers",
|
"for_endcustomers": "For End Customers",
|
||||||
"for_resellers": "For Resellers",
|
"for_resellers": "For Partner / Agencies",
|
||||||
"view_details": "View details",
|
"view_details": "View details",
|
||||||
"details_show": "Show Details",
|
"details_show": "Show Details",
|
||||||
"comparison_title": "Compare Packages",
|
"comparison_title": "Compare Packages",
|
||||||
@@ -188,10 +194,10 @@
|
|||||||
"not_available": "Not available",
|
"not_available": "Not available",
|
||||||
"standard_support": "Standard support",
|
"standard_support": "Standard support",
|
||||||
"max_tenants": "Max. Tenants",
|
"max_tenants": "Max. Tenants",
|
||||||
"max_events": "Max. Events/Year",
|
"max_events": "Events included",
|
||||||
"faq_free": "What is the Free Package?",
|
"faq_free": "What is the Free Package?",
|
||||||
"faq_upgrade": "Can I upgrade?",
|
"faq_upgrade": "Can I upgrade?",
|
||||||
"faq_reseller": "What for Resellers?",
|
"faq_reseller": "What for Partner / Agencies?",
|
||||||
"faq_payment": "Payment secure?",
|
"faq_payment": "Payment secure?",
|
||||||
"testimonials": {
|
"testimonials": {
|
||||||
"anna": "Fotospiel made our wedding perfect! Guests could easily share photos, and the gallery was a hit.",
|
"anna": "Fotospiel made our wedding perfect! Guests could easily share photos, and the gallery was a hit.",
|
||||||
@@ -336,7 +342,7 @@
|
|||||||
"purchase_complete_desc": "Log in to continue.",
|
"purchase_complete_desc": "Log in to continue.",
|
||||||
"login": "Log In",
|
"login": "Log In",
|
||||||
"no_account": "No Account? Register",
|
"no_account": "No Account? Register",
|
||||||
"manage_subscription": "Manage Subscription",
|
"manage_subscription": "Manage bundle",
|
||||||
"stripe_dashboard": "Stripe Dashboard",
|
"stripe_dashboard": "Stripe Dashboard",
|
||||||
"trial_activated": "Trial activated for 14 days!"
|
"trial_activated": "Trial activated for 14 days!"
|
||||||
},
|
},
|
||||||
@@ -479,7 +485,7 @@
|
|||||||
"summary_title": "Your order",
|
"summary_title": "Your order",
|
||||||
"package_label": "Selected package",
|
"package_label": "Selected package",
|
||||||
"billing_type_one_time": "One-time purchase (per event)",
|
"billing_type_one_time": "One-time purchase (per event)",
|
||||||
"billing_type_subscription": "Subscription (recurring)",
|
"billing_type_subscription": "One-time purchase (bundle)",
|
||||||
"legal_links_intro": "Details on the withdrawal policy:",
|
"legal_links_intro": "Details on the withdrawal policy:",
|
||||||
"link_terms": "Terms & Conditions",
|
"link_terms": "Terms & Conditions",
|
||||||
"link_privacy": "Privacy Policy",
|
"link_privacy": "Privacy Policy",
|
||||||
@@ -488,7 +494,7 @@
|
|||||||
"checkbox_terms_error": "Please confirm that you have read and accepted the Terms, Privacy Policy and Right of Withdrawal.",
|
"checkbox_terms_error": "Please confirm that you have read and accepted the Terms, Privacy Policy and Right of Withdrawal.",
|
||||||
"checkbox_digital_content_label": "I expressly request that you begin providing the digital services (activation of my event package including gallery and hosting) before the withdrawal period has expired. I understand that I lose my right of withdrawal once the contract has been fully performed.",
|
"checkbox_digital_content_label": "I expressly request that you begin providing the digital services (activation of my event package including gallery and hosting) before the withdrawal period has expired. I understand that I lose my right of withdrawal once the contract has been fully performed.",
|
||||||
"checkbox_digital_content_error": "Please confirm that you agree to the immediate start of the digital service and the related early expiry of the right of withdrawal.",
|
"checkbox_digital_content_error": "Please confirm that you agree to the immediate start of the digital service and the related early expiry of the right of withdrawal.",
|
||||||
"hint_subscription_withdrawal": "For subscriptions, consumers have a 14-day right of withdrawal from the conclusion of the contract. In case of withdrawal after the start of the service, we reserve the right to claim appropriate compensation for the value of services already provided.",
|
"hint_subscription_withdrawal": "For one-time purchases, consumers have a 14-day right of withdrawal from the conclusion of the contract. In case of withdrawal after the start of the service, we reserve the right to claim appropriate compensation for the value of services already provided.",
|
||||||
"open_withdrawal": "View withdrawal policy",
|
"open_withdrawal": "View withdrawal policy",
|
||||||
"modal_description": "Below is the current withdrawal policy for your purchase.",
|
"modal_description": "Below is the current withdrawal policy for your purchase.",
|
||||||
"modal_loading": "Loading withdrawal policy…",
|
"modal_loading": "Loading withdrawal policy…",
|
||||||
|
|||||||
@@ -121,6 +121,7 @@
|
|||||||
--guest-radius: 14px;
|
--guest-radius: 14px;
|
||||||
--guest-button-style: filled;
|
--guest-button-style: filled;
|
||||||
--guest-link: #007aff;
|
--guest-link: #007aff;
|
||||||
|
--guest-font-scale: 1;
|
||||||
--guest-font-family: 'Montserrat', 'Inter', 'Helvetica Neue', system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
|
--guest-font-family: 'Montserrat', 'Inter', 'Helvetica Neue', system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
--guest-body-font: 'Montserrat', 'Inter', 'Helvetica Neue', system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
|
--guest-body-font: 'Montserrat', 'Inter', 'Helvetica Neue', system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
--guest-heading-font: 'Playfair Display', 'Montserrat', 'Inter', 'Helvetica Neue', system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
|
--guest-heading-font: 'Playfair Display', 'Montserrat', 'Inter', 'Helvetica Neue', system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
@@ -511,6 +512,21 @@ h4,
|
|||||||
--sidebar-ring: oklch(0.439 0 0);
|
--sidebar-ring: oklch(0.439 0 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html.guest-theme {
|
||||||
|
--background: var(--guest-background);
|
||||||
|
--card: var(--guest-surface);
|
||||||
|
--popover: var(--guest-surface);
|
||||||
|
background-color: var(--guest-background);
|
||||||
|
font-size: calc(16px * var(--guest-font-scale, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
html.guest-theme.dark {
|
||||||
|
--background: var(--guest-background);
|
||||||
|
--card: var(--guest-surface);
|
||||||
|
--popover: var(--guest-surface);
|
||||||
|
background-color: var(--guest-background);
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes mobile-shimmer {
|
@keyframes mobile-shimmer {
|
||||||
0% {
|
0% {
|
||||||
background-position: -200% 0;
|
background-position: -200% 0;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Button } from '@tamagui/button';
|
|||||||
import { useTheme } from '@tamagui/core';
|
import { useTheme } from '@tamagui/core';
|
||||||
|
|
||||||
const DEV_TENANT_KEYS = [
|
const DEV_TENANT_KEYS = [
|
||||||
{ key: 'cust-standard-empty', label: 'Endkunde – Standard (kein Event)' },
|
{ key: 'cust-standard-empty', label: 'Endkunde – Starter (kein Event)' },
|
||||||
{ key: 'cust-starter-wedding', label: 'Endkunde – Starter (Hochzeit)' },
|
{ key: 'cust-starter-wedding', label: 'Endkunde – Starter (Hochzeit)' },
|
||||||
{ key: 'reseller-s-active', label: 'Reseller S – 3 aktive Events' },
|
{ key: 'reseller-s-active', label: 'Reseller S – 3 aktive Events' },
|
||||||
{ key: 'reseller-s-full', label: 'Reseller S – voll belegt (5/5)' },
|
{ key: 'reseller-s-full', label: 'Reseller S – voll belegt (5/5)' },
|
||||||
|
|||||||
19
resources/js/admin/__tests__/api.test.ts
Normal file
19
resources/js/admin/__tests__/api.test.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { normalizeTenantPackage } from '../api';
|
||||||
|
|
||||||
|
describe('normalizeTenantPackage', () => {
|
||||||
|
it('keeps remaining_events null when payload is null', () => {
|
||||||
|
const normalized = normalizeTenantPackage({ id: 1, remaining_events: null, used_events: 0 } as any);
|
||||||
|
expect(normalized.remaining_events).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps remaining_events null when payload is missing', () => {
|
||||||
|
const normalized = normalizeTenantPackage({ id: 1, used_events: 0 } as any);
|
||||||
|
expect(normalized.remaining_events).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('coerces remaining_events to number when provided', () => {
|
||||||
|
const normalized = normalizeTenantPackage({ id: 1, remaining_events: '2', used_events: 0 } as any);
|
||||||
|
expect(normalized.remaining_events).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -116,6 +116,7 @@ export type TenantEvent = {
|
|||||||
} | null;
|
} | null;
|
||||||
limits?: EventLimitSummary | null;
|
limits?: EventLimitSummary | null;
|
||||||
addons?: EventAddonSummary[];
|
addons?: EventAddonSummary[];
|
||||||
|
member_permissions?: string[] | null;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -218,6 +219,11 @@ export type PhotoboothStatus = {
|
|||||||
metrics?: PhotoboothStatusMetrics | null;
|
metrics?: PhotoboothStatusMetrics | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PhotoboothConnectCode = {
|
||||||
|
code: string;
|
||||||
|
expires_at: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type EventAddonCheckout = {
|
export type EventAddonCheckout = {
|
||||||
addon_key: string;
|
addon_key: string;
|
||||||
quantity?: number;
|
quantity?: number;
|
||||||
@@ -428,6 +434,8 @@ export type TenantPackageSummary = {
|
|||||||
id: number;
|
id: number;
|
||||||
package_id: number;
|
package_id: number;
|
||||||
package_name: string;
|
package_name: string;
|
||||||
|
package_type: string | null;
|
||||||
|
included_package_slug: string | null;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
used_events: number;
|
used_events: number;
|
||||||
remaining_events: number | null;
|
remaining_events: number | null;
|
||||||
@@ -738,6 +746,7 @@ type EventSavePayload = {
|
|||||||
status?: 'draft' | 'published' | 'archived';
|
status?: 'draft' | 'published' | 'archived';
|
||||||
is_active?: boolean;
|
is_active?: boolean;
|
||||||
package_id?: number;
|
package_id?: number;
|
||||||
|
service_package_slug?: string;
|
||||||
accepted_waiver?: boolean;
|
accepted_waiver?: boolean;
|
||||||
settings?: Record<string, unknown> & {
|
settings?: Record<string, unknown> & {
|
||||||
live_show?: LiveShowSettings;
|
live_show?: LiveShowSettings;
|
||||||
@@ -925,6 +934,11 @@ function normalizeEvent(event: JsonValue): TenantEvent {
|
|||||||
settings,
|
settings,
|
||||||
package: event.package ?? null,
|
package: event.package ?? null,
|
||||||
limits: (event.limits ?? null) as EventLimitSummary | null,
|
limits: (event.limits ?? null) as EventLimitSummary | null,
|
||||||
|
member_permissions: Array.isArray(event.member_permissions)
|
||||||
|
? (event.member_permissions as string[])
|
||||||
|
: event.member_permissions
|
||||||
|
? String(event.member_permissions).split(',').map((entry) => entry.trim())
|
||||||
|
: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
return normalized;
|
return normalized;
|
||||||
@@ -997,15 +1011,28 @@ function normalizeDashboard(payload: JsonValue | null): DashboardSummary | null
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeTenantPackage(pkg: JsonValue): TenantPackageSummary {
|
export function normalizeTenantPackage(pkg: JsonValue): TenantPackageSummary {
|
||||||
const packageData = pkg.package ?? {};
|
const packageData = pkg.package ?? {};
|
||||||
return {
|
return {
|
||||||
id: Number(pkg.id ?? 0),
|
id: Number(pkg.id ?? 0),
|
||||||
package_id: Number(pkg.package_id ?? packageData.id ?? 0),
|
package_id: Number(pkg.package_id ?? packageData.id ?? 0),
|
||||||
package_name: String(packageData.name ?? pkg.package_name ?? 'Unbekanntes Package'),
|
package_name: String(packageData.name ?? pkg.package_name ?? 'Unbekanntes Package'),
|
||||||
|
package_type:
|
||||||
|
typeof (packageData as any).type === 'string'
|
||||||
|
? String((packageData as any).type)
|
||||||
|
: typeof (pkg as any).package_type === 'string'
|
||||||
|
? String((pkg as any).package_type)
|
||||||
|
: null,
|
||||||
|
included_package_slug:
|
||||||
|
typeof (packageData as any).included_package_slug === 'string'
|
||||||
|
? String((packageData as any).included_package_slug)
|
||||||
|
: typeof (pkg as any).included_package_slug === 'string'
|
||||||
|
? String((pkg as any).included_package_slug)
|
||||||
|
: null,
|
||||||
active: Boolean(pkg.active ?? false),
|
active: Boolean(pkg.active ?? false),
|
||||||
used_events: Number(pkg.used_events ?? 0),
|
used_events: Number(pkg.used_events ?? 0),
|
||||||
remaining_events: pkg.remaining_events !== undefined ? Number(pkg.remaining_events) : null,
|
remaining_events:
|
||||||
|
pkg.remaining_events === undefined || pkg.remaining_events === null ? null : Number(pkg.remaining_events),
|
||||||
price: packageData.price !== undefined ? Number(packageData.price) : pkg.price ?? null,
|
price: packageData.price !== undefined ? Number(packageData.price) : pkg.price ?? null,
|
||||||
currency: packageData.currency ?? pkg.currency ?? 'EUR',
|
currency: packageData.currency ?? pkg.currency ?? 'EUR',
|
||||||
purchased_at: pkg.purchased_at ?? pkg.created_at ?? null,
|
purchased_at: pkg.purchased_at ?? pkg.created_at ?? null,
|
||||||
@@ -2041,6 +2068,35 @@ export async function disableEventPhotobooth(slug: string, options?: { mode?: 'f
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createEventPhotoboothConnectCode(
|
||||||
|
slug: string,
|
||||||
|
options?: { expires_in_minutes?: number }
|
||||||
|
): Promise<PhotoboothConnectCode> {
|
||||||
|
const body = options ? JSON.stringify(options) : undefined;
|
||||||
|
const headers = body ? { 'Content-Type': 'application/json' } : undefined;
|
||||||
|
|
||||||
|
const response = await authorizedFetch(`${photoboothEndpoint(slug)}/connect-codes`, {
|
||||||
|
method: 'POST',
|
||||||
|
body,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await jsonOrThrow<{ data?: JsonValue }>(response, 'Failed to create photobooth connect code');
|
||||||
|
const record = (data.data ?? {}) as Record<string, JsonValue>;
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: typeof record.code === 'string' ? record.code : '',
|
||||||
|
expires_at: typeof record.expires_at === 'string' ? record.expires_at : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendEventPhotoboothUploaderEmail(slug: string): Promise<void> {
|
||||||
|
const response = await authorizedFetch(`${photoboothEndpoint(slug)}/uploader-email`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
await jsonOrThrow<{ message?: string }>(response, 'Failed to send photobooth uploader email');
|
||||||
|
}
|
||||||
|
|
||||||
export async function submitTenantFeedback(payload: {
|
export async function submitTenantFeedback(payload: {
|
||||||
category: string;
|
category: string;
|
||||||
sentiment?: 'positive' | 'neutral' | 'negative';
|
sentiment?: 'positive' | 'neutral' | 'negative';
|
||||||
@@ -2065,11 +2121,19 @@ export async function submitTenantFeedback(payload: {
|
|||||||
export type Package = {
|
export type Package = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
slug?: string;
|
||||||
|
type?: 'endcustomer' | 'reseller';
|
||||||
price: number;
|
price: number;
|
||||||
max_photos: number | null;
|
max_photos: number | null;
|
||||||
max_guests: number | null;
|
max_guests: number | null;
|
||||||
gallery_days: number | null;
|
gallery_days: number | null;
|
||||||
features: Record<string, boolean>;
|
max_events_per_year?: number | null;
|
||||||
|
included_package_slug?: string | null;
|
||||||
|
paddle_price_id?: string | null;
|
||||||
|
paddle_product_id?: string | null;
|
||||||
|
branding_allowed?: boolean | null;
|
||||||
|
watermark_allowed?: boolean | null;
|
||||||
|
features: string[] | Record<string, boolean> | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function getPackages(type: 'endcustomer' | 'reseller' = 'endcustomer'): Promise<Package[]> {
|
export async function getPackages(type: 'endcustomer' | 'reseller' = 'endcustomer'): Promise<Package[]> {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user