Compare commits
97 Commits
main
...
8634d16359
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
e4100f7800 | ||
|
|
7786e3d134 | ||
|
|
30f3d148bb | ||
|
|
1970c259ed | ||
|
|
dc5c80cda4 | ||
|
|
75a9bcee12 | ||
|
|
6fe363640f | ||
|
|
3df0542013 | ||
|
|
4f4a527010 | ||
|
|
e69c94ad20 | ||
|
|
5afa96251b | ||
|
|
24f053d4c4 | ||
|
|
ec360ed860 | ||
|
|
83e78d7c66 | ||
|
|
9b1c5bf978 | ||
|
|
fb23a0a2f3 | ||
|
|
2287e7f32c | ||
|
|
cceed361b7 | ||
|
|
02363792c8 | ||
|
|
e93a00f0fc | ||
|
|
c1be7dd1ef | ||
|
|
f01a0e823b | ||
|
|
915aede66e | ||
|
|
b854e3feaa | ||
|
|
4bcaef53f7 | ||
|
|
8f1d3a3eb6 | ||
|
|
ab2cf3e023 | ||
|
|
ce0ab269c9 | ||
|
|
dce24bb86a | ||
|
|
03bf178d61 | ||
|
|
8ebaf6c31d | ||
|
|
1b6dc63ec6 | ||
|
|
accc63f4a2 | ||
|
|
59e318e7b9 | ||
|
|
3de1d3deab | ||
|
|
e9afbeb028 | ||
|
|
3e2b63f71f | ||
|
|
cff014ede5 | ||
|
|
8c5d3b93d5 | ||
|
|
22cb7ed7ce |
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
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
{"id":"fotospiel-app-1we","title":"Live Show: define trusted uploader rules \u0026 default retention window","description":"# Decision: Trusted uploader rules \u0026 default retention window\n\n## Context\nModeration is required for many events, but we also want a fast “auto-approve trusted sources” mode.\n\nWe currently track photo ingestion sources in `photos.ingest_source` (e.g. `tenant_admin`, `photobooth`, `sparkbooth`, `guest_pwa`). Guest uploads are token-based and do not have strong identity guarantees.\n\n## Definitions\n- **Trusted uploader**: uploads that can bypass Live Show manual moderation.\n- **Retention window**: time window for which approved photos remain eligible for rotation in the Live Show.\n\n## Options (trusted rules)\n### A) Trust by ingestion source only (recommended for V1)\nAuto-approve for Live Show only when `ingest_source` is one of:\n- `tenant_admin` (authenticated staff actions)\n- `photobooth` / `sparkbooth` (controlled integrations)\n\nAll `guest_pwa` uploads require manual approval when moderation is enabled.\n\n**Pros**\n- Harder to spoof; aligns with real security boundaries.\n- Simple to explain and operate.\n\n**Cons**\n- Guests never auto-approve; more moderator work.\n\n### B) Trust by guest device id (not recommended without stronger proof)\nUse `created_by_device_id` / `X-Device-Id` to whitelist devices.\n\n**Risk**\n- Device IDs are not cryptographically bound; a motivated guest could spoof the header.\n\nIf we want this later, we should introduce a **server-issued signed device token** (pairing flow) and validate it on upload.\n\n### C) Trust by invitation/QR (future)\nGuests who joined with a special “staff QR/pairing token” become trusted.\n\n## Recommended decision\nChoose **Option A** for V1.\n\n### Moderation mode semantics (proposed)\n- `off`: all photos with “submit to live show” become `approved` immediately *except* photos that are already flagged/removed by other moderation pipelines.\n- `manual`: all guest PWA photos become `pending`; trusted sources auto-approve.\n- `trusted_only`: same as manual, but UI copy emphasises that only booth/staff are automatic.\n\n## Retention window (defaults)\n### Recommendation\nDefault `retention_window_hours = 12` (configurable per event).\n\nRationale:\n- Keeps the “eligible set” bounded for performance.\n- Fits most event durations; avoids showing very old photos late in the night.\n\n### Notes\n- Even with a retention window, we can still show older photos via “curated” mode (e.g. featured/top-liked) if product wants.\n\n## Edge cases\n- **High-volume**: moderators may not keep up → allow temporary switch to “trusted_only” + announce to guests.\n- **Abuse**: if a trusted integration misbehaves, operator can disable trusted auto-approve.\n- **Reversal**: approving a previously rejected photo must be tracked with audit info (who/when).\n\n## Decision needed from product\n- Confirm the default retention window: 12h vs 6h vs “entire event”.\n- Confirm whether “trusted_only” should auto-approve `tenant_admin` uploads (recommended: yes).\n- Confirm whether guest auto-approve is desired in V1 (recommended: no, unless we build pairing).\n","acceptance_criteria":"- Trusted rules options listed, with security risk called out for device-id trust\\n- Clear V1 recommendation (trust by ingest_source only)\\n- Moderation mode semantics defined\\n- Default retention window recommendation + product decision questions","notes":"Decision: V1 trusted auto-approve uses ingest_source only (tenant_admin/photobooth/sparkbooth). Default retention_window_hours = 12.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-05T11:43:32.455339503+01:00","created_by":"soeren","updated_at":"2026-01-05T12:06:45.973092473+01:00","closed_at":"2026-01-05T12:06:45.973092473+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-1we","depends_on_id":"fotospiel-app-vro","type":"blocks","created_at":"2026-01-05T11:44:02.062725386+01:00","created_by":"soeren"}]}
|
{"id":"fotospiel-app-1we","title":"Live Show: define trusted uploader rules \u0026 default retention window","description":"# Decision: Trusted uploader rules \u0026 default retention window\n\n## Context\nModeration is required for many events, but we also want a fast “auto-approve trusted sources” mode.\n\nWe currently track photo ingestion sources in `photos.ingest_source` (e.g. `tenant_admin`, `photobooth`, `sparkbooth`, `guest_pwa`). Guest uploads are token-based and do not have strong identity guarantees.\n\n## Definitions\n- **Trusted uploader**: uploads that can bypass Live Show manual moderation.\n- **Retention window**: time window for which approved photos remain eligible for rotation in the Live Show.\n\n## Options (trusted rules)\n### A) Trust by ingestion source only (recommended for V1)\nAuto-approve for Live Show only when `ingest_source` is one of:\n- `tenant_admin` (authenticated staff actions)\n- `photobooth` / `sparkbooth` (controlled integrations)\n\nAll `guest_pwa` uploads require manual approval when moderation is enabled.\n\n**Pros**\n- Harder to spoof; aligns with real security boundaries.\n- Simple to explain and operate.\n\n**Cons**\n- Guests never auto-approve; more moderator work.\n\n### B) Trust by guest device id (not recommended without stronger proof)\nUse `created_by_device_id` / `X-Device-Id` to whitelist devices.\n\n**Risk**\n- Device IDs are not cryptographically bound; a motivated guest could spoof the header.\n\nIf we want this later, we should introduce a **server-issued signed device token** (pairing flow) and validate it on upload.\n\n### C) Trust by invitation/QR (future)\nGuests who joined with a special “staff QR/pairing token” become trusted.\n\n## Recommended decision\nChoose **Option A** for V1.\n\n### Moderation mode semantics (proposed)\n- `off`: all photos with “submit to live show” become `approved` immediately *except* photos that are already flagged/removed by other moderation pipelines.\n- `manual`: all guest PWA photos become `pending`; trusted sources auto-approve.\n- `trusted_only`: same as manual, but UI copy emphasises that only booth/staff are automatic.\n\n## Retention window (defaults)\n### Recommendation\nDefault `retention_window_hours = 12` (configurable per event).\n\nRationale:\n- Keeps the “eligible set” bounded for performance.\n- Fits most event durations; avoids showing very old photos late in the night.\n\n### Notes\n- Even with a retention window, we can still show older photos via “curated” mode (e.g. featured/top-liked) if product wants.\n\n## Edge cases\n- **High-volume**: moderators may not keep up → allow temporary switch to “trusted_only” + announce to guests.\n- **Abuse**: if a trusted integration misbehaves, operator can disable trusted auto-approve.\n- **Reversal**: approving a previously rejected photo must be tracked with audit info (who/when).\n\n## Decision needed from product\n- Confirm the default retention window: 12h vs 6h vs “entire event”.\n- Confirm whether “trusted_only” should auto-approve `tenant_admin` uploads (recommended: yes).\n- Confirm whether guest auto-approve is desired in V1 (recommended: no, unless we build pairing).\n","acceptance_criteria":"- Trusted rules options listed, with security risk called out for device-id trust\\n- Clear V1 recommendation (trust by ingest_source only)\\n- Moderation mode semantics defined\\n- Default retention window recommendation + product decision questions","notes":"Decision: V1 trusted auto-approve uses ingest_source only (tenant_admin/photobooth/sparkbooth). Default retention_window_hours = 12.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-05T11:43:32.455339503+01:00","created_by":"soeren","updated_at":"2026-01-05T12:06:45.973092473+01:00","closed_at":"2026-01-05T12:06:45.973092473+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-1we","depends_on_id":"fotospiel-app-vro","type":"blocks","created_at":"2026-01-05T11:44:02.062725386+01:00","created_by":"soeren"}]}
|
||||||
{"id":"fotospiel-app-25q","title":"Security review: payments/webhooks code audit (signatures, idempotency, linkage)","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:05:25.747336642+01:00","created_by":"soeren","updated_at":"2026-01-01T16:05:25.747336642+01:00"}
|
{"id":"fotospiel-app-25q","title":"Security review: payments/webhooks code audit (signatures, idempotency, linkage)","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:05:25.747336642+01:00","created_by":"soeren","updated_at":"2026-01-01T16:05:25.747336642+01:00"}
|
||||||
{"id":"fotospiel-app-29o","title":"Paddle catalog sync: PackageResource sync status badges + timestamp","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:01:10.009385187+01:00","created_by":"soeren","updated_at":"2026-01-01T16:01:15.639525807+01:00","closed_at":"2026-01-01T16:01:15.639525807+01:00","close_reason":"Completed in codebase (verified)"}
|
{"id":"fotospiel-app-29o","title":"Paddle catalog sync: PackageResource sync status badges + timestamp","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:01:10.009385187+01:00","created_by":"soeren","updated_at":"2026-01-01T16:01:15.639525807+01:00","closed_at":"2026-01-01T16:01:15.639525807+01:00","close_reason":"Completed in codebase (verified)"}
|
||||||
|
{"id":"fotospiel-app-29r","title":"Photobooth uploader: add watch-folder upload pipeline + persist creds","status":"closed","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-12T16:51:27.198056063+01:00","created_by":"Codex Agent","updated_at":"2026-01-12T17:07:04.06719869+01:00","closed_at":"2026-01-12T17:07:04.06719869+01:00","close_reason":"Closed"}
|
||||||
|
{"id":"fotospiel-app-2b5","title":"Uploader: connect code expiry countdown","description":"Part of epic fotospiel-app-5aa. Show time-to-expiry for the active connect code in the client.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:04:05.74962406+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:04:05.74962406+01:00"}
|
||||||
{"id":"fotospiel-app-2hq","title":"Security review: marketing/API controller+validation review","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:05:08.862737923+01:00","created_by":"soeren","updated_at":"2026-01-01T16:05:08.862737923+01:00"}
|
{"id":"fotospiel-app-2hq","title":"Security review: marketing/API controller+validation review","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:05:08.862737923+01:00","created_by":"soeren","updated_at":"2026-01-01T16:05:08.862737923+01:00"}
|
||||||
{"id":"fotospiel-app-2yn","title":"Event-Admin: Reset link routing + notifications + tests","description":"Point password reset emails to event-admin reset page; add rate limiting and tests for the new flow.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T10:45:09.279245468+01:00","created_by":"soeren","updated_at":"2026-01-06T11:01:49.083154811+01:00","closed_at":"2026-01-06T11:01:49.083154811+01:00","close_reason":"Closed"}
|
{"id":"fotospiel-app-2yn","title":"Event-Admin: Reset link routing + notifications + tests","description":"Point password reset emails to event-admin reset page; add rate limiting and tests for the new flow.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T10:45:09.279245468+01:00","created_by":"soeren","updated_at":"2026-01-06T11:01:49.083154811+01:00","closed_at":"2026-01-06T11:01:49.083154811+01:00","close_reason":"Closed"}
|
||||||
{"id":"fotospiel-app-33m","title":"Security review checklist: Guest PWA dynamic tests","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:04:40.730459361+01:00","created_by":"soeren","updated_at":"2026-01-01T16:04:40.730459361+01:00"}
|
{"id":"fotospiel-app-33m","title":"Security review checklist: Guest PWA dynamic tests","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:04:40.730459361+01:00","created_by":"soeren","updated_at":"2026-01-01T16:04:40.730459361+01:00"}
|
||||||
@@ -28,6 +30,7 @@
|
|||||||
{"id":"fotospiel-app-574","title":"Paddle catalog sync: extend PaddleClient tests/mocks for catalog endpoints","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:59:03.486301225+01:00","created_by":"soeren","updated_at":"2026-01-02T21:11:39.626820206+01:00","closed_at":"2026-01-02T21:11:39.626820206+01:00","close_reason":"Deprioritized"}
|
{"id":"fotospiel-app-574","title":"Paddle catalog sync: extend PaddleClient tests/mocks for catalog endpoints","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:59:03.486301225+01:00","created_by":"soeren","updated_at":"2026-01-02T21:11:39.626820206+01:00","closed_at":"2026-01-02T21:11:39.626820206+01:00","close_reason":"Deprioritized"}
|
||||||
{"id":"fotospiel-app-576","title":"Tenant admin onboarding: legacy asset audit + component inventory","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:07:59.996563146+01:00","created_by":"soeren","updated_at":"2026-01-01T16:08:05.599274641+01:00","closed_at":"2026-01-01T16:08:05.599274641+01:00","close_reason":"Completed in codebase (verified)"}
|
{"id":"fotospiel-app-576","title":"Tenant admin onboarding: legacy asset audit + component inventory","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:07:59.996563146+01:00","created_by":"soeren","updated_at":"2026-01-01T16:08:05.599274641+01:00","closed_at":"2026-01-01T16:08:05.599274641+01:00","close_reason":"Completed in codebase (verified)"}
|
||||||
{"id":"fotospiel-app-579","title":"Live Show: tests (backend + UI smoke)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-05T11:11:57.246607374+01:00","created_by":"soeren","updated_at":"2026-01-05T19:37:35.590123482+01:00","closed_at":"2026-01-05T19:37:35.590123482+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-579","depends_on_id":"fotospiel-app-539","type":"blocks","created_at":"2026-01-05T11:13:27.729131522+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-579","depends_on_id":"fotospiel-app-xg5","type":"blocks","created_at":"2026-01-05T11:13:37.425191011+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-579","depends_on_id":"fotospiel-app-qne","type":"blocks","created_at":"2026-01-05T11:13:46.257175231+01:00","created_by":"soeren"}]}
|
{"id":"fotospiel-app-579","title":"Live Show: tests (backend + UI smoke)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-05T11:11:57.246607374+01:00","created_by":"soeren","updated_at":"2026-01-05T19:37:35.590123482+01:00","closed_at":"2026-01-05T19:37:35.590123482+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-579","depends_on_id":"fotospiel-app-539","type":"blocks","created_at":"2026-01-05T11:13:27.729131522+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-579","depends_on_id":"fotospiel-app-xg5","type":"blocks","created_at":"2026-01-05T11:13:37.425191011+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-579","depends_on_id":"fotospiel-app-qne","type":"blocks","created_at":"2026-01-05T11:13:46.257175231+01:00","created_by":"soeren"}]}
|
||||||
|
{"id":"fotospiel-app-5aa","title":"Photobooth uploader: reliability + UX upgrades","status":"open","priority":2,"issue_type":"epic","owner":"codex-agent@example.com","created_at":"2026-01-13T11:01:29.745168595+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:01:29.745168595+01:00"}
|
||||||
{"id":"fotospiel-app-5dl","title":"Paddle catalog sync: PaddleCatalogService scaffold","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:00:24.916655836+01:00","created_by":"soeren","updated_at":"2026-01-01T16:00:30.566084195+01:00","closed_at":"2026-01-01T16:00:30.566084195+01:00","close_reason":"Completed in codebase (verified)"}
|
{"id":"fotospiel-app-5dl","title":"Paddle catalog sync: PaddleCatalogService scaffold","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:00:24.916655836+01:00","created_by":"soeren","updated_at":"2026-01-01T16:00:30.566084195+01:00","closed_at":"2026-01-01T16:00:30.566084195+01:00","close_reason":"Completed in codebase (verified)"}
|
||||||
{"id":"fotospiel-app-5hk","title":"Fix staging coupon seed 500 for E2E","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-03T15:12:53.643644221+01:00","created_by":"soeren","updated_at":"2026-01-04T16:21:46.441797374+01:00","closed_at":"2026-01-04T16:21:46.441797374+01:00","close_reason":"Resolved elsewhere; staging coupon seed 500 no longer reproducible after recent backend changes."}
|
{"id":"fotospiel-app-5hk","title":"Fix staging coupon seed 500 for E2E","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-03T15:12:53.643644221+01:00","created_by":"soeren","updated_at":"2026-01-04T16:21:46.441797374+01:00","closed_at":"2026-01-04T16:21:46.441797374+01:00","close_reason":"Resolved elsewhere; staging coupon seed 500 no longer reproducible after recent backend changes."}
|
||||||
{"id":"fotospiel-app-5ie","title":"Help docs: Live Show how-to + recommended hardware (DE/EN)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-05T11:12:05.973844187+01:00","created_by":"soeren","updated_at":"2026-01-05T19:42:44.39939087+01:00","closed_at":"2026-01-05T19:42:44.39939087+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-5ie","depends_on_id":"fotospiel-app-vro","type":"blocks","created_at":"2026-01-05T11:13:54.925412888+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-5ie","depends_on_id":"fotospiel-app-539","type":"blocks","created_at":"2026-01-05T11:14:03.257649076+01:00","created_by":"soeren"}]}
|
{"id":"fotospiel-app-5ie","title":"Help docs: Live Show how-to + recommended hardware (DE/EN)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-05T11:12:05.973844187+01:00","created_by":"soeren","updated_at":"2026-01-05T19:42:44.39939087+01:00","closed_at":"2026-01-05T19:42:44.39939087+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-5ie","depends_on_id":"fotospiel-app-vro","type":"blocks","created_at":"2026-01-05T11:13:54.925412888+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-5ie","depends_on_id":"fotospiel-app-539","type":"blocks","created_at":"2026-01-05T11:14:03.257649076+01:00","created_by":"soeren"}]}
|
||||||
@@ -38,10 +41,14 @@
|
|||||||
{"id":"fotospiel-app-6dp","title":"Coupon ops enhancements (redemption service, preview endpoint, widget, export)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:09:09.275919717+01:00","created_by":"soeren","updated_at":"2026-01-01T16:09:14.882264149+01:00","closed_at":"2026-01-01T16:09:14.882264149+01:00","close_reason":"Completed in codebase (verified)"}
|
{"id":"fotospiel-app-6dp","title":"Coupon ops enhancements (redemption service, preview endpoint, widget, export)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:09:09.275919717+01:00","created_by":"soeren","updated_at":"2026-01-01T16:09:14.882264149+01:00","closed_at":"2026-01-01T16:09:14.882264149+01:00","close_reason":"Completed in codebase (verified)"}
|
||||||
{"id":"fotospiel-app-6oj","title":"Security review: media pipeline code audit (AV/EXIF, signed URLs, storage separation)","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:05:31.390878341+01:00","created_by":"soeren","updated_at":"2026-01-01T16:05:31.390878341+01:00"}
|
{"id":"fotospiel-app-6oj","title":"Security review: media pipeline code audit (AV/EXIF, signed URLs, storage separation)","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:05:31.390878341+01:00","created_by":"soeren","updated_at":"2026-01-01T16:05:31.390878341+01:00"}
|
||||||
{"id":"fotospiel-app-6yt","title":"Paddle migration: register sandbox webhooks + document events consumed","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:56:34.333714988+01:00","created_by":"soeren","updated_at":"2026-01-02T22:23:52.212191068+01:00","closed_at":"2026-01-02T22:23:52.212191068+01:00","close_reason":"Completed"}
|
{"id":"fotospiel-app-6yt","title":"Paddle migration: register sandbox webhooks + document events consumed","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:56:34.333714988+01:00","created_by":"soeren","updated_at":"2026-01-02T22:23:52.212191068+01:00","closed_at":"2026-01-02T22:23:52.212191068+01:00","close_reason":"Completed"}
|
||||||
|
{"id":"fotospiel-app-6yz","title":"Uploader: activity log export","description":"Part of epic fotospiel-app-5aa. Add in-app log view and export/copy diagnostics for support.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:04:27.73767403+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:04:27.73767403+01:00"}
|
||||||
{"id":"fotospiel-app-6zc","title":"Live Show: Admin app settings \u0026 effect presets","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-01-05T11:11:27.038815978+01:00","created_by":"soeren","updated_at":"2026-01-05T15:02:42.035082497+01:00","closed_at":"2026-01-05T15:02:42.035082497+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-6zc","depends_on_id":"fotospiel-app-vro","type":"blocks","created_at":"2026-01-05T11:12:50.048055484+01:00","created_by":"soeren"}]}
|
{"id":"fotospiel-app-6zc","title":"Live Show: Admin app settings \u0026 effect presets","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-01-05T11:11:27.038815978+01:00","created_by":"soeren","updated_at":"2026-01-05T15:02:42.035082497+01:00","closed_at":"2026-01-05T15:02:42.035082497+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-6zc","depends_on_id":"fotospiel-app-vro","type":"blocks","created_at":"2026-01-05T11:12:50.048055484+01:00","created_by":"soeren"}]}
|
||||||
{"id":"fotospiel-app-7bu","title":"Paddle migration: extend config/env handling for Paddle keys/webhook secrets","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:57:27.242854801+01:00","created_by":"soeren","updated_at":"2026-01-01T15:57:32.890355888+01:00","closed_at":"2026-01-01T15:57:32.890355888+01:00","close_reason":"Completed in codebase (verified)"}
|
{"id":"fotospiel-app-7bu","title":"Paddle migration: extend config/env handling for Paddle keys/webhook secrets","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:57:27.242854801+01:00","created_by":"soeren","updated_at":"2026-01-01T15:57:32.890355888+01:00","closed_at":"2026-01-01T15:57:32.890355888+01:00","close_reason":"Completed in codebase (verified)"}
|
||||||
{"id":"fotospiel-app-7u1","title":"Paddle catalog sync: PaddlePackagePull job","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:00:47.468892178+01:00","created_by":"soeren","updated_at":"2026-01-01T16:00:53.126602817+01:00","closed_at":"2026-01-01T16:00:53.126602817+01:00","close_reason":"Completed in codebase (verified)"}
|
{"id":"fotospiel-app-7u1","title":"Paddle catalog sync: PaddlePackagePull job","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:00:47.468892178+01:00","created_by":"soeren","updated_at":"2026-01-01T16:00:53.126602817+01:00","closed_at":"2026-01-01T16:00:53.126602817+01:00","close_reason":"Completed in codebase (verified)"}
|
||||||
|
{"id":"fotospiel-app-7uu","title":"Uploader: improve file readiness detection","description":"Part of epic fotospiel-app-5aa. Use size + last-write stabilization to avoid partial uploads.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:01:54.142231578+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:01:54.142231578+01:00"}
|
||||||
|
{"id":"fotospiel-app-7x1","title":"Uploader: response format manual override","description":"Part of epic fotospiel-app-5aa. Allow manual response format override when connect code doesn't set it.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:03:54.824613016+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:03:54.824613016+01:00"}
|
||||||
{"id":"fotospiel-app-83q","title":"Implement Advanced Analytics","description":"Full plan: Phase 1 (MVP) includes Activity Timeline, Top Contributors, and Task Stats. Phase 2 includes Engagement Funnel, Vibe Check, and PDF Export. See chat history for details.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T15:40:08.826105426+01:00","created_by":"soeren","updated_at":"2026-01-06T16:15:17.722450844+01:00","closed_at":"2026-01-06T16:15:17.722455019+01:00"}
|
{"id":"fotospiel-app-83q","title":"Implement Advanced Analytics","description":"Full plan: Phase 1 (MVP) includes Activity Timeline, Top Contributors, and Task Stats. Phase 2 includes Engagement Funnel, Vibe Check, and PDF Export. See chat history for details.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T15:40:08.826105426+01:00","created_by":"soeren","updated_at":"2026-01-06T16:15:17.722450844+01:00","closed_at":"2026-01-06T16:15:17.722455019+01:00"}
|
||||||
|
{"id":"fotospiel-app-8ui","title":"Uploader: persist queue across restarts","description":"Part of epic fotospiel-app-5aa. Persist pending upload queue to disk (settings or local DB) so restarts don't lose files.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:01:42.213478619+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:01:42.213478619+01:00"}
|
||||||
{"id":"fotospiel-app-95m","title":"Paddle migration: admin catalog sync UI for packages","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:57:49.790409261+01:00","created_by":"soeren","updated_at":"2026-01-01T15:57:55.418180246+01:00","closed_at":"2026-01-01T15:57:55.418180246+01:00","close_reason":"Completed in codebase (verified)"}
|
{"id":"fotospiel-app-95m","title":"Paddle migration: admin catalog sync UI for packages","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:57:49.790409261+01:00","created_by":"soeren","updated_at":"2026-01-01T15:57:55.418180246+01:00","closed_at":"2026-01-01T15:57:55.418180246+01:00","close_reason":"Completed in codebase (verified)"}
|
||||||
{"id":"fotospiel-app-99d","title":"Paddle migration: marketing checkout uses Paddle-hosted checkout","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:58:12.298063897+01:00","created_by":"soeren","updated_at":"2026-01-01T15:58:17.968032021+01:00","closed_at":"2026-01-01T15:58:17.968032021+01:00","close_reason":"Completed in codebase (verified)"}
|
{"id":"fotospiel-app-99d","title":"Paddle migration: marketing checkout uses Paddle-hosted checkout","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:58:12.298063897+01:00","created_by":"soeren","updated_at":"2026-01-01T15:58:17.968032021+01:00","closed_at":"2026-01-01T15:58:17.968032021+01:00","close_reason":"Completed in codebase (verified)"}
|
||||||
{"id":"fotospiel-app-99o","title":"Fix German welcome phrasing with article-safe app_name","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-04T11:50:17.410390085+01:00","created_by":"soeren","updated_at":"2026-01-04T12:19:55.741616753+01:00","closed_at":"2026-01-04T12:19:55.741616753+01:00","close_reason":"Closed"}
|
{"id":"fotospiel-app-99o","title":"Fix German welcome phrasing with article-safe app_name","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-04T11:50:17.410390085+01:00","created_by":"soeren","updated_at":"2026-01-04T12:19:55.741616753+01:00","closed_at":"2026-01-04T12:19:55.741616753+01:00","close_reason":"Closed"}
|
||||||
@@ -62,9 +69,11 @@
|
|||||||
{"id":"fotospiel-app-bqm","title":"Paddle catalog sync: unit tests for service + jobs","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:01:22.090498843+01:00","created_by":"soeren","updated_at":"2026-01-01T16:01:27.71412122+01:00","closed_at":"2026-01-01T16:01:27.71412122+01:00","close_reason":"Completed in codebase (verified)"}
|
{"id":"fotospiel-app-bqm","title":"Paddle catalog sync: unit tests for service + jobs","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:01:22.090498843+01:00","created_by":"soeren","updated_at":"2026-01-01T16:01:27.71412122+01:00","closed_at":"2026-01-01T16:01:27.71412122+01:00","close_reason":"Completed in codebase (verified)"}
|
||||||
{"id":"fotospiel-app-bxu","title":"Checkout refactor: Stripe/Paddle payment integration + webhooks","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:06:32.279485614+01:00","created_by":"soeren","updated_at":"2026-01-01T16:06:37.876950599+01:00","closed_at":"2026-01-01T16:06:37.876950599+01:00","close_reason":"Completed in codebase (verified)"}
|
{"id":"fotospiel-app-bxu","title":"Checkout refactor: Stripe/Paddle payment integration + webhooks","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:06:32.279485614+01:00","created_by":"soeren","updated_at":"2026-01-01T16:06:37.876950599+01:00","closed_at":"2026-01-01T16:06:37.876950599+01:00","close_reason":"Completed in codebase (verified)"}
|
||||||
{"id":"fotospiel-app-bzb","title":"Paddle catalog sync: migration for paddle sync columns","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:00:02.362257158+01:00","created_by":"soeren","updated_at":"2026-01-01T16:00:08.018770606+01:00","closed_at":"2026-01-01T16:00:08.018770606+01:00","close_reason":"Completed in codebase (verified)"}
|
{"id":"fotospiel-app-bzb","title":"Paddle catalog sync: migration for paddle sync columns","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:00:02.362257158+01:00","created_by":"soeren","updated_at":"2026-01-01T16:00:08.018770606+01:00","closed_at":"2026-01-01T16:00:08.018770606+01:00","close_reason":"Completed in codebase (verified)"}
|
||||||
|
{"id":"fotospiel-app-cht","title":"Uploader: disk space low warning","description":"Part of epic fotospiel-app-5aa. Highlight low disk space thresholds in UI.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:03:32.710631234+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:03:32.710631234+01:00"}
|
||||||
{"id":"fotospiel-app-ci5","title":"Paddle catalog sync: configure log channel/Slack hook for sync outcomes","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:59:20.543083527+01:00","created_by":"soeren","updated_at":"2026-01-02T22:02:15.857149244+01:00","closed_at":"2026-01-02T22:02:15.857149244+01:00","close_reason":"Completed"}
|
{"id":"fotospiel-app-ci5","title":"Paddle catalog sync: configure log channel/Slack hook for sync outcomes","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:59:20.543083527+01:00","created_by":"soeren","updated_at":"2026-01-02T22:02:15.857149244+01:00","closed_at":"2026-01-02T22:02:15.857149244+01:00","close_reason":"Completed"}
|
||||||
{"id":"fotospiel-app-cwq","title":"Integrations health: unified Paddle/RevenueCat/webhook status dashboard","description":"Add a superadmin integrations health dashboard for Paddle/RevenueCat/webhooks.\nScope: show latest webhook processing status/lag, recent failures, retry backlog, and config presence (env set) without exposing secrets.\nInclude per-provider status badges and time-window filters, plus links to related logs/actions.\n","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-02T17:34:20.84661157+01:00","created_by":"soeren","updated_at":"2026-01-02T18:33:07.133704488+01:00","closed_at":"2026-01-02T18:33:07.133704488+01:00","close_reason":"Closed"}
|
{"id":"fotospiel-app-cwq","title":"Integrations health: unified Paddle/RevenueCat/webhook status dashboard","description":"Add a superadmin integrations health dashboard for Paddle/RevenueCat/webhooks.\nScope: show latest webhook processing status/lag, recent failures, retry backlog, and config presence (env set) without exposing secrets.\nInclude per-provider status badges and time-window filters, plus links to related logs/actions.\n","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-02T17:34:20.84661157+01:00","created_by":"soeren","updated_at":"2026-01-02T18:33:07.133704488+01:00","closed_at":"2026-01-02T18:33:07.133704488+01:00","close_reason":"Closed"}
|
||||||
{"id":"fotospiel-app-d39","title":"Superadmin control surface spec and access matrix","description":"Define the minimal superadmin control surface, permissions, and mapping to tenant/guest responsibilities. Document scope and non-goals.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T14:16:06.994379577+01:00","updated_at":"2026-01-01T14:20:43.080701114+01:00","closed_at":"2026-01-01T14:20:43.080701114+01:00"}
|
{"id":"fotospiel-app-d39","title":"Superadmin control surface spec and access matrix","description":"Define the minimal superadmin control surface, permissions, and mapping to tenant/guest responsibilities. Document scope and non-goals.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T14:16:06.994379577+01:00","updated_at":"2026-01-01T14:20:43.080701114+01:00","closed_at":"2026-01-01T14:20:43.080701114+01:00"}
|
||||||
|
{"id":"fotospiel-app-dar","title":"Uploader: retry policy for failed uploads","description":"Part of epic fotospiel-app-5aa. Auto-retry with backoff and retry limit before marking failed.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:03:00.808893045+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:03:00.808893045+01:00"}
|
||||||
{"id":"fotospiel-app-dl5","title":"SEC-API-01 Signed URL middleware + asset migration","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:52:24.24098702+01:00","created_by":"soeren","updated_at":"2026-01-01T15:52:29.8793891+01:00","closed_at":"2026-01-01T15:52:29.8793891+01:00","close_reason":"Completed in codebase (verified)"}
|
{"id":"fotospiel-app-dl5","title":"SEC-API-01 Signed URL middleware + asset migration","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:52:24.24098702+01:00","created_by":"soeren","updated_at":"2026-01-01T15:52:29.8793891+01:00","closed_at":"2026-01-01T15:52:29.8793891+01:00","close_reason":"Completed in codebase (verified)"}
|
||||||
{"id":"fotospiel-app-dm4","title":"SEC-BILL-01 Checkout session linkage + idempotency locks","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:53:26.350238207+01:00","created_by":"soeren","updated_at":"2026-01-01T15:53:31.997737421+01:00","closed_at":"2026-01-01T15:53:31.997737421+01:00","close_reason":"Completed in codebase (verified)"}
|
{"id":"fotospiel-app-dm4","title":"SEC-BILL-01 Checkout session linkage + idempotency locks","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:53:26.350238207+01:00","created_by":"soeren","updated_at":"2026-01-01T15:53:31.997737421+01:00","closed_at":"2026-01-01T15:53:31.997737421+01:00","close_reason":"Completed in codebase (verified)"}
|
||||||
{"id":"fotospiel-app-dmb","title":"Security review checklist: Event Admin dynamic tests","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:04:46.359468828+01:00","created_by":"soeren","updated_at":"2026-01-01T16:04:46.359468828+01:00"}
|
{"id":"fotospiel-app-dmb","title":"Security review checklist: Event Admin dynamic tests","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:04:46.359468828+01:00","created_by":"soeren","updated_at":"2026-01-01T16:04:46.359468828+01:00"}
|
||||||
@@ -85,6 +94,7 @@
|
|||||||
{"id":"fotospiel-app-iyh","title":"Security review follow-ups: signed URL TTLs, guest asset throttles, CORS allowlist, logging hygiene","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:05:42.642109576+01:00","created_by":"soeren","updated_at":"2026-01-01T16:05:42.642109576+01:00"}
|
{"id":"fotospiel-app-iyh","title":"Security review follow-ups: signed URL TTLs, guest asset throttles, CORS allowlist, logging hygiene","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:05:42.642109576+01:00","created_by":"soeren","updated_at":"2026-01-01T16:05:42.642109576+01:00"}
|
||||||
{"id":"fotospiel-app-jk4","title":"Checkout refactor: CheckoutController + marketing route alignment","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:06:21.088319132+01:00","created_by":"soeren","updated_at":"2026-01-01T16:06:26.663419594+01:00","closed_at":"2026-01-01T16:06:26.663419594+01:00","close_reason":"Completed in codebase (verified)"}
|
{"id":"fotospiel-app-jk4","title":"Checkout refactor: CheckoutController + marketing route alignment","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:06:21.088319132+01:00","created_by":"soeren","updated_at":"2026-01-01T16:06:26.663419594+01:00","closed_at":"2026-01-01T16:06:26.663419594+01:00","close_reason":"Completed in codebase (verified)"}
|
||||||
{"id":"fotospiel-app-jqy","title":"Tenant admin onboarding: Playwright skeleton for welcome flow","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:08:11.226297707+01:00","created_by":"soeren","updated_at":"2026-01-01T16:08:16.827679424+01:00","closed_at":"2026-01-01T16:08:16.827679424+01:00","close_reason":"Completed in codebase (verified)"}
|
{"id":"fotospiel-app-jqy","title":"Tenant admin onboarding: Playwright skeleton for welcome flow","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:08:11.226297707+01:00","created_by":"soeren","updated_at":"2026-01-01T16:08:16.827679424+01:00","closed_at":"2026-01-01T16:08:16.827679424+01:00","close_reason":"Completed in codebase (verified)"}
|
||||||
|
{"id":"fotospiel-app-jy1","title":"Uploader: clear failed uploads UI","description":"Part of epic fotospiel-app-5aa. Add action to clear/reset failed items and counters.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:03:13.134661157+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:03:13.134661157+01:00"}
|
||||||
{"id":"fotospiel-app-ko0","title":"Security review checklist: Webhooks/Billing dynamic tests","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:04:51.987093237+01:00","created_by":"soeren","updated_at":"2026-01-01T16:04:51.987093237+01:00"}
|
{"id":"fotospiel-app-ko0","title":"Security review checklist: Webhooks/Billing dynamic tests","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:04:51.987093237+01:00","created_by":"soeren","updated_at":"2026-01-01T16:04:51.987093237+01:00"}
|
||||||
{"id":"fotospiel-app-kry","title":"Paddle catalog sync: add DTO helpers for Paddle product/price responses","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:58:57.817750548+01:00","created_by":"soeren","updated_at":"2026-01-02T21:11:27.970220923+01:00","closed_at":"2026-01-02T21:11:27.970220923+01:00","close_reason":"Deprioritized"}
|
{"id":"fotospiel-app-kry","title":"Paddle catalog sync: add DTO helpers for Paddle product/price responses","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:58:57.817750548+01:00","created_by":"soeren","updated_at":"2026-01-02T21:11:27.970220923+01:00","closed_at":"2026-01-02T21:11:27.970220923+01:00","close_reason":"Deprioritized"}
|
||||||
{"id":"fotospiel-app-kso","title":"SEC-MS-02 Streaming upload refactor + tests","description":"Current state (code scan)\n- Guest uploads: App\\\\Http\\\\Controllers\\\\Api\\\\EventPublicController@upload uses Storage::disk()-\u003eputFile (stream-friendly) but still does watermark/thumbnail work inline.\n- Tenant admin uploads: App\\\\Http\\\\Controllers\\\\Api\\\\Tenant\\\\PhotoController@store and @uploadDirect use Storage::disk()-\u003eput($path, file_get_contents(...)) which loads entire file into memory.\n- Photobooth ingest already streams from import disk via readStream -\u003e Storage::disk()-\u003eput($path, $stream).\n- Presigned upload flow is stubbed to a local upload-direct endpoint; no true presigned S3 handling yet.\n- No tenant upload feature tests exist; guest upload tests exist and cover limits/security.\n\nGoal\n- Stream uploads to disk (avoid full in-memory buffers) for tenant-admin upload endpoints and keep behavior consistent across sources.\n\nPlan\n1) Introduce a small streaming upload helper/service\n - New service (e.g. App\\\\Services\\\\Storage\\\\UploadStreamService) that accepts UploadedFile + disk + destination path.\n - Use fopen on UploadedFile::getRealPath (or $file-\u003egetStream()) and Storage::disk($disk)-\u003eput($path, $stream) / writeStream.\n - Always close stream; return stored size and checksum (hash_file on stored path) for asset metadata.\n\n2) Refactor tenant upload endpoints to use streaming\n - Update PhotoController@store and @uploadDirect to use the helper instead of file_get_contents.\n - Use Storage::disk()-\u003eputFileAs (or helper) to preserve deterministic paths without buffering.\n - Keep existing validation, watermark, thumbnail, asset recording, and package usage logic.\n\n3) Optional consistency pass on guest upload\n - Consider routing EventPublicController@upload through the same helper for consistent storage + checksum handling, while keeping current validation/limits.\n\n4) Tests\n - Add Feature tests for tenant upload endpoints:\n - /api/v1/tenant/events/{slug}/photos (store) uploads a fake image and persists Photo + EventMediaAsset with expected path/size.\n - /api/v1/tenant/events/{slug}/upload-direct (presigned) uploads a fake image and stores asset + thumbnail.\n - Ensure existing guest upload tests still pass (no behavioral changes).\n\n5) Safety/ops\n - Verify streaming logic handles empty/invalid files gracefully and still reports errors via ApiError.\n - Keep request-time processing (thumb/watermark) unchanged for now; consider queuing in a follow-up if CPU spikes persist.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:53:03.729137616+01:00","created_by":"soeren","updated_at":"2026-01-02T20:51:17.752365339+01:00","closed_at":"2026-01-02T20:51:17.752365339+01:00","close_reason":"Closed"}
|
{"id":"fotospiel-app-kso","title":"SEC-MS-02 Streaming upload refactor + tests","description":"Current state (code scan)\n- Guest uploads: App\\\\Http\\\\Controllers\\\\Api\\\\EventPublicController@upload uses Storage::disk()-\u003eputFile (stream-friendly) but still does watermark/thumbnail work inline.\n- Tenant admin uploads: App\\\\Http\\\\Controllers\\\\Api\\\\Tenant\\\\PhotoController@store and @uploadDirect use Storage::disk()-\u003eput($path, file_get_contents(...)) which loads entire file into memory.\n- Photobooth ingest already streams from import disk via readStream -\u003e Storage::disk()-\u003eput($path, $stream).\n- Presigned upload flow is stubbed to a local upload-direct endpoint; no true presigned S3 handling yet.\n- No tenant upload feature tests exist; guest upload tests exist and cover limits/security.\n\nGoal\n- Stream uploads to disk (avoid full in-memory buffers) for tenant-admin upload endpoints and keep behavior consistent across sources.\n\nPlan\n1) Introduce a small streaming upload helper/service\n - New service (e.g. App\\\\Services\\\\Storage\\\\UploadStreamService) that accepts UploadedFile + disk + destination path.\n - Use fopen on UploadedFile::getRealPath (or $file-\u003egetStream()) and Storage::disk($disk)-\u003eput($path, $stream) / writeStream.\n - Always close stream; return stored size and checksum (hash_file on stored path) for asset metadata.\n\n2) Refactor tenant upload endpoints to use streaming\n - Update PhotoController@store and @uploadDirect to use the helper instead of file_get_contents.\n - Use Storage::disk()-\u003eputFileAs (or helper) to preserve deterministic paths without buffering.\n - Keep existing validation, watermark, thumbnail, asset recording, and package usage logic.\n\n3) Optional consistency pass on guest upload\n - Consider routing EventPublicController@upload through the same helper for consistent storage + checksum handling, while keeping current validation/limits.\n\n4) Tests\n - Add Feature tests for tenant upload endpoints:\n - /api/v1/tenant/events/{slug}/photos (store) uploads a fake image and persists Photo + EventMediaAsset with expected path/size.\n - /api/v1/tenant/events/{slug}/upload-direct (presigned) uploads a fake image and stores asset + thumbnail.\n - Ensure existing guest upload tests still pass (no behavioral changes).\n\n5) Safety/ops\n - Verify streaming logic handles empty/invalid files gracefully and still reports errors via ApiError.\n - Keep request-time processing (thumb/watermark) unchanged for now; consider queuing in a follow-up if CPU spikes persist.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:53:03.729137616+01:00","created_by":"soeren","updated_at":"2026-01-02T20:51:17.752365339+01:00","closed_at":"2026-01-02T20:51:17.752365339+01:00","close_reason":"Closed"}
|
||||||
@@ -92,6 +102,8 @@
|
|||||||
{"id":"fotospiel-app-l3n","title":"Session changes 2025-09-08 (PRP split, PWA scaffolding, Filament resources, API)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:10:18.204088457+01:00","created_by":"soeren","updated_at":"2026-01-01T16:10:23.815135505+01:00","closed_at":"2026-01-01T16:10:23.815135505+01:00","close_reason":"Completed in codebase (verified)"}
|
{"id":"fotospiel-app-l3n","title":"Session changes 2025-09-08 (PRP split, PWA scaffolding, Filament resources, API)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:10:18.204088457+01:00","created_by":"soeren","updated_at":"2026-01-01T16:10:23.815135505+01:00","closed_at":"2026-01-01T16:10:23.815135505+01:00","close_reason":"Completed in codebase (verified)"}
|
||||||
{"id":"fotospiel-app-l6a","title":"Registration flow fixes: JSON redirect, error clearing, role handling","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:07:16.253760139+01:00","created_by":"soeren","updated_at":"2026-01-01T16:07:21.964843904+01:00","closed_at":"2026-01-01T16:07:21.964843904+01:00","close_reason":"Completed in codebase (verified)"}
|
{"id":"fotospiel-app-l6a","title":"Registration flow fixes: JSON redirect, error clearing, role handling","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:07:16.253760139+01:00","created_by":"soeren","updated_at":"2026-01-01T16:07:21.964843904+01:00","closed_at":"2026-01-01T16:07:21.964843904+01:00","close_reason":"Completed in codebase (verified)"}
|
||||||
{"id":"fotospiel-app-l8q","title":"SEC-GT-02 Join-token analytics dashboard (Grafana)","description":"Logging + Filament summaries exist; Grafana dashboard still missing.","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:52:12.920875329+01:00","created_by":"soeren","updated_at":"2026-01-01T15:52:12.920875329+01:00"}
|
{"id":"fotospiel-app-l8q","title":"SEC-GT-02 Join-token analytics dashboard (Grafana)","description":"Logging + Filament summaries exist; Grafana dashboard still missing.","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:52:12.920875329+01:00","created_by":"soeren","updated_at":"2026-01-01T15:52:12.920875329+01:00"}
|
||||||
|
{"id":"fotospiel-app-lj6","title":"Uploader: folder health enhancements","description":"Part of epic fotospiel-app-5aa. Track last file seen, write permissions, and show clearer folder status.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:03:22.843330813+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:03:22.843330813+01:00"}
|
||||||
|
{"id":"fotospiel-app-llq","title":"Uploader: lock settings after connect","description":"Part of epic fotospiel-app-5aa. Prevent accidental changes to base URL/credentials unless explicitly unlocked.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:03:43.40971185+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:03:43.40971185+01:00"}
|
||||||
{"id":"fotospiel-app-ln3","title":"Paddle catalog sync: announce workflow change to admin users","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:59:49.021233635+01:00","created_by":"soeren","updated_at":"2026-01-02T21:11:09.349495631+01:00","closed_at":"2026-01-02T21:11:09.349495631+01:00","close_reason":"Deprioritized"}
|
{"id":"fotospiel-app-ln3","title":"Paddle catalog sync: announce workflow change to admin users","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:59:49.021233635+01:00","created_by":"soeren","updated_at":"2026-01-02T21:11:09.349495631+01:00","closed_at":"2026-01-02T21:11:09.349495631+01:00","close_reason":"Deprioritized"}
|
||||||
{"id":"fotospiel-app-lnb","title":"SEC-GT-01 Hash join tokens + data migration","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:52:01.658868778+01:00","created_by":"soeren","updated_at":"2026-01-01T15:52:07.314317124+01:00","closed_at":"2026-01-01T15:52:07.314317124+01:00","close_reason":"Completed in codebase (verified)"}
|
{"id":"fotospiel-app-lnb","title":"SEC-GT-01 Hash join tokens + data migration","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:52:01.658868778+01:00","created_by":"soeren","updated_at":"2026-01-01T15:52:07.314317124+01:00","closed_at":"2026-01-01T15:52:07.314317124+01:00","close_reason":"Completed in codebase (verified)"}
|
||||||
{"id":"fotospiel-app-lnf","title":"Remove legacy registration page assets","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-06T08:37:39.419274918+01:00","created_by":"soeren","updated_at":"2026-01-06T08:37:39.419274918+01:00"}
|
{"id":"fotospiel-app-lnf","title":"Remove legacy registration page assets","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-06T08:37:39.419274918+01:00","created_by":"soeren","updated_at":"2026-01-06T08:37:39.419274918+01:00"}
|
||||||
@@ -101,6 +113,7 @@
|
|||||||
{"id":"fotospiel-app-ml7","title":"SEC-GT-03 Tighten gallery/photo rate limits + alerting","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:52:18.593415508+01:00","created_by":"soeren","updated_at":"2026-01-01T15:52:18.593415508+01:00"}
|
{"id":"fotospiel-app-ml7","title":"SEC-GT-03 Tighten gallery/photo rate limits + alerting","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:52:18.593415508+01:00","created_by":"soeren","updated_at":"2026-01-01T15:52:18.593415508+01:00"}
|
||||||
{"id":"fotospiel-app-mol","title":"Coupon ops: wire analytics into Matomo dashboard","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:09:27.722458747+01:00","created_by":"soeren","updated_at":"2026-01-02T23:28:18.178704873+01:00","closed_at":"2026-01-02T23:28:18.178704873+01:00","close_reason":"Closed"}
|
{"id":"fotospiel-app-mol","title":"Coupon ops: wire analytics into Matomo dashboard","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:09:27.722458747+01:00","created_by":"soeren","updated_at":"2026-01-02T23:28:18.178704873+01:00","closed_at":"2026-01-02T23:28:18.178704873+01:00","close_reason":"Closed"}
|
||||||
{"id":"fotospiel-app-mpu","title":"Checkout refactor: test coverage + rollout notes","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:06:43.488302531+01:00","created_by":"soeren","updated_at":"2026-01-01T16:06:49.13645691+01:00","closed_at":"2026-01-01T16:06:49.13645691+01:00","close_reason":"Completed in codebase (verified)"}
|
{"id":"fotospiel-app-mpu","title":"Checkout refactor: test coverage + rollout notes","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:06:43.488302531+01:00","created_by":"soeren","updated_at":"2026-01-01T16:06:49.13645691+01:00","closed_at":"2026-01-01T16:06:49.13645691+01:00","close_reason":"Completed in codebase (verified)"}
|
||||||
|
{"id":"fotospiel-app-mwi","title":"Uploader: duplicate detection / upload cache","description":"Part of epic fotospiel-app-5aa. Track uploaded files (path/hash) to avoid re-uploads after restart.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:02:06.432781468+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:02:06.432781468+01:00"}
|
||||||
{"id":"fotospiel-app-mx5","title":"Localized SEO: sitemap updated with locale alternates","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:02:15.177013722+01:00","created_by":"soeren","updated_at":"2026-01-01T16:02:20.812287917+01:00","closed_at":"2026-01-01T16:02:20.812287917+01:00","close_reason":"Completed in codebase (verified)"}
|
{"id":"fotospiel-app-mx5","title":"Localized SEO: sitemap updated with locale alternates","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:02:15.177013722+01:00","created_by":"soeren","updated_at":"2026-01-01T16:02:20.812287917+01:00","closed_at":"2026-01-01T16:02:20.812287917+01:00","close_reason":"Completed in codebase (verified)"}
|
||||||
{"id":"fotospiel-app-mxw","title":"Security review: configure env assumptions for dynamic testing","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:04:29.498402235+01:00","created_by":"soeren","updated_at":"2026-01-01T16:04:29.498402235+01:00"}
|
{"id":"fotospiel-app-mxw","title":"Security review: configure env assumptions for dynamic testing","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:04:29.498402235+01:00","created_by":"soeren","updated_at":"2026-01-01T16:04:29.498402235+01:00"}
|
||||||
{"id":"fotospiel-app-n8q","title":"Paddle migration: draft production cutover procedure","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:56:51.427425262+01:00","created_by":"soeren","updated_at":"2026-01-02T22:28:41.469357437+01:00","closed_at":"2026-01-02T22:28:41.469357437+01:00","close_reason":"Completed"}
|
{"id":"fotospiel-app-n8q","title":"Paddle migration: draft production cutover procedure","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:56:51.427425262+01:00","created_by":"soeren","updated_at":"2026-01-02T22:28:41.469357437+01:00","closed_at":"2026-01-02T22:28:41.469357437+01:00","close_reason":"Completed"}
|
||||||
@@ -118,11 +131,15 @@
|
|||||||
{"id":"fotospiel-app-qlj","title":"Paddle catalog sync: verify legacy packages mapped before auto-sync","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:59:43.333792314+01:00","created_by":"soeren","updated_at":"2026-01-02T21:46:52.797515024+01:00","closed_at":"2026-01-02T21:46:52.797515024+01:00","close_reason":"Completed"}
|
{"id":"fotospiel-app-qlj","title":"Paddle catalog sync: verify legacy packages mapped before auto-sync","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:59:43.333792314+01:00","created_by":"soeren","updated_at":"2026-01-02T21:46:52.797515024+01:00","closed_at":"2026-01-02T21:46:52.797515024+01:00","close_reason":"Completed"}
|
||||||
{"id":"fotospiel-app-qne","title":"Live Show: realtime delivery channel (WS/SSE) + fallback polling","acceptance_criteria":"- Public Live Show endpoints exist for state, updates, and SSE stream\\n- Updates endpoint supports cursor (after_approved_at + after_id)\\n- SSE emits photo.approved and ping, with settings updates when version changes\\n- Feature tests cover state, updates, invalid token","notes":"Added LiveShowController with public endpoints: /api/v1/live-show/{token} (state), /updates (polling), /stream (SSE). Provides live-show settings (defaults + event.settings.live_show merge), settings_version hash, ordered approved photo feed with cursor. SSE emits photo.approved, settings.updated, ping. Added routes in routes/api.php. Added Photo live_status default. Tests: tests/Feature/LiveShowRealtimeTest.php. Ran Pint + test.","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-01-05T11:11:06.028871737+01:00","created_by":"soeren","updated_at":"2026-01-05T13:08:33.936740582+01:00","closed_at":"2026-01-05T13:08:33.936740582+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-qne","depends_on_id":"fotospiel-app-t1k","type":"blocks","created_at":"2026-01-05T11:12:30.363982215+01:00","created_by":"soeren"}]}
|
{"id":"fotospiel-app-qne","title":"Live Show: realtime delivery channel (WS/SSE) + fallback polling","acceptance_criteria":"- Public Live Show endpoints exist for state, updates, and SSE stream\\n- Updates endpoint supports cursor (after_approved_at + after_id)\\n- SSE emits photo.approved and ping, with settings updates when version changes\\n- Feature tests cover state, updates, invalid token","notes":"Added LiveShowController with public endpoints: /api/v1/live-show/{token} (state), /updates (polling), /stream (SSE). Provides live-show settings (defaults + event.settings.live_show merge), settings_version hash, ordered approved photo feed with cursor. SSE emits photo.approved, settings.updated, ping. Added routes in routes/api.php. Added Photo live_status default. Tests: tests/Feature/LiveShowRealtimeTest.php. Ran Pint + test.","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-01-05T11:11:06.028871737+01:00","created_by":"soeren","updated_at":"2026-01-05T13:08:33.936740582+01:00","closed_at":"2026-01-05T13:08:33.936740582+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-qne","depends_on_id":"fotospiel-app-t1k","type":"blocks","created_at":"2026-01-05T11:12:30.363982215+01:00","created_by":"soeren"}]}
|
||||||
{"id":"fotospiel-app-qtn","title":"Security review kickoff mitigations (CORS allowlist, headers, upload hardening, signed URLs)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:09:46.310873311+01:00","created_by":"soeren","updated_at":"2026-01-01T16:09:51.914359487+01:00","closed_at":"2026-01-01T16:09:51.914359487+01:00","close_reason":"Completed in codebase (verified)"}
|
{"id":"fotospiel-app-qtn","title":"Security review kickoff mitigations (CORS allowlist, headers, upload hardening, signed URLs)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:09:46.310873311+01:00","created_by":"soeren","updated_at":"2026-01-01T16:09:51.914359487+01:00","closed_at":"2026-01-01T16:09:51.914359487+01:00","close_reason":"Completed in codebase (verified)"}
|
||||||
|
{"id":"fotospiel-app-rpv","title":"Uploader: connection test (no upload)","description":"Part of epic fotospiel-app-5aa. Add lightweight ping/test for upload URL + credentials.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:02:39.061938692+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:02:39.061938692+01:00"}
|
||||||
{"id":"fotospiel-app-sbs","title":"Compliance tools: data export + retention overrides","description":"GDPR-compliant export requests and retention override workflows for tenants/events.","status":"closed","priority":3,"issue_type":"feature","created_at":"2026-01-01T14:20:16.530289009+01:00","updated_at":"2026-01-02T20:13:31.704875591+01:00","closed_at":"2026-01-02T20:13:31.704875591+01:00","close_reason":"Closed"}
|
{"id":"fotospiel-app-sbs","title":"Compliance tools: data export + retention overrides","description":"GDPR-compliant export requests and retention override workflows for tenants/events.","status":"closed","priority":3,"issue_type":"feature","created_at":"2026-01-01T14:20:16.530289009+01:00","updated_at":"2026-01-02T20:13:31.704875591+01:00","closed_at":"2026-01-02T20:13:31.704875591+01:00","close_reason":"Closed"}
|
||||||
|
{"id":"fotospiel-app-sdg","title":"Uploader: watch include/exclude patterns","description":"Part of epic fotospiel-app-5aa. Configurable file patterns (ignore tmp/preview) for watcher.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:02:17.188267106+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:02:17.188267106+01:00"}
|
||||||
{"id":"fotospiel-app-sju","title":"Live Show link sharing + QR in admin","description":"Expose Live Show link in Event Admin with copy/share/open actions and embedded QR (use simplesoftwareio/simple-qrcode, no external service). Add API endpoints for link fetch/rotate, admin UI card with rotate confirmation, and tests.","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-05T20:00:25.427132538+01:00","created_by":"soeren","updated_at":"2026-01-05T20:00:25.427132538+01:00"}
|
{"id":"fotospiel-app-sju","title":"Live Show link sharing + QR in admin","description":"Expose Live Show link in Event Admin with copy/share/open actions and embedded QR (use simplesoftwareio/simple-qrcode, no external service). Add API endpoints for link fetch/rotate, admin UI card with rotate confirmation, and tests.","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-05T20:00:25.427132538+01:00","created_by":"soeren","updated_at":"2026-01-05T20:00:25.427132538+01:00"}
|
||||||
{"id":"fotospiel-app-swb","title":"Security review: replace public asset URLs with signed routes","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:04:05.610098299+01:00","created_by":"soeren","updated_at":"2026-01-01T16:04:11.215921463+01:00","closed_at":"2026-01-01T16:04:11.215921463+01:00","close_reason":"Completed in codebase (verified)"}
|
{"id":"fotospiel-app-swb","title":"Security review: replace public asset URLs with signed routes","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:04:05.610098299+01:00","created_by":"soeren","updated_at":"2026-01-01T16:04:11.215921463+01:00","closed_at":"2026-01-01T16:04:11.215921463+01:00","close_reason":"Completed in codebase (verified)"}
|
||||||
{"id":"fotospiel-app-t1k","title":"Live Show: data model \u0026 status workflow (pending/approved/ready)","acceptance_criteria":"- DB migrations add event token + photo live fields + indexes\\n- Token generation supports rotation (no expiry)\\n- Photo live workflow methods set timestamps/reviewer consistently\\n- Feature test covers token + workflow","notes":"Implemented Live Show data model: events.live_show_token + live_show_token_rotated_at; photos.live_status + timestamps/reviewer/rejection fields + indexes. Added PhotoLiveStatus enum and Photo workflow methods (markLivePending/approveForLiveShow/rejectForLiveShow). Added Event helpers (ensureLiveShowToken/rotateLiveShowToken). Tests: tests/Feature/LiveShowDataModelTest.php.","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-01-05T11:10:56.560421826+01:00","created_by":"soeren","updated_at":"2026-01-05T12:22:51.967913423+01:00","closed_at":"2026-01-05T12:22:51.967913423+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-t1k","depends_on_id":"fotospiel-app-vro","type":"blocks","created_at":"2026-01-05T11:12:20.345646244+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-t1k","depends_on_id":"fotospiel-app-h5d","type":"blocks","created_at":"2026-01-05T11:44:12.439413712+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-t1k","depends_on_id":"fotospiel-app-1eu","type":"blocks","created_at":"2026-01-05T11:44:22.588642567+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-t1k","depends_on_id":"fotospiel-app-1we","type":"blocks","created_at":"2026-01-05T11:44:31.775634827+01:00","created_by":"soeren"}]}
|
{"id":"fotospiel-app-t1k","title":"Live Show: data model \u0026 status workflow (pending/approved/ready)","acceptance_criteria":"- DB migrations add event token + photo live fields + indexes\\n- Token generation supports rotation (no expiry)\\n- Photo live workflow methods set timestamps/reviewer consistently\\n- Feature test covers token + workflow","notes":"Implemented Live Show data model: events.live_show_token + live_show_token_rotated_at; photos.live_status + timestamps/reviewer/rejection fields + indexes. Added PhotoLiveStatus enum and Photo workflow methods (markLivePending/approveForLiveShow/rejectForLiveShow). Added Event helpers (ensureLiveShowToken/rotateLiveShowToken). Tests: tests/Feature/LiveShowDataModelTest.php.","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-01-05T11:10:56.560421826+01:00","created_by":"soeren","updated_at":"2026-01-05T12:22:51.967913423+01:00","closed_at":"2026-01-05T12:22:51.967913423+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-t1k","depends_on_id":"fotospiel-app-vro","type":"blocks","created_at":"2026-01-05T11:12:20.345646244+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-t1k","depends_on_id":"fotospiel-app-h5d","type":"blocks","created_at":"2026-01-05T11:44:12.439413712+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-t1k","depends_on_id":"fotospiel-app-1eu","type":"blocks","created_at":"2026-01-05T11:44:22.588642567+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-t1k","depends_on_id":"fotospiel-app-1we","type":"blocks","created_at":"2026-01-05T11:44:31.775634827+01:00","created_by":"soeren"}]}
|
||||||
|
{"id":"fotospiel-app-t2s","title":"Uploader: multiple event profiles","description":"Part of epic fotospiel-app-5aa. Save multiple event profiles and allow quick switching.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:04:18.20222112+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:04:18.20222112+01:00"}
|
||||||
{"id":"fotospiel-app-tqg","title":"Tenant admin onboarding: staging E2E validation","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:08:57.448899354+01:00","created_by":"soeren","updated_at":"2026-01-01T16:08:57.448899354+01:00"}
|
{"id":"fotospiel-app-tqg","title":"Tenant admin onboarding: staging E2E validation","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:08:57.448899354+01:00","created_by":"soeren","updated_at":"2026-01-01T16:08:57.448899354+01:00"}
|
||||||
|
{"id":"fotospiel-app-tsb","title":"Uploader: upload throttling presets","description":"Part of epic fotospiel-app-5aa. Add optional delay/presets to smooth upload bursts.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:02:27.111436345+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:02:27.111436345+01:00"}
|
||||||
{"id":"fotospiel-app-ty9","title":"Security review: data classes \u0026 retention baseline","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:03:09.595870306+01:00","created_by":"soeren","updated_at":"2026-01-01T16:03:15.211042718+01:00","closed_at":"2026-01-01T16:03:15.211042718+01:00","close_reason":"Completed in codebase (verified)"}
|
{"id":"fotospiel-app-ty9","title":"Security review: data classes \u0026 retention baseline","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:03:09.595870306+01:00","created_by":"soeren","updated_at":"2026-01-01T16:03:15.211042718+01:00","closed_at":"2026-01-01T16:03:15.211042718+01:00","close_reason":"Completed in codebase (verified)"}
|
||||||
{"id":"fotospiel-app-tym","title":"Ops health dashboard (queues, storage, upload pipeline)","description":"Superadmin ops dashboard showing queue backlog, failed jobs, storage thresholds, and upload pipeline health.","notes":"Implemented Ops Health dashboard with storage+queue widgets, new translations, and navigation wiring.","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-01-01T14:20:04.991351193+01:00","updated_at":"2026-01-02T17:34:10.326367902+01:00","closed_at":"2026-01-02T17:34:10.326367902+01:00","close_reason":"Closed"}
|
{"id":"fotospiel-app-tym","title":"Ops health dashboard (queues, storage, upload pipeline)","description":"Superadmin ops dashboard showing queue backlog, failed jobs, storage thresholds, and upload pipeline health.","notes":"Implemented Ops Health dashboard with storage+queue widgets, new translations, and navigation wiring.","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-01-01T14:20:04.991351193+01:00","updated_at":"2026-01-02T17:34:10.326367902+01:00","closed_at":"2026-01-02T17:34:10.326367902+01:00","close_reason":"Closed"}
|
||||||
{"id":"fotospiel-app-ugk","title":"Paddle catalog sync: feature test for artisan command","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:01:33.309716868+01:00","created_by":"soeren","updated_at":"2026-01-01T16:01:38.940407157+01:00","closed_at":"2026-01-01T16:01:38.940407157+01:00","close_reason":"Completed in codebase (verified)"}
|
{"id":"fotospiel-app-ugk","title":"Paddle catalog sync: feature test for artisan command","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:01:33.309716868+01:00","created_by":"soeren","updated_at":"2026-01-01T16:01:38.940407157+01:00","closed_at":"2026-01-01T16:01:38.940407157+01:00","close_reason":"Completed in codebase (verified)"}
|
||||||
@@ -141,6 +158,7 @@
|
|||||||
{"id":"fotospiel-app-wku","title":"Security review: run dynamic testing harness (identities, DAST, fuzz uploads)","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:05:37.008239379+01:00","created_by":"soeren","updated_at":"2026-01-01T16:05:37.008239379+01:00"}
|
{"id":"fotospiel-app-wku","title":"Security review: run dynamic testing harness (identities, DAST, fuzz uploads)","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:05:37.008239379+01:00","created_by":"soeren","updated_at":"2026-01-01T16:05:37.008239379+01:00"}
|
||||||
{"id":"fotospiel-app-xg5","title":"Live Show: Admin app moderation queue UI","acceptance_criteria":"- Dedicated Live Show moderation API endpoints exist for list + approve/reject/clear\\n- Admin mobile UI exposes Live Show queue with status filter and actions\\n- PhotoResource includes live_* fields for admin UI\\n- Feature tests cover list + approve/reject/clear workflows","notes":"Added dedicated Live Show moderation API (tenant admin): /events/{slug}/live-show/photos + approve/reject/clear actions. Added LiveShowPhotoController + FormRequests. PhotoResource now exposes live_* fields. Admin app: new Live Show queue page, route, and Event detail shortcut tile. Admin API updated with Live Show functions + types. Added translations (EN/DE) for Live Show queue UI. Tests: tests/Feature/LiveShowPhotoControllerTest.php.","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-01-05T11:11:15.006484132+01:00","created_by":"soeren","updated_at":"2026-01-05T14:03:41.410176482+01:00","closed_at":"2026-01-05T14:03:41.410176482+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-xg5","depends_on_id":"fotospiel-app-t1k","type":"blocks","created_at":"2026-01-05T11:12:38.94145573+01:00","created_by":"soeren"}]}
|
{"id":"fotospiel-app-xg5","title":"Live Show: Admin app moderation queue UI","acceptance_criteria":"- Dedicated Live Show moderation API endpoints exist for list + approve/reject/clear\\n- Admin mobile UI exposes Live Show queue with status filter and actions\\n- PhotoResource includes live_* fields for admin UI\\n- Feature tests cover list + approve/reject/clear workflows","notes":"Added dedicated Live Show moderation API (tenant admin): /events/{slug}/live-show/photos + approve/reject/clear actions. Added LiveShowPhotoController + FormRequests. PhotoResource now exposes live_* fields. Admin app: new Live Show queue page, route, and Event detail shortcut tile. Admin API updated with Live Show functions + types. Added translations (EN/DE) for Live Show queue UI. Tests: tests/Feature/LiveShowPhotoControllerTest.php.","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-01-05T11:11:15.006484132+01:00","created_by":"soeren","updated_at":"2026-01-05T14:03:41.410176482+01:00","closed_at":"2026-01-05T14:03:41.410176482+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-xg5","depends_on_id":"fotospiel-app-t1k","type":"blocks","created_at":"2026-01-05T11:12:38.94145573+01:00","created_by":"soeren"}]}
|
||||||
{"id":"fotospiel-app-xht","title":"Paddle migration: tenant ↔ Paddle customer sync + webhook handlers","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:58:01.028435913+01:00","created_by":"soeren","updated_at":"2026-01-01T15:58:06.685122343+01:00","closed_at":"2026-01-01T15:58:06.685122343+01:00","close_reason":"Completed in codebase (verified)"}
|
{"id":"fotospiel-app-xht","title":"Paddle migration: tenant ↔ Paddle customer sync + webhook handlers","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:58:01.028435913+01:00","created_by":"soeren","updated_at":"2026-01-01T15:58:06.685122343+01:00","closed_at":"2026-01-01T15:58:06.685122343+01:00","close_reason":"Completed in codebase (verified)"}
|
||||||
|
{"id":"fotospiel-app-xik","title":"Uploader: richer error details","description":"Part of epic fotospiel-app-5aa. Surface HTTP status/body summary in last error and recent uploads.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:02:49.591107008+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:02:49.591107008+01:00"}
|
||||||
{"id":"fotospiel-app-y1f","title":"Compliance tools: superadmin data export + retention override UI","description":"Add superadmin compliance tools for data exports and retention overrides.\nScope: list export requests, status, expiry, and allow manual retry/cancel; add per-tenant/event retention override UI with audit logging.\nEnsure access is restricted to superadmins and no PII is exposed beyond existing export metadata.\n","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-02T17:34:29.825347299+01:00","created_by":"soeren","updated_at":"2026-01-02T22:49:53.586758621+01:00","closed_at":"2026-01-02T22:49:53.586758621+01:00","close_reason":"Closed"}
|
{"id":"fotospiel-app-y1f","title":"Compliance tools: superadmin data export + retention override UI","description":"Add superadmin compliance tools for data exports and retention overrides.\nScope: list export requests, status, expiry, and allow manual retry/cancel; add per-tenant/event retention override UI with audit logging.\nEnsure access is restricted to superadmins and no PII is exposed beyond existing export metadata.\n","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-02T17:34:29.825347299+01:00","created_by":"soeren","updated_at":"2026-01-02T22:49:53.586758621+01:00","closed_at":"2026-01-02T22:49:53.586758621+01:00","close_reason":"Closed"}
|
||||||
{"id":"fotospiel-app-yii","title":"Implement 'Upgrade to Premium' flow for Analytics Upsell","description":"The Analytics page currently has an upsell screen for non-premium users. The 'Upgrade to Premium' button redirects to the billing page, but the actual upgrade/purchase flow needs to be fully implemented and verified to allow users to unlock the feature.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T16:13:55.446495378+01:00","created_by":"soeren","updated_at":"2026-01-06T16:35:41.968964977+01:00","closed_at":"2026-01-06T16:35:41.968970147+01:00"}
|
{"id":"fotospiel-app-yii","title":"Implement 'Upgrade to Premium' flow for Analytics Upsell","description":"The Analytics page currently has an upsell screen for non-premium users. The 'Upgrade to Premium' button redirects to the billing page, but the actual upgrade/purchase flow needs to be fully implemented and verified to allow users to unlock the feature.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T16:13:55.446495378+01:00","created_by":"soeren","updated_at":"2026-01-06T16:35:41.968964977+01:00","closed_at":"2026-01-06T16:35:41.968970147+01:00"}
|
||||||
{"id":"fotospiel-app-z2k","title":"Ops health widget visual polish","description":"Replace Tailwind utility styling in ops health widget with Filament components and icon-driven layout.","notes":"Updated queue health widget layout to use Filament cards, badges, empty states, and grid utilities; added status strip and alert rail.","status":"closed","priority":3,"issue_type":"task","created_at":"2026-01-01T21:34:39.851728527+01:00","created_by":"soeren","updated_at":"2026-01-01T21:34:59.834597413+01:00","closed_at":"2026-01-01T21:34:59.834597413+01:00","close_reason":"completed"}
|
{"id":"fotospiel-app-z2k","title":"Ops health widget visual polish","description":"Replace Tailwind utility styling in ops health widget with Filament components and icon-driven layout.","notes":"Updated queue health widget layout to use Filament cards, badges, empty states, and grid utilities; added status strip and alert rail.","status":"closed","priority":3,"issue_type":"task","created_at":"2026-01-01T21:34:39.851728527+01:00","created_by":"soeren","updated_at":"2026-01-01T21:34:59.834597413+01:00","closed_at":"2026-01-01T21:34:59.834597413+01:00","close_reason":"completed"}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
fotospiel-app-9em
|
fotospiel-app-6yz
|
||||||
|
|||||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -13,6 +13,8 @@ fotospiel-tenant-app
|
|||||||
/storage/*.key
|
/storage/*.key
|
||||||
/storage/pail
|
/storage/pail
|
||||||
/vendor
|
/vendor
|
||||||
|
/clients/photobooth-uploader/**/bin
|
||||||
|
/clients/photobooth-uploader/**/obj
|
||||||
.env
|
.env
|
||||||
.env.backup
|
.env.backup
|
||||||
.env.production
|
.env.production
|
||||||
@@ -23,11 +25,9 @@ Homestead.yaml
|
|||||||
npm-debug.log
|
npm-debug.log
|
||||||
yarn-error.log
|
yarn-error.log
|
||||||
/auth.json
|
/auth.json
|
||||||
/.fleet
|
|
||||||
/.idea
|
|
||||||
/.nova
|
|
||||||
/.vscode
|
/.vscode
|
||||||
/.zed
|
|
||||||
tools/git-askpass.ps1
|
|
||||||
podman-compose.dev.yml
|
|
||||||
test-results
|
test-results
|
||||||
|
GEMINI.md
|
||||||
|
.beads/.sync.lock
|
||||||
|
.beads/daemon-error
|
||||||
|
.beads/sync_base.jsonl
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -40,7 +40,7 @@ class Login extends BaseLogin implements HasForms
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SuperAdmin-spezifisch: Prüfe auf SuperAdmin-Rolle, keine Tenant-Prüfung
|
// SuperAdmin-spezifisch: Prüfe auf SuperAdmin-Rolle, keine Tenant-Prüfung
|
||||||
if ($user->role !== 'super_admin') {
|
if (! $user->isSuperAdmin()) {
|
||||||
$authGuard->logout();
|
$authGuard->logout();
|
||||||
|
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
|
|||||||
@@ -45,11 +45,11 @@ class GuestPolicySettingsPage extends Page
|
|||||||
|
|
||||||
public int $join_token_failure_decay_minutes = 5;
|
public int $join_token_failure_decay_minutes = 5;
|
||||||
|
|
||||||
public int $join_token_access_limit = 120;
|
public int $join_token_access_limit = 300;
|
||||||
|
|
||||||
public int $join_token_access_decay_minutes = 1;
|
public int $join_token_access_decay_minutes = 1;
|
||||||
|
|
||||||
public int $join_token_download_limit = 60;
|
public int $join_token_download_limit = 120;
|
||||||
|
|
||||||
public int $join_token_download_decay_minutes = 1;
|
public int $join_token_download_decay_minutes = 1;
|
||||||
|
|
||||||
@@ -69,9 +69,9 @@ class GuestPolicySettingsPage extends Page
|
|||||||
$this->per_device_upload_limit = (int) ($settings->per_device_upload_limit ?? 50);
|
$this->per_device_upload_limit = (int) ($settings->per_device_upload_limit ?? 50);
|
||||||
$this->join_token_failure_limit = (int) ($settings->join_token_failure_limit ?? 10);
|
$this->join_token_failure_limit = (int) ($settings->join_token_failure_limit ?? 10);
|
||||||
$this->join_token_failure_decay_minutes = (int) ($settings->join_token_failure_decay_minutes ?? 5);
|
$this->join_token_failure_decay_minutes = (int) ($settings->join_token_failure_decay_minutes ?? 5);
|
||||||
$this->join_token_access_limit = (int) ($settings->join_token_access_limit ?? 120);
|
$this->join_token_access_limit = (int) ($settings->join_token_access_limit ?? 300);
|
||||||
$this->join_token_access_decay_minutes = (int) ($settings->join_token_access_decay_minutes ?? 1);
|
$this->join_token_access_decay_minutes = (int) ($settings->join_token_access_decay_minutes ?? 1);
|
||||||
$this->join_token_download_limit = (int) ($settings->join_token_download_limit ?? 60);
|
$this->join_token_download_limit = (int) ($settings->join_token_download_limit ?? 120);
|
||||||
$this->join_token_download_decay_minutes = (int) ($settings->join_token_download_decay_minutes ?? 1);
|
$this->join_token_download_decay_minutes = (int) ($settings->join_token_download_decay_minutes ?? 1);
|
||||||
$this->join_token_ttl_hours = (int) ($settings->join_token_ttl_hours ?? 168);
|
$this->join_token_ttl_hours = (int) ($settings->join_token_ttl_hours ?? 168);
|
||||||
$this->share_link_ttl_hours = (int) ($settings->share_link_ttl_hours ?? 48);
|
$this->share_link_ttl_hours = (int) ($settings->share_link_ttl_hours ?? 48);
|
||||||
|
|||||||
@@ -3,9 +3,12 @@
|
|||||||
namespace App\Http\Controllers\Api;
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Checkout\CheckoutSessionStatusRequest;
|
||||||
|
use App\Models\CheckoutSession;
|
||||||
use App\Models\Package;
|
use App\Models\Package;
|
||||||
use App\Models\PackagePurchase;
|
use App\Models\PackagePurchase;
|
||||||
use App\Models\TenantPackage;
|
use App\Models\TenantPackage;
|
||||||
|
use App\Services\Checkout\CheckoutSessionService;
|
||||||
use App\Services\Paddle\PaddleCheckoutService;
|
use App\Services\Paddle\PaddleCheckoutService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -14,7 +17,10 @@ use Illuminate\Validation\ValidationException;
|
|||||||
|
|
||||||
class PackageController extends Controller
|
class PackageController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(private readonly PaddleCheckoutService $paddleCheckout) {}
|
public function __construct(
|
||||||
|
private readonly PaddleCheckoutService $paddleCheckout,
|
||||||
|
private readonly CheckoutSessionService $sessions,
|
||||||
|
) {}
|
||||||
|
|
||||||
public function index(Request $request): JsonResponse
|
public function index(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
@@ -165,23 +171,82 @@ class PackageController extends Controller
|
|||||||
|
|
||||||
$package = Package::findOrFail($request->integer('package_id'));
|
$package = Package::findOrFail($request->integer('package_id'));
|
||||||
$tenant = $request->attributes->get('tenant');
|
$tenant = $request->attributes->get('tenant');
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
if (! $tenant) {
|
if (! $tenant) {
|
||||||
throw ValidationException::withMessages(['tenant' => 'Tenant context missing.']);
|
throw ValidationException::withMessages(['tenant' => 'Tenant context missing.']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (! $user) {
|
||||||
|
throw ValidationException::withMessages(['user' => 'User context missing.']);
|
||||||
|
}
|
||||||
|
|
||||||
if (! $package->paddle_price_id) {
|
if (! $package->paddle_price_id) {
|
||||||
throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Paddle price.']);
|
throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Paddle price.']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$session = $this->sessions->createOrResume($user, $package, [
|
||||||
|
'tenant' => $tenant,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->sessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE);
|
||||||
|
|
||||||
|
$now = now();
|
||||||
|
|
||||||
|
$session->forceFill([
|
||||||
|
'accepted_terms_at' => $now,
|
||||||
|
'accepted_privacy_at' => $now,
|
||||||
|
'accepted_withdrawal_notice_at' => $now,
|
||||||
|
'digital_content_waiver_at' => null,
|
||||||
|
'legal_version' => config('app.legal_version', $now->toDateString()),
|
||||||
|
])->save();
|
||||||
|
|
||||||
$payload = [
|
$payload = [
|
||||||
'success_url' => $request->input('success_url'),
|
'success_url' => $request->input('success_url'),
|
||||||
'return_url' => $request->input('return_url'),
|
'return_url' => $request->input('return_url'),
|
||||||
|
'metadata' => [
|
||||||
|
'checkout_session_id' => $session->id,
|
||||||
|
'legal_version' => $session->legal_version,
|
||||||
|
'accepted_terms' => true,
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
$checkout = $this->paddleCheckout->createCheckout($tenant, $package, $payload);
|
$checkout = $this->paddleCheckout->createCheckout($tenant, $package, $payload);
|
||||||
|
|
||||||
return response()->json($checkout);
|
$session->forceFill([
|
||||||
|
'paddle_checkout_id' => $checkout['id'] ?? $session->paddle_checkout_id,
|
||||||
|
'provider_metadata' => array_merge($session->provider_metadata ?? [], array_filter([
|
||||||
|
'paddle_checkout_id' => $checkout['id'] ?? null,
|
||||||
|
'paddle_checkout_url' => $checkout['checkout_url'] ?? null,
|
||||||
|
'paddle_expires_at' => $checkout['expires_at'] ?? null,
|
||||||
|
])),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
return response()->json(array_merge($checkout, [
|
||||||
|
'checkout_session_id' => $session->id,
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function checkoutSessionStatus(CheckoutSessionStatusRequest $request, CheckoutSession $session): JsonResponse
|
||||||
|
{
|
||||||
|
$history = $session->status_history ?? [];
|
||||||
|
$reason = null;
|
||||||
|
|
||||||
|
foreach (array_reverse($history) as $entry) {
|
||||||
|
if (($entry['status'] ?? null) === $session->status) {
|
||||||
|
$reason = $entry['reason'] ?? null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$checkoutUrl = data_get($session->provider_metadata ?? [], 'paddle_checkout_url');
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'status' => $session->status,
|
||||||
|
'completed_at' => optional($session->completed_at)->toIso8601String(),
|
||||||
|
'reason' => $reason,
|
||||||
|
'checkout_url' => is_string($checkoutUrl) ? $checkoutUrl : null,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function handleFreePurchase(Request $request, Package $package, $tenant): JsonResponse
|
private function handleFreePurchase(Request $request, Package $package, $tenant): JsonResponse
|
||||||
|
|||||||
70
app/Http/Controllers/Api/PhotoboothConnectController.php
Normal file
70
app/Http/Controllers/Api/PhotoboothConnectController.php
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Photobooth\PhotoboothConnectRedeemRequest;
|
||||||
|
use App\Models\Event;
|
||||||
|
use App\Services\Photobooth\PhotoboothConnectCodeService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
class PhotoboothConnectController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private readonly PhotoboothConnectCodeService $service) {}
|
||||||
|
|
||||||
|
public function store(PhotoboothConnectRedeemRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$record = $this->service->redeem($request->input('code'));
|
||||||
|
|
||||||
|
if (! $record) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => __('Ungültiger oder abgelaufener Verbindungscode.'),
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$record->loadMissing('event.photoboothSetting');
|
||||||
|
$event = $record->event;
|
||||||
|
$setting = $event?->photoboothSetting;
|
||||||
|
|
||||||
|
if (! $event || ! $setting || ! $setting->enabled || $setting->mode !== 'sparkbooth') {
|
||||||
|
return response()->json([
|
||||||
|
'message' => __('Photobooth ist nicht im Sparkbooth-Modus aktiv.'),
|
||||||
|
], 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => [
|
||||||
|
'event_name' => $this->resolveEventName($event),
|
||||||
|
'upload_url' => route('api.v1.photobooth.upload'),
|
||||||
|
'username' => $setting->username,
|
||||||
|
'password' => $setting->password,
|
||||||
|
'expires_at' => optional($setting->expires_at)->toIso8601String(),
|
||||||
|
'response_format' => ($setting->metadata ?? [])['sparkbooth_response_format']
|
||||||
|
?? config('photobooth.sparkbooth.response_format', 'json'),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveEventName(?Event $event): ?string
|
||||||
|
{
|
||||||
|
if (! $event) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = $event->name;
|
||||||
|
|
||||||
|
if (is_string($name) && trim($name) !== '') {
|
||||||
|
return $name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($name)) {
|
||||||
|
foreach ($name as $value) {
|
||||||
|
if (is_string($value) && trim($value) !== '') {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $event->slug ?: null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ use App\Models\Package;
|
|||||||
use App\Models\PackagePurchase;
|
use App\Models\PackagePurchase;
|
||||||
use App\Models\Photo;
|
use App\Models\Photo;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
use App\Services\EventJoinTokenService;
|
use App\Services\EventJoinTokenService;
|
||||||
use App\Support\ApiError;
|
use App\Support\ApiError;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
@@ -88,12 +89,15 @@ class EventController extends Controller
|
|||||||
$tenant = Tenant::findOrFail($tenantId);
|
$tenant = Tenant::findOrFail($tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$actor = $request->user();
|
||||||
|
$isSuperAdmin = $actor instanceof User && $actor->isSuperAdmin();
|
||||||
|
|
||||||
// Package check is now handled by middleware
|
// Package check is now handled by middleware
|
||||||
|
|
||||||
$validated = $request->validated();
|
$validated = $request->validated();
|
||||||
$tenantId = $tenant->id;
|
$tenantId = $tenant->id;
|
||||||
|
|
||||||
$requestedPackageId = $validated['package_id'] ?? null;
|
$requestedPackageId = $isSuperAdmin ? $request->integer('package_id') : null;
|
||||||
unset($validated['package_id']);
|
unset($validated['package_id']);
|
||||||
|
|
||||||
$tenantPackage = $tenant->tenantPackages()
|
$tenantPackage = $tenant->tenantPackages()
|
||||||
@@ -108,6 +112,10 @@ class EventController extends Controller
|
|||||||
$package = Package::query()->find($requestedPackageId);
|
$package = Package::query()->find($requestedPackageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (! $package && $isSuperAdmin) {
|
||||||
|
$package = $this->resolveOwnerPackage();
|
||||||
|
}
|
||||||
|
|
||||||
if (! $package && $tenantPackage) {
|
if (! $package && $tenantPackage) {
|
||||||
$package = $tenantPackage->package ?? Package::query()->find($tenantPackage->package_id);
|
$package = $tenantPackage->package ?? Package::query()->find($tenantPackage->package_id);
|
||||||
}
|
}
|
||||||
@@ -121,7 +129,7 @@ class EventController extends Controller
|
|||||||
$requiresWaiver = $package->isEndcustomer();
|
$requiresWaiver = $package->isEndcustomer();
|
||||||
$latestPurchase = $requiresWaiver ? $this->resolveLatestPackagePurchase($tenant, $package) : null;
|
$latestPurchase = $requiresWaiver ? $this->resolveLatestPackagePurchase($tenant, $package) : null;
|
||||||
$existingWaiver = $latestPurchase ? data_get($latestPurchase->metadata, 'consents.digital_content_waiver_at') : null;
|
$existingWaiver = $latestPurchase ? data_get($latestPurchase->metadata, 'consents.digital_content_waiver_at') : null;
|
||||||
$needsWaiver = $requiresWaiver && ! $existingWaiver;
|
$needsWaiver = ! $isSuperAdmin && $requiresWaiver && ! $existingWaiver;
|
||||||
|
|
||||||
if ($needsWaiver && ! $request->boolean('accepted_waiver')) {
|
if ($needsWaiver && ! $request->boolean('accepted_waiver')) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
@@ -182,7 +190,7 @@ class EventController extends Controller
|
|||||||
|
|
||||||
$eventData = Arr::only($eventData, $allowed);
|
$eventData = Arr::only($eventData, $allowed);
|
||||||
|
|
||||||
$event = DB::transaction(function () use ($tenant, $eventData, $package) {
|
$event = DB::transaction(function () use ($tenant, $eventData, $package, $isSuperAdmin) {
|
||||||
$event = Event::create($eventData);
|
$event = Event::create($eventData);
|
||||||
|
|
||||||
EventPackage::create([
|
EventPackage::create([
|
||||||
@@ -193,7 +201,7 @@ class EventController extends Controller
|
|||||||
'gallery_expires_at' => $package->gallery_days ? now()->addDays($package->gallery_days) : null,
|
'gallery_expires_at' => $package->gallery_days ? now()->addDays($package->gallery_days) : null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if ($package->isReseller()) {
|
if ($package->isReseller() && ! $isSuperAdmin) {
|
||||||
$note = sprintf('Event #%d created (%s)', $event->id, $event->name);
|
$note = sprintf('Event #%d created (%s)', $event->id, $event->name);
|
||||||
|
|
||||||
if (! $tenant->consumeEventAllowance(1, 'event.create', $note)) {
|
if (! $tenant->consumeEventAllowance(1, 'event.create', $note)) {
|
||||||
@@ -229,6 +237,15 @@ class EventController extends Controller
|
|||||||
->first();
|
->first();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function resolveOwnerPackage(): ?Package
|
||||||
|
{
|
||||||
|
$ownerPackage = Package::query()
|
||||||
|
->where('slug', 'pro')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
return $ownerPackage ?? Package::query()->find(3);
|
||||||
|
}
|
||||||
|
|
||||||
private function recordEventStartWaiver(Tenant $tenant, Package $package, ?PackagePurchase $purchase): void
|
private function recordEventStartWaiver(Tenant $tenant, Package $package, ?PackagePurchase $purchase): void
|
||||||
{
|
{
|
||||||
$timestamp = now();
|
$timestamp = now();
|
||||||
@@ -332,9 +349,14 @@ class EventController extends Controller
|
|||||||
$validated['settings']['watermark_allowed'] = $watermarkAllowed;
|
$validated['settings']['watermark_allowed'] = $watermarkAllowed;
|
||||||
|
|
||||||
$settings = $validated['settings'];
|
$settings = $validated['settings'];
|
||||||
|
$branding = Arr::get($settings, 'branding', []);
|
||||||
$watermark = Arr::get($settings, 'watermark', []);
|
$watermark = Arr::get($settings, 'watermark', []);
|
||||||
$existingWatermark = is_array($watermark) ? $watermark : [];
|
$existingWatermark = is_array($watermark) ? $watermark : [];
|
||||||
|
|
||||||
|
if (is_array($branding)) {
|
||||||
|
$settings['branding'] = $this->normalizeBrandingSettings($branding, $event, $brandingAllowed);
|
||||||
|
}
|
||||||
|
|
||||||
if (is_array($watermark)) {
|
if (is_array($watermark)) {
|
||||||
$mode = $watermark['mode'] ?? 'base';
|
$mode = $watermark['mode'] ?? 'base';
|
||||||
$policy = $watermarkAllowed ? 'basic' : 'none';
|
$policy = $watermarkAllowed ? 'basic' : 'none';
|
||||||
@@ -425,6 +447,68 @@ class EventController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $branding
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function normalizeBrandingSettings(array $branding, Event $event, bool $brandingAllowed): array
|
||||||
|
{
|
||||||
|
$logoDataUrl = $branding['logo_data_url'] ?? null;
|
||||||
|
|
||||||
|
if (! $brandingAllowed) {
|
||||||
|
unset($branding['logo_data_url']);
|
||||||
|
|
||||||
|
return $branding;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_string($logoDataUrl) || trim($logoDataUrl) === '') {
|
||||||
|
unset($branding['logo_data_url']);
|
||||||
|
|
||||||
|
return $branding;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! preg_match('/^data:image\\/(png|webp|jpe?g);base64,(.+)$/i', $logoDataUrl, $matches)) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'settings.branding.logo_data_url' => __('Ungültiges Branding-Logo.'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = base64_decode($matches[2], true);
|
||||||
|
|
||||||
|
if ($decoded === false) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'settings.branding.logo_data_url' => __('Branding-Logo konnte nicht gelesen werden.'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strlen($decoded) > 1024 * 1024) { // 1 MB
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'settings.branding.logo_data_url' => __('Branding-Logo ist zu groß (max. 1 MB).'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$extension = str_starts_with(strtolower($matches[1]), 'jp') ? 'jpg' : strtolower($matches[1]);
|
||||||
|
$path = sprintf('branding/logos/event-%s.%s', $event->id, $extension);
|
||||||
|
Storage::disk('public')->put($path, $decoded);
|
||||||
|
|
||||||
|
$branding['logo_url'] = $path;
|
||||||
|
$branding['logo_mode'] = 'upload';
|
||||||
|
$branding['logo_value'] = $path;
|
||||||
|
|
||||||
|
$logo = $branding['logo'] ?? [];
|
||||||
|
if (! is_array($logo)) {
|
||||||
|
$logo = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$logo['mode'] = 'upload';
|
||||||
|
$logo['value'] = $path;
|
||||||
|
$branding['logo'] = $logo;
|
||||||
|
|
||||||
|
unset($branding['logo_data_url']);
|
||||||
|
|
||||||
|
return $branding;
|
||||||
|
}
|
||||||
|
|
||||||
public function destroy(Request $request, Event $event): JsonResponse
|
public function destroy(Request $request, Event $event): JsonResponse
|
||||||
{
|
{
|
||||||
$tenantId = $request->attributes->get('tenant_id');
|
$tenantId = $request->attributes->get('tenant_id');
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ class EventMemberController extends Controller
|
|||||||
$user->password = Hash::make(Str::random(32));
|
$user->password = Hash::make(Str::random(32));
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($user->tenant_id && (int) $user->tenant_id !== (int) $tenant->id && $user->role !== 'super_admin') {
|
if ($user->tenant_id && (int) $user->tenant_id !== (int) $tenant->id && ! $user->isSuperAdmin()) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'email' => __('Dieser Benutzer ist einem anderen Mandanten zugeordnet.'),
|
'email' => __('Dieser Benutzer ist einem anderen Mandanten zugeordnet.'),
|
||||||
]);
|
]);
|
||||||
@@ -143,9 +143,9 @@ class EventMemberController extends Controller
|
|||||||
|
|
||||||
$user->tenant_id = $tenant->id;
|
$user->tenant_id = $tenant->id;
|
||||||
|
|
||||||
if ($role === 'tenant_admin' && $user->role !== 'super_admin') {
|
if ($role === 'tenant_admin' && ! $user->isSuperAdmin()) {
|
||||||
$user->role = 'tenant_admin';
|
$user->role = 'tenant_admin';
|
||||||
} elseif (! in_array($user->role, ['tenant_admin', 'super_admin'], true)) {
|
} elseif (! in_array($user->role, ['tenant_admin', 'super_admin', 'superadmin'], true)) {
|
||||||
$user->role = 'member';
|
$user->role = 'member';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -525,13 +525,13 @@ class PhotoController extends Controller
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// Only tenant admins can moderate
|
// Only tenant admins can moderate
|
||||||
if (isset($validated['status']) && ! $this->tokenHasScope($request, 'tenant:write')) {
|
if (isset($validated['status']) && ! $this->tokenHasScope($request, 'tenant-admin')) {
|
||||||
return ApiError::response(
|
return ApiError::response(
|
||||||
'insufficient_scope',
|
'insufficient_scope',
|
||||||
'Insufficient Scopes',
|
'Insufficient Scopes',
|
||||||
'You are not allowed to moderate photos for this event.',
|
'You are not allowed to moderate photos for this event.',
|
||||||
Response::HTTP_FORBIDDEN,
|
Response::HTTP_FORBIDDEN,
|
||||||
['required_scope' => 'tenant:write']
|
['required_scope' => 'tenant-admin']
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -823,6 +823,11 @@ class PhotoController extends Controller
|
|||||||
|
|
||||||
private function tokenHasScope(Request $request, string $scope): bool
|
private function tokenHasScope(Request $request, string $scope): bool
|
||||||
{
|
{
|
||||||
|
$accessToken = $request->user()?->currentAccessToken();
|
||||||
|
if ($accessToken && $accessToken->can($scope)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
$scopes = $request->user()->scopes ?? ($request->attributes->get('decoded_token')['scopes'] ?? []);
|
$scopes = $request->user()->scopes ?? ($request->attributes->get('decoded_token')['scopes'] ?? []);
|
||||||
|
|
||||||
if (! is_array($scopes)) {
|
if (! is_array($scopes)) {
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\Tenant;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Tenant\PhotoboothConnectCodeStoreRequest;
|
||||||
|
use App\Models\Event;
|
||||||
|
use App\Services\Photobooth\PhotoboothConnectCodeService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
class PhotoboothConnectCodeController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private readonly PhotoboothConnectCodeService $service) {}
|
||||||
|
|
||||||
|
public function store(PhotoboothConnectCodeStoreRequest $request, Event $event): JsonResponse
|
||||||
|
{
|
||||||
|
$this->assertEventBelongsToTenant($request, $event);
|
||||||
|
|
||||||
|
$event->loadMissing('photoboothSetting');
|
||||||
|
$setting = $event->photoboothSetting;
|
||||||
|
|
||||||
|
if (! $setting || ! $setting->enabled || $setting->mode !== 'sparkbooth') {
|
||||||
|
return response()->json([
|
||||||
|
'message' => __('Photobooth muss im Sparkbooth-Modus aktiviert sein.'),
|
||||||
|
], 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
$expiresInMinutes = $request->input('expires_in_minutes');
|
||||||
|
$result = $this->service->create($event, $expiresInMinutes ? (int) $expiresInMinutes : null);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => [
|
||||||
|
'code' => $result['code'],
|
||||||
|
'expires_at' => $result['expires_at']->toIso8601String(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function assertEventBelongsToTenant(PhotoboothConnectCodeStoreRequest $request, Event $event): void
|
||||||
|
{
|
||||||
|
$tenantId = (int) $request->attributes->get('tenant_id');
|
||||||
|
|
||||||
|
if ($tenantId !== (int) $event->tenant_id) {
|
||||||
|
abort(403, 'Event gehört nicht zu diesem Tenant.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -193,11 +193,11 @@ class TenantAdminTokenController extends Controller
|
|||||||
$abilities[] = 'tenant:'.$user->tenant_id;
|
$abilities[] = 'tenant:'.$user->tenant_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (in_array($user->role, ['tenant_admin', 'admin', 'super_admin'], true)) {
|
if (in_array($user->role, ['tenant_admin', 'admin', 'super_admin', 'superadmin'], true)) {
|
||||||
$abilities[] = 'tenant-admin';
|
$abilities[] = 'tenant-admin';
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($user->role === 'super_admin') {
|
if ($user->isSuperAdmin()) {
|
||||||
$abilities[] = 'super-admin';
|
$abilities[] = 'super-admin';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,7 +219,7 @@ class TenantAdminTokenController extends Controller
|
|||||||
|
|
||||||
private function ensureUserCanAccessPanel(User $user): void
|
private function ensureUserCanAccessPanel(User $user): void
|
||||||
{
|
{
|
||||||
if (in_array($user->role, ['tenant_admin', 'admin', 'super_admin'], true)) {
|
if (in_array($user->role, ['tenant_admin', 'admin', 'super_admin', 'superadmin'], true)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ use App\Models\User;
|
|||||||
use App\Notifications\TenantFeedbackSubmitted;
|
use App\Notifications\TenantFeedbackSubmitted;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Validation\Rule;
|
|
||||||
use Illuminate\Support\Facades\Notification;
|
use Illuminate\Support\Facades\Notification;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
class TenantFeedbackController extends Controller
|
class TenantFeedbackController extends Controller
|
||||||
{
|
{
|
||||||
@@ -56,7 +56,7 @@ class TenantFeedbackController extends Controller
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$recipients = User::query()
|
$recipients = User::query()
|
||||||
->where('role', 'super_admin')
|
->whereIn('role', ['super_admin', 'superadmin'])
|
||||||
->whereNotNull('email')
|
->whereNotNull('email')
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ class TenantAdminPasswordResetController extends Controller
|
|||||||
|
|
||||||
private function canAccessEventAdmin(User $user): bool
|
private function canAccessEventAdmin(User $user): bool
|
||||||
{
|
{
|
||||||
if (in_array($user->role, ['tenant_admin', 'admin', 'super_admin'], true)) {
|
if (in_array($user->role, ['tenant_admin', 'admin', 'super_admin', 'superadmin'], true)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ class AuthenticatedSessionController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Super admins go to Filament superadmin panel
|
// Super admins go to Filament superadmin panel
|
||||||
if ($user && $user->role === 'super_admin') {
|
if ($user && $user->isSuperAdmin()) {
|
||||||
return '/super-admin';
|
return '/super-admin';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ class TenantAdminAuthController extends Controller
|
|||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
|
|
||||||
// Allow only tenant_admin and super_admin
|
// Allow only tenant_admin and super_admin
|
||||||
if ($user && in_array($user->role, ['tenant_admin', 'super_admin'])) {
|
if ($user && in_array($user->role, ['tenant_admin', 'super_admin', 'superadmin'], true)) {
|
||||||
return view('admin');
|
return view('admin');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ class TenantAdminGoogleController extends Controller
|
|||||||
/** @var User|null $user */
|
/** @var User|null $user */
|
||||||
$user = User::query()->where('email', $email)->first();
|
$user = User::query()->where('email', $email)->first();
|
||||||
|
|
||||||
if (! $user || ! in_array($user->role, ['tenant_admin', 'super_admin'], true)) {
|
if (! $user || ! in_array($user->role, ['tenant_admin', 'super_admin', 'superadmin'], true)) {
|
||||||
return $this->sendBackWithError($request, 'google_no_match', 'No tenant admin account is linked to this Google address.');
|
return $this->sendBackWithError($request, 'google_no_match', 'No tenant admin account is linked to this Google address.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Http\Middleware;
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
use App\Services\Packages\PackageLimitEvaluator;
|
use App\Services\Packages\PackageLimitEvaluator;
|
||||||
use App\Support\ApiError;
|
use App\Support\ApiError;
|
||||||
use Closure;
|
use Closure;
|
||||||
@@ -26,7 +27,7 @@ class CreditCheckMiddleware
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->requiresCredits($request)) {
|
if ($this->requiresCredits($request) && ! $this->shouldBypassCreditCheck($request, $tenant)) {
|
||||||
$violation = $this->limitEvaluator->assessEventCreation($tenant);
|
$violation = $this->limitEvaluator->assessEventCreation($tenant);
|
||||||
|
|
||||||
if ($violation !== null) {
|
if ($violation !== null) {
|
||||||
@@ -43,6 +44,24 @@ class CreditCheckMiddleware
|
|||||||
return $next($request);
|
return $next($request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function shouldBypassCreditCheck(Request $request, Tenant $tenant): bool
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->isSuperAdmin()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->tenant_id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) $user->tenant_id === (int) $tenant->id;
|
||||||
|
}
|
||||||
|
|
||||||
private function requiresCredits(Request $request): bool
|
private function requiresCredits(Request $request): bool
|
||||||
{
|
{
|
||||||
return $request->isMethod('post')
|
return $request->isMethod('post')
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ class EnsureTenantAdminToken
|
|||||||
/** @var Tenant|null $tenant */
|
/** @var Tenant|null $tenant */
|
||||||
$tenant = $user->tenant;
|
$tenant = $user->tenant;
|
||||||
|
|
||||||
if (! $tenant && $user->role === 'super_admin') {
|
if (! $tenant && $user->isSuperAdmin()) {
|
||||||
$requestedTenantId = $this->resolveRequestedTenantId($request);
|
$requestedTenantId = $this->resolveRequestedTenantId($request);
|
||||||
|
|
||||||
if ($requestedTenantId !== null) {
|
if ($requestedTenantId !== null) {
|
||||||
@@ -50,14 +50,14 @@ class EnsureTenantAdminToken
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $tenant && $user->role !== 'super_admin') {
|
if (! $tenant && ! $user->isSuperAdmin()) {
|
||||||
return $this->forbiddenResponse('Tenant context missing for user.');
|
return $this->forbiddenResponse('Tenant context missing for user.');
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($tenant) {
|
if ($tenant) {
|
||||||
$request->attributes->set('tenant_id', $tenant->id);
|
$request->attributes->set('tenant_id', $tenant->id);
|
||||||
$request->attributes->set('tenant', $tenant);
|
$request->attributes->set('tenant', $tenant);
|
||||||
} elseif ($user->role === 'super_admin') {
|
} elseif ($user->isSuperAdmin()) {
|
||||||
$requestedTenantId = $this->resolveRequestedTenantId($request);
|
$requestedTenantId = $this->resolveRequestedTenantId($request);
|
||||||
if ($requestedTenantId !== null) {
|
if ($requestedTenantId !== null) {
|
||||||
$request->attributes->set('tenant_id', $requestedTenantId);
|
$request->attributes->set('tenant_id', $requestedTenantId);
|
||||||
@@ -96,7 +96,7 @@ class EnsureTenantAdminToken
|
|||||||
*/
|
*/
|
||||||
protected function allowedRoles(): array
|
protected function allowedRoles(): array
|
||||||
{
|
{
|
||||||
return ['tenant_admin', 'super_admin', 'admin'];
|
return ['tenant_admin', 'super_admin', 'superadmin', 'admin'];
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function forbiddenRoleMessage(): string
|
protected function forbiddenRoleMessage(): string
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ class EnsureTenantCollaboratorToken extends EnsureTenantAdminToken
|
|||||||
{
|
{
|
||||||
protected function allowedRoles(): array
|
protected function allowedRoles(): array
|
||||||
{
|
{
|
||||||
return ['tenant_admin', 'super_admin', 'admin', 'member'];
|
return ['tenant_admin', 'super_admin', 'superadmin', 'admin', 'member'];
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function forbiddenRoleMessage(): string
|
protected function forbiddenRoleMessage(): string
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Http\Middleware;
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
use App\Services\Packages\PackageLimitEvaluator;
|
use App\Services\Packages\PackageLimitEvaluator;
|
||||||
use App\Support\ApiError;
|
use App\Support\ApiError;
|
||||||
use Closure;
|
use Closure;
|
||||||
@@ -26,7 +27,7 @@ class PackageMiddleware
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->requiresPackageCheck($request)) {
|
if ($this->requiresPackageCheck($request) && ! $this->shouldBypassPackageCheck($request, $tenant)) {
|
||||||
$violation = $this->detectViolation($request, $tenant);
|
$violation = $this->detectViolation($request, $tenant);
|
||||||
|
|
||||||
if ($violation !== null) {
|
if ($violation !== null) {
|
||||||
@@ -43,6 +44,24 @@ class PackageMiddleware
|
|||||||
return $next($request);
|
return $next($request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function shouldBypassPackageCheck(Request $request, Tenant $tenant): bool
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->isSuperAdmin()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->tenant_id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) $user->tenant_id === (int) $tenant->id;
|
||||||
|
}
|
||||||
|
|
||||||
private function requiresPackageCheck(Request $request): bool
|
private function requiresPackageCheck(Request $request): bool
|
||||||
{
|
{
|
||||||
return $request->isMethod('post') && (
|
return $request->isMethod('post') && (
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ class RedirectIfAuthenticated extends BaseMiddleware
|
|||||||
return '/event-admin/dashboard';
|
return '/event-admin/dashboard';
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($user && $user->role === 'super_admin') {
|
if ($user && $user->isSuperAdmin()) {
|
||||||
return '/super-admin';
|
return '/super-admin';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ namespace App\Http\Middleware;
|
|||||||
|
|
||||||
use Closure;
|
use Closure;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
class SuperAdminAuth
|
class SuperAdminAuth
|
||||||
{
|
{
|
||||||
@@ -21,15 +21,15 @@ class SuperAdminAuth
|
|||||||
return $next($request);
|
return $next($request);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Auth::check()) {
|
if (! Auth::check()) {
|
||||||
abort(403, 'Nicht angemeldet.');
|
abort(403, 'Nicht angemeldet.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
Log::info('SuperAdminAuth: User ID ' . $user->id . ', role: ' . $user->role);
|
Log::info('SuperAdminAuth: User ID '.$user->id.', role: '.$user->role);
|
||||||
|
|
||||||
if ($user->role !== 'super_admin') {
|
if (! $user->isSuperAdmin()) {
|
||||||
abort(403, 'Zugriff nur für SuperAdmin. User ID: ' . $user->id . ', Role: ' . $user->role);
|
abort(403, 'Zugriff nur für SuperAdmin. User ID: '.$user->id.', Role: '.$user->role);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $next($request);
|
return $next($request);
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Photobooth;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class PhotoboothConnectRedeemRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'code' => ['required', 'string', 'size:6', 'regex:/^\d{6}$/'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
$code = preg_replace('/\D+/', '', (string) $this->input('code'));
|
||||||
|
|
||||||
|
$this->merge([
|
||||||
|
'code' => $code,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ class EventStoreRequest extends FormRequest
|
|||||||
'event_date' => ['required', 'date', 'after_or_equal:today'],
|
'event_date' => ['required', 'date', 'after_or_equal:today'],
|
||||||
'location' => ['nullable', 'string', 'max:255'],
|
'location' => ['nullable', 'string', 'max:255'],
|
||||||
'event_type_id' => ['required', 'exists:event_types,id'],
|
'event_type_id' => ['required', 'exists:event_types,id'],
|
||||||
|
'package_id' => ['nullable', 'integer', 'exists:packages,id'],
|
||||||
'max_participants' => ['nullable', 'integer', 'min:1', 'max:10000'],
|
'max_participants' => ['nullable', 'integer', 'min:1', 'max:10000'],
|
||||||
'public_url' => ['nullable', 'url', 'max:500'],
|
'public_url' => ['nullable', 'url', 'max:500'],
|
||||||
'custom_domain' => ['nullable', 'string', 'max:255'],
|
'custom_domain' => ['nullable', 'string', 'max:255'],
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Tenant;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class PhotoboothConnectCodeStoreRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'expires_in_minutes' => ['nullable', 'integer', 'min:1', 'max:120'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -5,11 +5,19 @@ namespace App\Listeners\GuestNotifications;
|
|||||||
use App\Enums\GuestNotificationAudience;
|
use App\Enums\GuestNotificationAudience;
|
||||||
use App\Enums\GuestNotificationType;
|
use App\Enums\GuestNotificationType;
|
||||||
use App\Events\GuestPhotoUploaded;
|
use App\Events\GuestPhotoUploaded;
|
||||||
|
use App\Models\GuestNotification;
|
||||||
use App\Models\Photo;
|
use App\Models\Photo;
|
||||||
use App\Services\GuestNotificationService;
|
use App\Services\GuestNotificationService;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
class SendPhotoUploadedNotification
|
class SendPhotoUploadedNotification
|
||||||
{
|
{
|
||||||
|
private const DEDUPE_WINDOW_SECONDS = 30;
|
||||||
|
|
||||||
|
private const GROUP_WINDOW_MINUTES = 10;
|
||||||
|
|
||||||
|
private const MAX_GROUP_PHOTOS = 6;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param int[] $milestones
|
* @param int[] $milestones
|
||||||
*/
|
*/
|
||||||
@@ -25,7 +33,20 @@ class SendPhotoUploadedNotification
|
|||||||
? sprintf('%s hat gerade ein Foto gemacht 🎉', $guestLabel)
|
? sprintf('%s hat gerade ein Foto gemacht 🎉', $guestLabel)
|
||||||
: 'Es gibt neue Fotos!';
|
: 'Es gibt neue Fotos!';
|
||||||
|
|
||||||
$this->notifications->createNotification(
|
$recent = $this->findRecentPhotoNotification($event->event->id);
|
||||||
|
if ($recent) {
|
||||||
|
if ($this->shouldSkipDuplicate($recent, $event->photoId, $title)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$notification = $this->updateGroupedNotification($recent, $event->photoId);
|
||||||
|
$this->markUploaderRead($notification, $event->guestIdentifier);
|
||||||
|
$this->maybeCreateMilestoneNotification($event, $guestLabel);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$notification = $this->notifications->createNotification(
|
||||||
$event->event,
|
$event->event,
|
||||||
GuestNotificationType::PHOTO_ACTIVITY,
|
GuestNotificationType::PHOTO_ACTIVITY,
|
||||||
$title,
|
$title,
|
||||||
@@ -34,11 +55,15 @@ class SendPhotoUploadedNotification
|
|||||||
'audience_scope' => GuestNotificationAudience::ALL,
|
'audience_scope' => GuestNotificationAudience::ALL,
|
||||||
'payload' => [
|
'payload' => [
|
||||||
'photo_id' => $event->photoId,
|
'photo_id' => $event->photoId,
|
||||||
|
'photo_ids' => [$event->photoId],
|
||||||
|
'count' => 1,
|
||||||
],
|
],
|
||||||
'expires_at' => now()->addHours(3),
|
'expires_at' => now()->addHours(3),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$this->markUploaderRead($notification, $event->guestIdentifier);
|
||||||
|
|
||||||
$this->maybeCreateMilestoneNotification($event, $guestLabel);
|
$this->maybeCreateMilestoneNotification($event, $guestLabel);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,4 +112,94 @@ class SendPhotoUploadedNotification
|
|||||||
|
|
||||||
return $guestIdentifier;
|
return $guestIdentifier;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function findRecentPhotoNotification(int $eventId): ?GuestNotification
|
||||||
|
{
|
||||||
|
$cutoff = Carbon::now()->subMinutes(self::GROUP_WINDOW_MINUTES);
|
||||||
|
|
||||||
|
return GuestNotification::query()
|
||||||
|
->where('event_id', $eventId)
|
||||||
|
->where('type', GuestNotificationType::PHOTO_ACTIVITY)
|
||||||
|
->active()
|
||||||
|
->notExpired()
|
||||||
|
->where('created_at', '>=', $cutoff)
|
||||||
|
->orderByDesc('id')
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function shouldSkipDuplicate(GuestNotification $notification, int $photoId, string $title): bool
|
||||||
|
{
|
||||||
|
$payload = $notification->payload;
|
||||||
|
if (is_array($payload)) {
|
||||||
|
$payloadIds = array_filter(
|
||||||
|
array_map(
|
||||||
|
fn ($value) => is_numeric($value) ? (int) $value : null,
|
||||||
|
(array) ($payload['photo_ids'] ?? [])
|
||||||
|
),
|
||||||
|
fn ($value) => $value !== null && $value > 0
|
||||||
|
);
|
||||||
|
if (in_array($photoId, $payloadIds, true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (is_numeric($payload['photo_id'] ?? null) && (int) $payload['photo_id'] === $photoId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$cutoff = Carbon::now()->subSeconds(self::DEDUPE_WINDOW_SECONDS);
|
||||||
|
if ($notification->created_at instanceof Carbon && $notification->created_at->greaterThanOrEqualTo($cutoff)) {
|
||||||
|
return $notification->title === $title;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function updateGroupedNotification(GuestNotification $notification, int $photoId): GuestNotification
|
||||||
|
{
|
||||||
|
$payload = is_array($notification->payload) ? $notification->payload : [];
|
||||||
|
$photoIds = array_filter(
|
||||||
|
array_map(
|
||||||
|
fn ($value) => is_numeric($value) ? (int) $value : null,
|
||||||
|
(array) ($payload['photo_ids'] ?? [])
|
||||||
|
),
|
||||||
|
fn ($value) => $value !== null && $value > 0
|
||||||
|
);
|
||||||
|
$photoIds[] = $photoId;
|
||||||
|
$photoIds = array_values(array_unique($photoIds));
|
||||||
|
$photoIds = array_slice($photoIds, 0, self::MAX_GROUP_PHOTOS);
|
||||||
|
|
||||||
|
$existingCount = is_numeric($payload['count'] ?? null)
|
||||||
|
? max(1, (int) $payload['count'])
|
||||||
|
: max(1, count($photoIds) - 1);
|
||||||
|
$newCount = $existingCount + 1;
|
||||||
|
|
||||||
|
$notification->forceFill([
|
||||||
|
'title' => $this->buildGroupedTitle($newCount),
|
||||||
|
'payload' => [
|
||||||
|
'count' => $newCount,
|
||||||
|
'photo_ids' => $photoIds,
|
||||||
|
],
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
return $notification;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildGroupedTitle(int $count): string
|
||||||
|
{
|
||||||
|
if ($count <= 1) {
|
||||||
|
return 'Es gibt neue Fotos!';
|
||||||
|
}
|
||||||
|
|
||||||
|
return sprintf('Es gibt %d neue Fotos!', $count);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function markUploaderRead(GuestNotification $notification, string $guestIdentifier): void
|
||||||
|
{
|
||||||
|
$guestIdentifier = trim($guestIdentifier);
|
||||||
|
if ($guestIdentifier === '' || $guestIdentifier === 'anonymous') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->notifications->markAsRead($notification, $guestIdentifier);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,9 +41,9 @@ class GuestPolicySetting extends Model
|
|||||||
'per_device_upload_limit' => 50,
|
'per_device_upload_limit' => 50,
|
||||||
'join_token_failure_limit' => (int) config('join_tokens.failure_limit', 10),
|
'join_token_failure_limit' => (int) config('join_tokens.failure_limit', 10),
|
||||||
'join_token_failure_decay_minutes' => (int) config('join_tokens.failure_decay_minutes', 5),
|
'join_token_failure_decay_minutes' => (int) config('join_tokens.failure_decay_minutes', 5),
|
||||||
'join_token_access_limit' => (int) config('join_tokens.access_limit', 120),
|
'join_token_access_limit' => (int) config('join_tokens.access_limit', 300),
|
||||||
'join_token_access_decay_minutes' => (int) config('join_tokens.access_decay_minutes', 1),
|
'join_token_access_decay_minutes' => (int) config('join_tokens.access_decay_minutes', 1),
|
||||||
'join_token_download_limit' => (int) config('join_tokens.download_limit', 60),
|
'join_token_download_limit' => (int) config('join_tokens.download_limit', 120),
|
||||||
'join_token_download_decay_minutes' => (int) config('join_tokens.download_decay_minutes', 1),
|
'join_token_download_decay_minutes' => (int) config('join_tokens.download_decay_minutes', 1),
|
||||||
'join_token_ttl_hours' => 168,
|
'join_token_ttl_hours' => 168,
|
||||||
'share_link_ttl_hours' => (int) config('share-links.ttl_hours', 48),
|
'share_link_ttl_hours' => (int) config('share-links.ttl_hours', 48),
|
||||||
|
|||||||
25
app/Models/PhotoboothConnectCode.php
Normal file
25
app/Models/PhotoboothConnectCode.php
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class PhotoboothConnectCode extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<\Database\Factories\PhotoboothConnectCodeFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $guarded = [];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'expires_at' => 'datetime',
|
||||||
|
'redeemed_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function event(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Event::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,6 +69,16 @@ class User extends Authenticatable implements FilamentHasTenants, FilamentUser,
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function isSuperAdmin(): bool
|
||||||
|
{
|
||||||
|
return self::isSuperAdminRole($this->role);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function isSuperAdminRole(?string $role): bool
|
||||||
|
{
|
||||||
|
return in_array($role, ['super_admin', 'superadmin'], true);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieve the user by the given credentials.
|
* Retrieve the user by the given credentials.
|
||||||
*/
|
*/
|
||||||
@@ -127,12 +137,12 @@ class User extends Authenticatable implements FilamentHasTenants, FilamentUser,
|
|||||||
|
|
||||||
public function canAccessPanel(Panel $panel): bool
|
public function canAccessPanel(Panel $panel): bool
|
||||||
{
|
{
|
||||||
if (! $this->email_verified_at && $this->role !== 'super_admin') {
|
if (! $this->email_verified_at && ! $this->isSuperAdmin()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return match ($panel->getId()) {
|
return match ($panel->getId()) {
|
||||||
'superadmin' => $this->role === 'super_admin',
|
'superadmin' => $this->isSuperAdmin(),
|
||||||
'admin' => $this->role === 'tenant_admin',
|
'admin' => $this->role === 'tenant_admin',
|
||||||
default => false,
|
default => false,
|
||||||
};
|
};
|
||||||
@@ -140,7 +150,7 @@ class User extends Authenticatable implements FilamentHasTenants, FilamentUser,
|
|||||||
|
|
||||||
public function canAccessTenant(Model $tenant): bool
|
public function canAccessTenant(Model $tenant): bool
|
||||||
{
|
{
|
||||||
if ($this->role === 'super_admin') {
|
if ($this->isSuperAdmin()) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,7 +165,7 @@ class User extends Authenticatable implements FilamentHasTenants, FilamentUser,
|
|||||||
|
|
||||||
public function getTenants(Panel $panel): array|Collection
|
public function getTenants(Panel $panel): array|Collection
|
||||||
{
|
{
|
||||||
if ($this->role === 'super_admin') {
|
if ($this->isSuperAdmin()) {
|
||||||
return Tenant::query()->orderBy('name')->get();
|
return Tenant::query()->orderBy('name')->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,12 +12,11 @@ class PurchaseHistoryPolicy
|
|||||||
|
|
||||||
public function viewAny(User $user): bool
|
public function viewAny(User $user): bool
|
||||||
{
|
{
|
||||||
return $user->role === 'super_admin';
|
return $user->isSuperAdmin();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function view(User $user, PurchaseHistory $purchaseHistory): bool
|
public function view(User $user, PurchaseHistory $purchaseHistory): bool
|
||||||
{
|
{
|
||||||
return $user->role === 'super_admin';
|
return $user->isSuperAdmin();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class TenantPolicy
|
|||||||
*/
|
*/
|
||||||
public function viewAny(User $user): bool
|
public function viewAny(User $user): bool
|
||||||
{
|
{
|
||||||
return $user->role === 'super_admin';
|
return $user->isSuperAdmin();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -35,7 +35,7 @@ class TenantPolicy
|
|||||||
*/
|
*/
|
||||||
public function create(User $user): bool
|
public function create(User $user): bool
|
||||||
{
|
{
|
||||||
return $user->role === 'super_admin';
|
return $user->isSuperAdmin();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -43,7 +43,7 @@ class TenantPolicy
|
|||||||
*/
|
*/
|
||||||
public function update(User $user, Tenant $tenant): bool
|
public function update(User $user, Tenant $tenant): bool
|
||||||
{
|
{
|
||||||
return $user->role === 'super_admin';
|
return $user->isSuperAdmin();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -51,7 +51,7 @@ class TenantPolicy
|
|||||||
*/
|
*/
|
||||||
public function delete(User $user, Tenant $tenant): bool
|
public function delete(User $user, Tenant $tenant): bool
|
||||||
{
|
{
|
||||||
return $user->role === 'super_admin';
|
return $user->isSuperAdmin();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -59,6 +59,6 @@ class TenantPolicy
|
|||||||
*/
|
*/
|
||||||
public function suspend(User $user, Tenant $tenant): bool
|
public function suspend(User $user, Tenant $tenant): bool
|
||||||
{
|
{
|
||||||
return $user->role === 'super_admin';
|
return $user->isSuperAdmin();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,7 +155,15 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
|
|
||||||
$key = $tenantId ? 'tenant:'.$tenantId : ('ip:'.($request->ip() ?? 'unknown'));
|
$key = $tenantId ? 'tenant:'.$tenantId : ('ip:'.($request->ip() ?? 'unknown'));
|
||||||
|
|
||||||
return Limit::perMinute(100)->by($key);
|
return Limit::perMinute(600)->by($key);
|
||||||
|
});
|
||||||
|
|
||||||
|
RateLimiter::for('guest-api', function (Request $request) {
|
||||||
|
return Limit::perMinute(300)->by('guest-api:'.($request->ip() ?? 'unknown'));
|
||||||
|
});
|
||||||
|
|
||||||
|
RateLimiter::for('photobooth-connect', function (Request $request) {
|
||||||
|
return Limit::perMinute(30)->by('photobooth-connect:'.($request->ip() ?? 'unknown'));
|
||||||
});
|
});
|
||||||
|
|
||||||
RateLimiter::for('tenant-auth', function (Request $request) {
|
RateLimiter::for('tenant-auth', function (Request $request) {
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ class AuthServiceProvider extends ServiceProvider
|
|||||||
});
|
});
|
||||||
|
|
||||||
Gate::before(function (User $user): ?bool {
|
Gate::before(function (User $user): ?bool {
|
||||||
return $user->role === 'super_admin' ? true : null;
|
return $user->isSuperAdmin() ? true : null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ class SuperAdminAuditLogger
|
|||||||
|
|
||||||
private function shouldLog(?User $actor): bool
|
private function shouldLog(?User $actor): bool
|
||||||
{
|
{
|
||||||
if (! $actor || $actor->role !== 'super_admin') {
|
if (! $actor || ! $actor->isSuperAdmin()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -126,6 +126,36 @@ class GuestNotificationService
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$photoId = Arr::get($payload, 'photo_id');
|
||||||
|
if (is_numeric($photoId)) {
|
||||||
|
$photoId = max(1, (int) $photoId);
|
||||||
|
} else {
|
||||||
|
$photoId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$photoIds = Arr::get($payload, 'photo_ids');
|
||||||
|
if (is_array($photoIds)) {
|
||||||
|
$photoIds = array_values(array_unique(array_filter(array_map(function ($value) {
|
||||||
|
if (! is_numeric($value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$int = (int) $value;
|
||||||
|
|
||||||
|
return $int > 0 ? $int : null;
|
||||||
|
}, $photoIds))));
|
||||||
|
$photoIds = array_slice($photoIds, 0, 10);
|
||||||
|
} else {
|
||||||
|
$photoIds = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$count = Arr::get($payload, 'count');
|
||||||
|
if (is_numeric($count)) {
|
||||||
|
$count = max(1, min(9999, (int) $count));
|
||||||
|
} else {
|
||||||
|
$count = null;
|
||||||
|
}
|
||||||
|
|
||||||
$cta = Arr::get($payload, 'cta');
|
$cta = Arr::get($payload, 'cta');
|
||||||
if (is_array($cta)) {
|
if (is_array($cta)) {
|
||||||
$cta = [
|
$cta = [
|
||||||
@@ -142,6 +172,9 @@ class GuestNotificationService
|
|||||||
|
|
||||||
$clean = array_filter([
|
$clean = array_filter([
|
||||||
'cta' => $cta,
|
'cta' => $cta,
|
||||||
|
'photo_id' => $photoId,
|
||||||
|
'photo_ids' => $photoIds,
|
||||||
|
'count' => $count,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return $clean === [] ? null : $clean;
|
return $clean === [] ? null : $clean;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ class PaddleDiscountService
|
|||||||
*/
|
*/
|
||||||
public function createDiscount(Coupon $coupon): array
|
public function createDiscount(Coupon $coupon): array
|
||||||
{
|
{
|
||||||
|
$existing = $this->findExistingDiscount($coupon->code);
|
||||||
|
if ($existing !== null) {
|
||||||
|
return $existing;
|
||||||
|
}
|
||||||
|
|
||||||
$payload = $this->buildDiscountPayload($coupon);
|
$payload = $this->buildDiscountPayload($coupon);
|
||||||
|
|
||||||
$response = $this->client->post('/discounts', $payload);
|
$response = $this->client->post('/discounts', $payload);
|
||||||
@@ -82,6 +87,35 @@ class PaddleDiscountService
|
|||||||
return Arr::get($response, 'data', $response);
|
return Arr::get($response, 'data', $response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
protected function findExistingDiscount(?string $code): ?array
|
||||||
|
{
|
||||||
|
$normalized = Str::upper(trim((string) $code));
|
||||||
|
if ($normalized === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $this->client->get('/discounts', [
|
||||||
|
'code' => $normalized,
|
||||||
|
'per_page' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$items = Arr::get($response, 'data', []);
|
||||||
|
if (! is_array($items) || $items === []) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$match = Collection::make($items)->first(static function ($item) use ($normalized) {
|
||||||
|
$codeValue = Str::upper((string) Arr::get($item, 'code', ''));
|
||||||
|
|
||||||
|
return $codeValue === $normalized ? $item : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return is_array($match) ? $match : null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
|
|||||||
80
app/Services/Photobooth/PhotoboothConnectCodeService.php
Normal file
80
app/Services/Photobooth/PhotoboothConnectCodeService.php
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Photobooth;
|
||||||
|
|
||||||
|
use App\Models\Event;
|
||||||
|
use App\Models\PhotoboothConnectCode;
|
||||||
|
|
||||||
|
class PhotoboothConnectCodeService
|
||||||
|
{
|
||||||
|
public function create(Event $event, ?int $expiresInMinutes = null): array
|
||||||
|
{
|
||||||
|
$length = (int) config('photobooth.connect_code.length', 6);
|
||||||
|
$length = max(4, min(8, $length));
|
||||||
|
|
||||||
|
$expiresInMinutes = $expiresInMinutes ?: (int) config('photobooth.connect_code.expires_minutes', 10);
|
||||||
|
$expiresInMinutes = max(1, min(120, $expiresInMinutes));
|
||||||
|
|
||||||
|
$code = null;
|
||||||
|
$hash = null;
|
||||||
|
$max = (10 ** $length) - 1;
|
||||||
|
|
||||||
|
for ($attempts = 0; $attempts < 5; $attempts++) {
|
||||||
|
$candidate = str_pad((string) random_int(0, $max), $length, '0', STR_PAD_LEFT);
|
||||||
|
$candidateHash = hash('sha256', $candidate);
|
||||||
|
|
||||||
|
$exists = PhotoboothConnectCode::query()
|
||||||
|
->where('code_hash', $candidateHash)
|
||||||
|
->whereNull('redeemed_at')
|
||||||
|
->where('expires_at', '>=', now())
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if (! $exists) {
|
||||||
|
$code = $candidate;
|
||||||
|
$hash = $candidateHash;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $code || ! $hash) {
|
||||||
|
$code = str_pad((string) random_int(0, $max), $length, '0', STR_PAD_LEFT);
|
||||||
|
$hash = hash('sha256', $code);
|
||||||
|
}
|
||||||
|
|
||||||
|
$expiresAt = now()->addMinutes($expiresInMinutes);
|
||||||
|
|
||||||
|
$record = PhotoboothConnectCode::query()->create([
|
||||||
|
'event_id' => $event->getKey(),
|
||||||
|
'code_hash' => $hash,
|
||||||
|
'expires_at' => $expiresAt,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'code' => $code,
|
||||||
|
'record' => $record,
|
||||||
|
'expires_at' => $expiresAt,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function redeem(string $code): ?PhotoboothConnectCode
|
||||||
|
{
|
||||||
|
$hash = hash('sha256', $code);
|
||||||
|
|
||||||
|
/** @var PhotoboothConnectCode|null $record */
|
||||||
|
$record = PhotoboothConnectCode::query()
|
||||||
|
->where('code_hash', $hash)
|
||||||
|
->whereNull('redeemed_at')
|
||||||
|
->where('expires_at', '>=', now())
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $record) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$record->forceFill([
|
||||||
|
'redeemed_at' => now(),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
return $record;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,15 +24,15 @@ class TenantAuth
|
|||||||
}
|
}
|
||||||
|
|
||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
if ($user && in_array($user->role, ['tenant_admin', 'admin', 'super_admin', 'member'], true)) {
|
if ($user && in_array($user->role, ['tenant_admin', 'admin', 'super_admin', 'superadmin', 'member'], true)) {
|
||||||
if ($user->role !== 'super_admin' || (int) $user->tenant_id === (int) $tenantId) {
|
if (! $user->isSuperAdmin() || (int) $user->tenant_id === (int) $tenantId) {
|
||||||
return $user;
|
return $user;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$user = User::query()
|
$user = User::query()
|
||||||
->where('tenant_id', $tenantId)
|
->where('tenant_id', $tenantId)
|
||||||
->whereIn('role', ['tenant_admin', 'admin', 'member'])
|
->whereIn('role', ['tenant_admin', 'admin', 'super_admin', 'superadmin', 'member'])
|
||||||
->orderByDesc('email_verified_at')
|
->orderByDesc('email_verified_at')
|
||||||
->orderBy('id')
|
->orderBy('id')
|
||||||
->first();
|
->first();
|
||||||
|
|||||||
90
clients/photobooth-uploader/PhotoboothUploader/App.axaml
Normal file
90
clients/photobooth-uploader/PhotoboothUploader/App.axaml
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<Application xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
x:Class="PhotoboothUploader.App"
|
||||||
|
RequestedThemeVariant="Default">
|
||||||
|
<!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. -->
|
||||||
|
|
||||||
|
<Application.Styles>
|
||||||
|
<FluentTheme />
|
||||||
|
<Style>
|
||||||
|
<Style.Resources>
|
||||||
|
<Color x:Key="BrandRose">#FFB6C1</Color>
|
||||||
|
<Color x:Key="BrandRoseStrong">#FF69B4</Color>
|
||||||
|
<Color x:Key="BrandRoseSoft">#FFE5EC</Color>
|
||||||
|
<Color x:Key="BrandGold">#FFD700</Color>
|
||||||
|
<Color x:Key="BrandSky">#87CEEB</Color>
|
||||||
|
<Color x:Key="BrandSkySoft">#E0F5FF</Color>
|
||||||
|
<Color x:Key="BrandNavy">#0F4C75</Color>
|
||||||
|
<Color x:Key="BrandSlate">#1F2937</Color>
|
||||||
|
<Color x:Key="BrandCream">#FFF8F5</Color>
|
||||||
|
|
||||||
|
<SolidColorBrush x:Key="TextPrimaryBrush" Color="{DynamicResource BrandSlate}" />
|
||||||
|
<SolidColorBrush x:Key="TextMutedBrush" Color="#6B7280" />
|
||||||
|
<SolidColorBrush x:Key="CardBorderBrush" Color="{DynamicResource BrandRoseSoft}" />
|
||||||
|
<SolidColorBrush x:Key="CardBackgroundBrush" Color="#FFFFFF" />
|
||||||
|
<SolidColorBrush x:Key="AccentBackgroundBrush" Color="{DynamicResource BrandSkySoft}" />
|
||||||
|
<SolidColorBrush x:Key="InputBorderBrush" Color="{DynamicResource BrandRoseSoft}" />
|
||||||
|
<SolidColorBrush x:Key="InputBackgroundBrush" Color="#FFFFFF" />
|
||||||
|
<SolidColorBrush x:Key="PrimaryButtonBrush" Color="{DynamicResource BrandRoseStrong}" />
|
||||||
|
<SolidColorBrush x:Key="PrimaryButtonTextBrush" Color="#FFFFFF" />
|
||||||
|
<SolidColorBrush x:Key="SecondaryButtonBrush" Color="{DynamicResource BrandSky}" />
|
||||||
|
<SolidColorBrush x:Key="SecondaryButtonTextBrush" Color="{DynamicResource BrandNavy}" />
|
||||||
|
|
||||||
|
<LinearGradientBrush x:Key="WindowBackgroundBrush" StartPoint="0,0" EndPoint="1,1">
|
||||||
|
<GradientStop Color="{DynamicResource BrandCream}" Offset="0" />
|
||||||
|
<GradientStop Color="{DynamicResource BrandRoseSoft}" Offset="0.5" />
|
||||||
|
<GradientStop Color="{DynamicResource BrandSkySoft}" Offset="1" />
|
||||||
|
</LinearGradientBrush>
|
||||||
|
</Style.Resources>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Window">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource WindowBackgroundBrush}" />
|
||||||
|
<Setter Property="FontFamily" Value="Inter" />
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="TextBlock.title">
|
||||||
|
<Setter Property="FontSize" Value="20" />
|
||||||
|
<Setter Property="FontWeight" Value="SemiBold" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="TextBlock.subtitle">
|
||||||
|
<Setter Property="FontSize" Value="12" />
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource TextMutedBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Border.card">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource CardBackgroundBrush}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource CardBorderBrush}" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
<Setter Property="CornerRadius" Value="12" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Border.card.accent">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource AccentBackgroundBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="TextBox">
|
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource InputBorderBrush}" />
|
||||||
|
<Setter Property="Background" Value="{DynamicResource InputBackgroundBrush}" />
|
||||||
|
<Setter Property="CornerRadius" Value="8" />
|
||||||
|
<Setter Property="Padding" Value="10,8" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Button">
|
||||||
|
<Setter Property="CornerRadius" Value="8" />
|
||||||
|
<Setter Property="Padding" Value="12,8" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Button.primary">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource PrimaryButtonBrush}" />
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource PrimaryButtonTextBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Button.secondary">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource SecondaryButtonBrush}" />
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource SecondaryButtonTextBrush}" />
|
||||||
|
</Style>
|
||||||
|
</Application.Styles>
|
||||||
|
</Application>
|
||||||
23
clients/photobooth-uploader/PhotoboothUploader/App.axaml.cs
Normal file
23
clients/photobooth-uploader/PhotoboothUploader/App.axaml.cs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls.ApplicationLifetimes;
|
||||||
|
using Avalonia.Markup.Xaml;
|
||||||
|
|
||||||
|
namespace PhotoboothUploader;
|
||||||
|
|
||||||
|
public partial class App : Application
|
||||||
|
{
|
||||||
|
public override void Initialize()
|
||||||
|
{
|
||||||
|
AvaloniaXamlLoader.Load(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void OnFrameworkInitializationCompleted()
|
||||||
|
{
|
||||||
|
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
|
{
|
||||||
|
desktop.MainWindow = new MainWindow();
|
||||||
|
}
|
||||||
|
|
||||||
|
base.OnFrameworkInitializationCompleted();
|
||||||
|
}
|
||||||
|
}
|
||||||
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 |
160
clients/photobooth-uploader/PhotoboothUploader/MainWindow.axaml
Normal file
160
clients/photobooth-uploader/PhotoboothUploader/MainWindow.axaml
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
mc:Ignorable="d" d:DesignWidth="520" d:DesignHeight="360"
|
||||||
|
x:Class="PhotoboothUploader.MainWindow"
|
||||||
|
Width="560" Height="420"
|
||||||
|
MinWidth="520" MinHeight="400"
|
||||||
|
Title="Die Fotospiel.App - Photobooth Uploader">
|
||||||
|
<Grid Margin="24,32,24,24" RowDefinitions="Auto,*">
|
||||||
|
<StackPanel Grid.Row="0" Orientation="Horizontal" Spacing="12" VerticalAlignment="Center">
|
||||||
|
<Border Width="40" Height="40" Classes="card accent" VerticalAlignment="Center" HorizontalAlignment="Left">
|
||||||
|
<Image Source="avares://PhotoboothUploader/Assets/logo.png" Width="28" Height="28" HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||||
|
</Border>
|
||||||
|
<StackPanel Spacing="2">
|
||||||
|
<TextBlock x:Name="TitleText"
|
||||||
|
Text="Die Fotospiel.App - Photobooth Uploader"
|
||||||
|
Classes="title"
|
||||||
|
PointerPressed="TitleText_PointerPressed" />
|
||||||
|
<TextBlock Text="Sicherer Upload der Fotobox-Fotos ins Event." Classes="subtitle" />
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<Grid Grid.Row="1" ColumnDefinitions="*,16,*">
|
||||||
|
<StackPanel Grid.Column="0" Spacing="16" MaxWidth="420">
|
||||||
|
|
||||||
|
<Border Padding="14" Classes="card">
|
||||||
|
<StackPanel Spacing="6">
|
||||||
|
<TextBlock Text="Schritte" FontWeight="SemiBold" />
|
||||||
|
<TextBlock x:Name="StepCodeText" Text="1. Code eingeben" />
|
||||||
|
<TextBlock x:Name="StepFolderText" Text="2. Upload-Ordner wählen" />
|
||||||
|
<TextBlock x:Name="StepReadyText" Text="3. Upload läuft" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border Padding="14" Classes="card">
|
||||||
|
<StackPanel Spacing="10">
|
||||||
|
<TextBlock Text="Verbindungscode" FontWeight="SemiBold" />
|
||||||
|
<TextBlock Text="Gib den 6-stelligen Verbindungscode ein." TextWrapping="Wrap" Classes="subtitle" />
|
||||||
|
<TextBox x:Name="CodeBox" MaxLength="6" Watermark="123456" TextChanged="CodeBox_TextChanged" />
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
|
<Button x:Name="ConnectButton" Content="Verbinden" Click="ConnectButton_Click" Classes="primary" />
|
||||||
|
<Button x:Name="ReconnectButton" Content="Erneut verbinden" Click="ReconnectButton_Click" IsEnabled="False" Classes="secondary" />
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border Padding="14" Classes="card">
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
<TextBlock Text="Upload-Ordner" FontWeight="SemiBold" />
|
||||||
|
<TextBlock x:Name="FolderText" Text="Noch nicht ausgewählt." TextWrapping="Wrap" Classes="subtitle" />
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
|
<Button x:Name="DslrBoothPresetButton" Content="DSLrBooth" Click="DslrBoothPresetButton_Click" Classes="secondary" IsVisible="False" />
|
||||||
|
<Button x:Name="SparkboothPresetButton" Content="Sparkbooth" Click="SparkboothPresetButton_Click" Classes="secondary" IsVisible="False" />
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
|
<Button x:Name="PickFolderButton" Content="Ordner auswählen" Click="PickFolderButton_Click" IsEnabled="False" Classes="primary" />
|
||||||
|
<Button x:Name="TestUploadButton" Content="Test-Upload senden" Click="TestUploadButton_Click" IsEnabled="False" Classes="secondary" />
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<ToggleSwitch x:Name="QuietToggle" Content="Ruhiger Modus (nur Fehler anzeigen)" />
|
||||||
|
|
||||||
|
<Border x:Name="AdvancedPanel" Padding="12" Classes="card accent" IsVisible="False">
|
||||||
|
<StackPanel Spacing="6">
|
||||||
|
<TextBlock Text="Erweiterte Einstellungen" FontWeight="SemiBold" />
|
||||||
|
<ToggleSwitch x:Name="SettingsUnlockToggle" Content="Einstellungen entsperren" Checked="SettingsUnlockToggle_Changed" Unchecked="SettingsUnlockToggle_Changed" />
|
||||||
|
<TextBlock Text="Profile" />
|
||||||
|
<ComboBox x:Name="ProfilesBox" />
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
|
<Button x:Name="LoadProfileButton" Content="Profil laden" Click="LoadProfileButton_Click" Classes="secondary" />
|
||||||
|
<Button x:Name="SaveProfileButton" Content="Profil speichern" Click="SaveProfileButton_Click" Classes="secondary" />
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock Text="Basis-URL" />
|
||||||
|
<TextBox x:Name="BaseUrlBox" Watermark="https://fotospiel.app" />
|
||||||
|
<TextBlock Text="Max. parallele Uploads" />
|
||||||
|
<TextBox x:Name="MaxUploadsBox" Watermark="2" />
|
||||||
|
<TextBlock Text="Upload-Tempo" />
|
||||||
|
<ComboBox x:Name="UploadTempoBox" SelectedIndex="1">
|
||||||
|
<ComboBoxItem Content="Schnell (ohne Pause)" />
|
||||||
|
<ComboBoxItem Content="Normal" />
|
||||||
|
<ComboBoxItem Content="Sanft (schont Netzwerk)" />
|
||||||
|
</ComboBox>
|
||||||
|
<TextBlock Text="Nur diese Dateien (optional)" />
|
||||||
|
<TextBox x:Name="IncludePatternsBox" Watermark="*.jpg;*.jpeg;*.png" />
|
||||||
|
<TextBlock Text="Dateien ausschliessen (optional)" />
|
||||||
|
<TextBox x:Name="ExcludePatternsBox" Watermark="*_preview*;*.tmp" />
|
||||||
|
<TextBlock Text="Antwort-Format (optional)" />
|
||||||
|
<ComboBox x:Name="ResponseFormatBox" SelectedIndex="0">
|
||||||
|
<ComboBoxItem Content="Auto" />
|
||||||
|
<ComboBoxItem Content="JSON" />
|
||||||
|
<ComboBoxItem Content="XML" />
|
||||||
|
</ComboBox>
|
||||||
|
<TextBlock Text="Manuelle Zugangsdaten (optional)" FontWeight="SemiBold" Margin="0,8,0,0" />
|
||||||
|
<TextBlock Text="Diese Felder ueberschreiben den Verbindungscode." Classes="subtitle" TextWrapping="Wrap" />
|
||||||
|
<TextBlock Text="Upload-URL" />
|
||||||
|
<TextBox x:Name="ManualUploadUrlBox" Watermark="https://fotospiel.app/api/v1/photobooth/upload" />
|
||||||
|
<TextBlock Text="Benutzername" />
|
||||||
|
<TextBox x:Name="ManualUsernameBox" />
|
||||||
|
<TextBlock Text="Passwort" />
|
||||||
|
<TextBox x:Name="ManualPasswordBox" PasswordChar="•" />
|
||||||
|
<Button x:Name="TestConnectionButton" Content="Verbindung testen" Click="TestConnectionButton_Click" Classes="secondary" />
|
||||||
|
<Button x:Name="SaveAdvancedButton" Content="Speichern" Click="SaveAdvancedButton_Click" Classes="primary" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Grid.Column="2" Spacing="16" MaxWidth="380" Margin="0,6,0,0">
|
||||||
|
<Border Padding="14" Classes="card accent">
|
||||||
|
<StackPanel Spacing="6">
|
||||||
|
<TextBlock Text="Status" FontWeight="SemiBold" />
|
||||||
|
<TextBlock x:Name="StatusText" Text="Nicht verbunden." TextWrapping="Wrap" />
|
||||||
|
<TextBlock x:Name="LastUploadText" Text="Letzter Upload: —" />
|
||||||
|
<TextBlock x:Name="QueueStatusText" Text="Warteschlange: 0 · Läuft: 0 · Fehlgeschlagen: 0" />
|
||||||
|
<TextBlock x:Name="LiveStatusText" Text="Live: —" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border Padding="14" Classes="card">
|
||||||
|
<StackPanel Spacing="6">
|
||||||
|
<TextBlock Text="Details" FontWeight="SemiBold" />
|
||||||
|
<TextBlock x:Name="EventNameText" Text="Event: —" TextWrapping="Wrap" />
|
||||||
|
<TextBlock x:Name="BaseUrlText" Text="Basis-URL: —" TextWrapping="Wrap" />
|
||||||
|
<TextBlock x:Name="VersionText" Text="App-Version: —" />
|
||||||
|
<TextBlock x:Name="ConnectExpiryText" Text="Verbindungscode: —" TextWrapping="Wrap" />
|
||||||
|
<TextBlock x:Name="FolderHealthText" Text="Ordner: —" TextWrapping="Wrap" />
|
||||||
|
<TextBlock x:Name="DiskFreeText" Text="Freier Speicher: —" TextWrapping="Wrap" />
|
||||||
|
<TextBlock x:Name="LastSeenText" Text="Letzte Datei: —" TextWrapping="Wrap" />
|
||||||
|
<TextBlock x:Name="LastErrorText" Text="Letzter Fehler: —" TextWrapping="Wrap" />
|
||||||
|
<Button x:Name="LogCopyButton" Content="Log kopieren" Click="LogCopyButton_Click" Classes="secondary" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border Padding="14" Classes="card">
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
<TextBlock Text="Letzte Uploads" FontWeight="SemiBold" />
|
||||||
|
<ItemsControl x:Name="RecentUploadsList" ItemsSource="{Binding RecentUploads}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<Border Background="#14FFFFFF" Padding="10" CornerRadius="8" Margin="0,0,0,8">
|
||||||
|
<Grid ColumnDefinitions="*,Auto" RowDefinitions="Auto,Auto">
|
||||||
|
<TextBlock Grid.Column="0" Grid.Row="0" Text="{Binding FileName}" />
|
||||||
|
<TextBlock Grid.Column="1" Grid.Row="0" Text="{Binding StatusLabel}" />
|
||||||
|
<TextBlock Grid.Column="0" Grid.Row="1" Text="{Binding UpdatedLabel}" Opacity="0.7" FontSize="11" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
|
<Button x:Name="RetryFailedButton" Content="Fehlgeschlagene erneut senden" Click="RetryFailedButton_Click" IsEnabled="False" Classes="secondary" />
|
||||||
|
<Button x:Name="ClearFailedButton" Content="Fehlerliste leeren" Click="ClearFailedButton_Click" IsEnabled="False" Classes="secondary" />
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
1350
clients/photobooth-uploader/PhotoboothUploader/MainWindow.axaml.cs
Normal file
1350
clients/photobooth-uploader/PhotoboothUploader/MainWindow.axaml.cs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace PhotoboothUploader.Models;
|
||||||
|
|
||||||
|
public sealed class PhotoboothConnectResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("data")]
|
||||||
|
public PhotoboothConnectPayload? Data { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("message")]
|
||||||
|
public string? Message { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class PhotoboothConnectPayload
|
||||||
|
{
|
||||||
|
[JsonPropertyName("event_name")]
|
||||||
|
public string? EventName { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("upload_url")]
|
||||||
|
public string? UploadUrl { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("username")]
|
||||||
|
public string? Username { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("password")]
|
||||||
|
public string? Password { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("expires_at")]
|
||||||
|
public string? ExpiresAt { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("response_format")]
|
||||||
|
public string? ResponseFormat { get; set; }
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace PhotoboothUploader.Models;
|
||||||
|
|
||||||
|
public sealed class PhotoboothSettings
|
||||||
|
{
|
||||||
|
public string? BaseUrl { get; set; }
|
||||||
|
public string? EventName { get; set; }
|
||||||
|
public string? UploadUrl { get; set; }
|
||||||
|
public string? Username { get; set; }
|
||||||
|
public string? Password { get; set; }
|
||||||
|
public string? ResponseFormat { get; set; }
|
||||||
|
public string? WatchFolder { get; set; }
|
||||||
|
public string? IncludePatterns { get; set; }
|
||||||
|
public string? ExcludePatterns { get; set; }
|
||||||
|
public List<string> PendingUploads { get; set; } = new();
|
||||||
|
public Dictionary<string, string> UploadedFiles { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
public List<PhotoboothProfile> Profiles { get; set; } = new();
|
||||||
|
public string? ConnectExpiresAt { get; set; }
|
||||||
|
public string? LastSeenFile { get; set; }
|
||||||
|
public string? LastSeenAt { get; set; }
|
||||||
|
public string? LastError { get; set; }
|
||||||
|
public string? LastErrorAt { get; set; }
|
||||||
|
public int MaxConcurrentUploads { get; set; } = 2;
|
||||||
|
public int UploadDelayMs { get; set; } = 500;
|
||||||
|
public double WindowWidth { get; set; }
|
||||||
|
public double WindowHeight { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
using System;
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
namespace PhotoboothUploader.Models;
|
||||||
|
|
||||||
|
public enum UploadStatus
|
||||||
|
{
|
||||||
|
Queued,
|
||||||
|
Uploading,
|
||||||
|
Success,
|
||||||
|
Failed,
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class UploadItem : INotifyPropertyChanged
|
||||||
|
{
|
||||||
|
private UploadStatus _status;
|
||||||
|
private DateTimeOffset _updatedAt;
|
||||||
|
|
||||||
|
public UploadItem(string path)
|
||||||
|
{
|
||||||
|
Path = path;
|
||||||
|
FileName = System.IO.Path.GetFileName(path);
|
||||||
|
UpdatedAt = DateTimeOffset.Now;
|
||||||
|
Status = UploadStatus.Queued;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Path { get; }
|
||||||
|
|
||||||
|
public string FileName { get; }
|
||||||
|
|
||||||
|
public UploadStatus Status
|
||||||
|
{
|
||||||
|
get => _status;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (_status != value)
|
||||||
|
{
|
||||||
|
_status = value;
|
||||||
|
UpdatedAt = DateTimeOffset.Now;
|
||||||
|
OnPropertyChanged();
|
||||||
|
OnPropertyChanged(nameof(StatusLabel));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public DateTimeOffset UpdatedAt
|
||||||
|
{
|
||||||
|
get => _updatedAt;
|
||||||
|
private set
|
||||||
|
{
|
||||||
|
_updatedAt = value;
|
||||||
|
OnPropertyChanged();
|
||||||
|
OnPropertyChanged(nameof(UpdatedLabel));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string StatusLabel => Status switch
|
||||||
|
{
|
||||||
|
UploadStatus.Uploading => "Upload läuft",
|
||||||
|
UploadStatus.Success => "Fertig",
|
||||||
|
UploadStatus.Failed => "Fehlgeschlagen",
|
||||||
|
_ => "Wartet",
|
||||||
|
};
|
||||||
|
|
||||||
|
public string UpdatedLabel => $"{UpdatedAt:HH:mm}";
|
||||||
|
|
||||||
|
public event PropertyChangedEventHandler? PropertyChanged;
|
||||||
|
|
||||||
|
private void OnPropertyChanged([CallerMemberName] string? propertyName = null)
|
||||||
|
{
|
||||||
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>WinExe</OutputType>
|
||||||
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
|
<ApplicationIcon>Assets\app.ico</ApplicationIcon>
|
||||||
|
<AvaloniaUseCompiledBindingsByDefault>false</AvaloniaUseCompiledBindingsByDefault>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Avalonia" Version="11.3.10" />
|
||||||
|
<PackageReference Include="Avalonia.Desktop" Version="11.3.10" />
|
||||||
|
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.10" />
|
||||||
|
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.10" />
|
||||||
|
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
|
||||||
|
<PackageReference Include="Avalonia.Diagnostics" Version="11.3.10">
|
||||||
|
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
|
||||||
|
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Include="Assets\app.ico" />
|
||||||
|
<AvaloniaResource Include="Assets\logo.png" />
|
||||||
|
<AvaloniaResource Include="Assets\sample-upload.png" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
21
clients/photobooth-uploader/PhotoboothUploader/Program.cs
Normal file
21
clients/photobooth-uploader/PhotoboothUploader/Program.cs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
using Avalonia;
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace PhotoboothUploader;
|
||||||
|
|
||||||
|
class Program
|
||||||
|
{
|
||||||
|
// Initialization code. Don't use any Avalonia, third-party APIs or any
|
||||||
|
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
|
||||||
|
// yet and stuff might break.
|
||||||
|
[STAThread]
|
||||||
|
public static void Main(string[] args) => BuildAvaloniaApp()
|
||||||
|
.StartWithClassicDesktopLifetime(args);
|
||||||
|
|
||||||
|
// Avalonia configuration, don't remove; also used by visual designer.
|
||||||
|
public static AppBuilder BuildAvaloniaApp()
|
||||||
|
=> AppBuilder.Configure<App>()
|
||||||
|
.UsePlatformDetect()
|
||||||
|
.WithInterFont()
|
||||||
|
.LogToTrace();
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
using System;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using PhotoboothUploader.Models;
|
||||||
|
|
||||||
|
namespace PhotoboothUploader.Services;
|
||||||
|
|
||||||
|
public sealed class PhotoboothConnectClient
|
||||||
|
{
|
||||||
|
private const int MaxRetries = 2;
|
||||||
|
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(10);
|
||||||
|
private readonly HttpClient _httpClient;
|
||||||
|
private readonly JsonSerializerOptions _jsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
public PhotoboothConnectClient(string baseUrl, string userAgent)
|
||||||
|
{
|
||||||
|
_httpClient = new HttpClient
|
||||||
|
{
|
||||||
|
BaseAddress = new Uri(baseUrl),
|
||||||
|
Timeout = DefaultTimeout,
|
||||||
|
};
|
||||||
|
|
||||||
|
_httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||||
|
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd(userAgent);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PhotoboothConnectResponse> RedeemAsync(string code, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var request = new { code };
|
||||||
|
|
||||||
|
for (var attempt = 0; attempt <= MaxRetries; attempt++)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var response = await _httpClient.PostAsJsonAsync("/api/v1/photobooth/connect", request, cancellationToken);
|
||||||
|
var payload = await ReadPayloadAsync(response, cancellationToken);
|
||||||
|
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
return payload ?? Fail(response.ReasonPhrase ?? "Verbindung fehlgeschlagen.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.StatusCode is HttpStatusCode.UnprocessableEntity or HttpStatusCode.Conflict or HttpStatusCode.Unauthorized)
|
||||||
|
{
|
||||||
|
return payload ?? Fail(response.ReasonPhrase ?? "Verbindung fehlgeschlagen.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt < MaxRetries && IsTransientStatus(response.StatusCode))
|
||||||
|
{
|
||||||
|
await Task.Delay(GetRetryDelay(attempt), cancellationToken);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload ?? Fail(response.ReasonPhrase ?? "Verbindung fehlgeschlagen.");
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
if (attempt < MaxRetries)
|
||||||
|
{
|
||||||
|
await Task.Delay(GetRetryDelay(attempt), cancellationToken);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Fail("Zeitüberschreitung bei der Verbindung.");
|
||||||
|
}
|
||||||
|
catch (HttpRequestException)
|
||||||
|
{
|
||||||
|
if (attempt < MaxRetries)
|
||||||
|
{
|
||||||
|
await Task.Delay(GetRetryDelay(attempt), cancellationToken);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Fail("Netzwerkfehler. Bitte Verbindung prüfen.");
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return Fail("Serverantwort konnte nicht gelesen werden.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Fail("Verbindung fehlgeschlagen.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<PhotoboothConnectResponse?> ReadPayloadAsync(HttpResponseMessage response, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (response.Content.Headers.ContentLength == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.Content.ReadFromJsonAsync<PhotoboothConnectResponse>(_jsonOptions, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsTransientStatus(HttpStatusCode statusCode)
|
||||||
|
{
|
||||||
|
return statusCode is HttpStatusCode.RequestTimeout or HttpStatusCode.TooManyRequests
|
||||||
|
or HttpStatusCode.BadGateway or HttpStatusCode.ServiceUnavailable or HttpStatusCode.GatewayTimeout
|
||||||
|
or HttpStatusCode.InternalServerError;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TimeSpan GetRetryDelay(int attempt)
|
||||||
|
{
|
||||||
|
return TimeSpan.FromMilliseconds(500 * (attempt + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PhotoboothConnectResponse Fail(string message)
|
||||||
|
{
|
||||||
|
return new PhotoboothConnectResponse
|
||||||
|
{
|
||||||
|
Message = message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text.Json;
|
||||||
|
using PhotoboothUploader.Models;
|
||||||
|
|
||||||
|
namespace PhotoboothUploader.Services;
|
||||||
|
|
||||||
|
public sealed class SettingsStore
|
||||||
|
{
|
||||||
|
private readonly JsonSerializerOptions _options = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
WriteIndented = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
public string SettingsPath { get; }
|
||||||
|
public string LogPath { get; }
|
||||||
|
|
||||||
|
public SettingsStore()
|
||||||
|
{
|
||||||
|
var basePath = Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
|
"Fotospiel",
|
||||||
|
"PhotoboothUploader");
|
||||||
|
|
||||||
|
Directory.CreateDirectory(basePath);
|
||||||
|
SettingsPath = Path.Combine(basePath, "settings.json");
|
||||||
|
LogPath = Path.Combine(basePath, "uploader.log");
|
||||||
|
}
|
||||||
|
|
||||||
|
public PhotoboothSettings Load()
|
||||||
|
{
|
||||||
|
if (!File.Exists(SettingsPath))
|
||||||
|
{
|
||||||
|
return new PhotoboothSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
var json = File.ReadAllText(SettingsPath);
|
||||||
|
return JsonSerializer.Deserialize<PhotoboothSettings>(json, _options) ?? new PhotoboothSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Save(PhotoboothSettings settings)
|
||||||
|
{
|
||||||
|
var json = JsonSerializer.Serialize(settings, _options);
|
||||||
|
File.WriteAllText(SettingsPath, json);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Channels;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using PhotoboothUploader.Models;
|
||||||
|
|
||||||
|
namespace PhotoboothUploader.Services;
|
||||||
|
|
||||||
|
public sealed class UploadService
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(20);
|
||||||
|
private static readonly TimeSpan RetryBaseDelay = TimeSpan.FromSeconds(2);
|
||||||
|
private const int MaxRetries = 2;
|
||||||
|
private readonly Channel<string> _queue = Channel.CreateUnbounded<string>();
|
||||||
|
private readonly ConcurrentDictionary<string, byte> _pending = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private string _userAgent = "FotospielPhotoboothUploader";
|
||||||
|
private CancellationTokenSource? _cts;
|
||||||
|
private readonly List<Task> _workers = new();
|
||||||
|
|
||||||
|
public void Configure(string userAgent)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(userAgent))
|
||||||
|
{
|
||||||
|
_userAgent = userAgent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Start(
|
||||||
|
PhotoboothSettings settings,
|
||||||
|
Action<string> onQueued,
|
||||||
|
Action<string> onUploading,
|
||||||
|
Action<string> onSuccess,
|
||||||
|
Action<string, string> onFailure)
|
||||||
|
{
|
||||||
|
Stop();
|
||||||
|
|
||||||
|
_cts = new CancellationTokenSource();
|
||||||
|
var workerCount = GetWorkerCount(settings);
|
||||||
|
for (var i = 0; i < workerCount; i++)
|
||||||
|
{
|
||||||
|
_workers.Add(Task.Run(() => WorkerAsync(settings, onQueued, onUploading, onSuccess, onFailure, _cts.Token)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Stop()
|
||||||
|
{
|
||||||
|
_cts?.Cancel();
|
||||||
|
_cts = null;
|
||||||
|
_pending.Clear();
|
||||||
|
_workers.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Enqueue(string path, Action<string> onQueued)
|
||||||
|
{
|
||||||
|
if (!_pending.TryAdd(path, 0))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_queue.Writer.TryWrite(path);
|
||||||
|
onQueued(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task WorkerAsync(
|
||||||
|
PhotoboothSettings settings,
|
||||||
|
Action<string> onQueued,
|
||||||
|
Action<string> onUploading,
|
||||||
|
Action<string> onSuccess,
|
||||||
|
Action<string, string> onFailure,
|
||||||
|
CancellationToken token)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(settings.UploadUrl))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var client = new HttpClient();
|
||||||
|
client.Timeout = DefaultTimeout;
|
||||||
|
client.DefaultRequestHeaders.UserAgent.ParseAdd(_userAgent);
|
||||||
|
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||||
|
|
||||||
|
while (await _queue.Reader.WaitToReadAsync(token))
|
||||||
|
{
|
||||||
|
while (_queue.Reader.TryRead(out var path))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
onUploading(path);
|
||||||
|
var error = await UploadWithRetryAsync(client, settings, path, token);
|
||||||
|
if (error is null)
|
||||||
|
{
|
||||||
|
onSuccess(path);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
onFailure(path, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_pending.TryRemove(path, out _);
|
||||||
|
if (settings.UploadDelayMs > 0)
|
||||||
|
{
|
||||||
|
await Task.Delay(settings.UploadDelayMs, token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string?> UploadWithRetryAsync(
|
||||||
|
HttpClient client,
|
||||||
|
PhotoboothSettings settings,
|
||||||
|
string path,
|
||||||
|
CancellationToken token)
|
||||||
|
{
|
||||||
|
for (var attempt = 0; attempt <= MaxRetries; attempt++)
|
||||||
|
{
|
||||||
|
var attemptError = await UploadOnceAsync(client, settings, path, token);
|
||||||
|
if (attemptError.Success)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!attemptError.Retryable || attempt >= MaxRetries)
|
||||||
|
{
|
||||||
|
return attemptError.Error ?? "Upload fehlgeschlagen.";
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(GetRetryDelay(attempt), token);
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Upload fehlgeschlagen.";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<UploadAttempt> UploadOnceAsync(
|
||||||
|
HttpClient client,
|
||||||
|
PhotoboothSettings settings,
|
||||||
|
string path,
|
||||||
|
CancellationToken token)
|
||||||
|
{
|
||||||
|
var readyError = await WaitForFileReadyAsync(path, token);
|
||||||
|
if (readyError is not null)
|
||||||
|
{
|
||||||
|
return UploadAttempt.Fail(readyError, retryable: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!File.Exists(path))
|
||||||
|
{
|
||||||
|
return UploadAttempt.Fail("Datei nicht gefunden.", retryable: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
using var content = new MultipartFormDataContent();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(settings.Username))
|
||||||
|
{
|
||||||
|
content.Add(new StringContent(settings.Username), "username");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(settings.Password))
|
||||||
|
{
|
||||||
|
content.Add(new StringContent(settings.Password), "password");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(settings.ResponseFormat))
|
||||||
|
{
|
||||||
|
content.Add(new StringContent(settings.ResponseFormat), "format");
|
||||||
|
}
|
||||||
|
|
||||||
|
var stream = File.OpenRead(path);
|
||||||
|
var fileContent = new StreamContent(stream);
|
||||||
|
fileContent.Headers.ContentType = new MediaTypeHeaderValue(ResolveContentType(path));
|
||||||
|
content.Add(fileContent, "media", Path.GetFileName(path));
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await client.PostAsync(settings.UploadUrl, content, token);
|
||||||
|
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
return UploadAttempt.Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
var body = await ReadResponseBodyAsync(response, token);
|
||||||
|
var status = $"{(int)response.StatusCode} {response.ReasonPhrase}".Trim();
|
||||||
|
var message = string.IsNullOrWhiteSpace(body) ? status : $"{status} – {body}";
|
||||||
|
return UploadAttempt.Fail(message, IsRetryableStatus(response.StatusCode));
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException) when (!token.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return UploadAttempt.Fail("Zeitüberschreitung beim Upload.", retryable: true);
|
||||||
|
}
|
||||||
|
catch (HttpRequestException)
|
||||||
|
{
|
||||||
|
return UploadAttempt.Fail("Netzwerkfehler beim Upload.", retryable: true);
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
return UploadAttempt.Fail("Datei konnte nicht gelesen werden.", retryable: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string?> WaitForFileReadyAsync(string path, CancellationToken token)
|
||||||
|
{
|
||||||
|
var lastSize = -1L;
|
||||||
|
|
||||||
|
for (var attempts = 0; attempts < 10; attempts++)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
if (!File.Exists(path))
|
||||||
|
{
|
||||||
|
await Task.Delay(500, token);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var info = new FileInfo(path);
|
||||||
|
var size = info.Length;
|
||||||
|
|
||||||
|
if (size > 0 && size == lastSize)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastSize = size;
|
||||||
|
await Task.Delay(700, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Datei ist noch in Bearbeitung.";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveContentType(string path)
|
||||||
|
{
|
||||||
|
return Path.GetExtension(path)?.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
".png" => "image/png",
|
||||||
|
".webp" => "image/webp",
|
||||||
|
_ => "image/jpeg",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsRetryableStatus(System.Net.HttpStatusCode statusCode)
|
||||||
|
{
|
||||||
|
var numeric = (int)statusCode;
|
||||||
|
return numeric >= 500 || statusCode is System.Net.HttpStatusCode.RequestTimeout or System.Net.HttpStatusCode.TooManyRequests;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TimeSpan GetRetryDelay(int attempt)
|
||||||
|
{
|
||||||
|
var jitter = TimeSpan.FromMilliseconds(Random.Shared.Next(100, 350));
|
||||||
|
return TimeSpan.FromMilliseconds(RetryBaseDelay.TotalMilliseconds * Math.Pow(2, attempt)) + jitter;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string?> ReadResponseBodyAsync(HttpResponseMessage response, CancellationToken token)
|
||||||
|
{
|
||||||
|
if (response.Content is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var body = await response.Content.ReadAsStringAsync(token);
|
||||||
|
if (string.IsNullOrWhiteSpace(body))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
body = body.Trim();
|
||||||
|
return body.Length > 200 ? body[..200] + "…" : body;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int GetWorkerCount(PhotoboothSettings settings)
|
||||||
|
{
|
||||||
|
var count = settings.MaxConcurrentUploads;
|
||||||
|
if (count < 1)
|
||||||
|
{
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return count > 5 ? 5 : count;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly record struct UploadAttempt(bool Success, bool Retryable, string? Error)
|
||||||
|
{
|
||||||
|
public static UploadAttempt Ok() => new(true, false, null);
|
||||||
|
|
||||||
|
public static UploadAttempt Fail(string error, bool retryable) => new(false, retryable, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
clients/photobooth-uploader/PhotoboothUploader/app.manifest
Normal file
18
clients/photobooth-uploader/PhotoboothUploader/app.manifest
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||||
|
<!-- This manifest is used on Windows only.
|
||||||
|
Don't remove it as it might cause problems with window transparency and embedded controls.
|
||||||
|
For more details visit https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests -->
|
||||||
|
<assemblyIdentity version="1.0.0.0" name="PhotoboothUploader.Desktop"/>
|
||||||
|
|
||||||
|
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||||
|
<application>
|
||||||
|
<!-- A list of the Windows versions that this application has been tested on
|
||||||
|
and is designed to work with. Uncomment the appropriate elements
|
||||||
|
and Windows will automatically select the most compatible environment. -->
|
||||||
|
|
||||||
|
<!-- Windows 10 -->
|
||||||
|
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||||
|
</application>
|
||||||
|
</compatibility>
|
||||||
|
</assembly>
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ return [
|
|||||||
'failure_limit' => (int) env('JOIN_TOKEN_FAILURE_LIMIT', 10),
|
'failure_limit' => (int) env('JOIN_TOKEN_FAILURE_LIMIT', 10),
|
||||||
'failure_decay_minutes' => (int) env('JOIN_TOKEN_FAILURE_DECAY', 5),
|
'failure_decay_minutes' => (int) env('JOIN_TOKEN_FAILURE_DECAY', 5),
|
||||||
|
|
||||||
'access_limit' => (int) env('JOIN_TOKEN_ACCESS_LIMIT', 120),
|
'access_limit' => (int) env('JOIN_TOKEN_ACCESS_LIMIT', 300),
|
||||||
'access_decay_minutes' => (int) env('JOIN_TOKEN_ACCESS_DECAY', 1),
|
'access_decay_minutes' => (int) env('JOIN_TOKEN_ACCESS_DECAY', 1),
|
||||||
|
|
||||||
'download_limit' => (int) env('JOIN_TOKEN_DOWNLOAD_LIMIT', 60),
|
'download_limit' => (int) env('JOIN_TOKEN_DOWNLOAD_LIMIT', 120),
|
||||||
'download_decay_minutes' => (int) env('JOIN_TOKEN_DOWNLOAD_DECAY', 1),
|
'download_decay_minutes' => (int) env('JOIN_TOKEN_DOWNLOAD_DECAY', 1),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -34,4 +34,8 @@ return [
|
|||||||
'rate_limit_per_minute' => (int) env('SPARKBOOTH_RATE_LIMIT_PER_MINUTE', env('PHOTOBOOTH_RATE_LIMIT_PER_MINUTE', 20)),
|
'rate_limit_per_minute' => (int) env('SPARKBOOTH_RATE_LIMIT_PER_MINUTE', env('PHOTOBOOTH_RATE_LIMIT_PER_MINUTE', 20)),
|
||||||
'response_format' => env('SPARKBOOTH_RESPONSE_FORMAT', 'json'),
|
'response_format' => env('SPARKBOOTH_RESPONSE_FORMAT', 'json'),
|
||||||
],
|
],
|
||||||
|
'connect_code' => [
|
||||||
|
'length' => (int) env('PHOTOBOOTH_CONNECT_CODE_LENGTH', 6),
|
||||||
|
'expires_minutes' => (int) env('PHOTOBOOTH_CONNECT_CODE_EXPIRES_MINUTES', 10),
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
29
database/factories/PhotoboothConnectCodeFactory.php
Normal file
29
database/factories/PhotoboothConnectCodeFactory.php
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\Event;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\PhotoboothConnectCode>
|
||||||
|
*/
|
||||||
|
class PhotoboothConnectCodeFactory extends Factory
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Define the model's default state.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
$rawCode = str_pad((string) $this->faker->numberBetween(0, 999999), 6, '0', STR_PAD_LEFT);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'event_id' => Event::factory(),
|
||||||
|
'code_hash' => hash('sha256', $rawCode),
|
||||||
|
'expires_at' => now()->addMinutes(10),
|
||||||
|
'redeemed_at' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('photobooth_connect_codes', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('event_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->string('code_hash', 64)->unique();
|
||||||
|
$table->timestamp('expires_at');
|
||||||
|
$table->timestamp('redeemed_at')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('photobooth_connect_codes');
|
||||||
|
}
|
||||||
|
};
|
||||||
16
database/seeders/PhotoboothConnectCodeSeeder.php
Normal file
16
database/seeders/PhotoboothConnectCodeSeeder.php
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
|
class PhotoboothConnectCodeSeeder extends Seeder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the database seeds.
|
||||||
|
*/
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,9 +2,11 @@
|
|||||||
|
|
||||||
namespace Database\Seeders;
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
use Illuminate\Database\Seeder;
|
use Illuminate\Database\Seeder;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use App\Models\User;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class SuperAdminSeeder extends Seeder
|
class SuperAdminSeeder extends Seeder
|
||||||
{
|
{
|
||||||
@@ -12,12 +14,49 @@ class SuperAdminSeeder extends Seeder
|
|||||||
{
|
{
|
||||||
$email = env('ADMIN_EMAIL', 'admin@example.com');
|
$email = env('ADMIN_EMAIL', 'admin@example.com');
|
||||||
$password = env('ADMIN_PASSWORD', 'ChangeMe123!');
|
$password = env('ADMIN_PASSWORD', 'ChangeMe123!');
|
||||||
User::updateOrCreate(['email'=>$email], [
|
$user = User::updateOrCreate(['email' => $email], [
|
||||||
'first_name' => 'Super',
|
'first_name' => 'Super',
|
||||||
'last_name' => 'Admin',
|
'last_name' => 'Admin',
|
||||||
'password' => Hash::make($password),
|
'password' => Hash::make($password),
|
||||||
'role' => 'super_admin',
|
'role' => 'super_admin',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$tenantSlug = env('OWNER_TENANT_SLUG', 'owner-tenant');
|
||||||
|
$tenantName = env('OWNER_TENANT_NAME', 'Owner Tenant');
|
||||||
|
|
||||||
|
$tenant = Tenant::query()->firstOrCreate(
|
||||||
|
['slug' => $tenantSlug],
|
||||||
|
[
|
||||||
|
'name' => $tenantName,
|
||||||
|
'email' => $email,
|
||||||
|
'contact_email' => $email,
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'is_active' => true,
|
||||||
|
'is_suspended' => false,
|
||||||
|
'settings' => [
|
||||||
|
'contact_email' => $email,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $tenant->slug) {
|
||||||
|
$tenant->forceFill(['slug' => Str::slug($tenantName)])->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $tenant->user_id) {
|
||||||
|
$tenant->forceFill(['user_id' => $user->id])->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $tenant->email) {
|
||||||
|
$tenant->forceFill(['email' => $email])->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $tenant->contact_email) {
|
||||||
|
$tenant->forceFill(['contact_email' => $email])->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user->tenant_id !== $tenant->id) {
|
||||||
|
$user->forceFill(['tenant_id' => $tenant->id])->save();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1 +1,80 @@
|
|||||||
{}
|
{
|
||||||
|
"login_failed": "Diese Anmeldedaten wurden nicht gefunden.",
|
||||||
|
"login_success": "Sie sind nun eingeloggt.",
|
||||||
|
"registration_failed": "Registrierung fehlgeschlagen.",
|
||||||
|
"registration_success": "Registrierung erfolgreich – bitte mit dem Kauf fortfahren.",
|
||||||
|
"already_logged_in": "Sie sind bereits eingeloggt.",
|
||||||
|
"failed_credentials": "Diese Anmeldedaten wurden nicht gefunden.",
|
||||||
|
"header": {
|
||||||
|
"login": "Anmelden",
|
||||||
|
"register": "Registrieren",
|
||||||
|
"home": "Startseite",
|
||||||
|
"packages": "Pakete",
|
||||||
|
"blog": "Blog",
|
||||||
|
"occasions": {
|
||||||
|
"wedding": "Hochzeit",
|
||||||
|
"birthday": "Geburtstag",
|
||||||
|
"corporate": "Firmenevent"
|
||||||
|
},
|
||||||
|
"contact": "Kontakt"
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"title": "Die Fotospiel App",
|
||||||
|
"description": "Melde dich mit deinem Fotospiel-Zugang an und steuere deine Events zentral in einem Dashboard.",
|
||||||
|
"brand": "Die Fotospiel App",
|
||||||
|
"logo_alt": "Logo Die Fotospiel App",
|
||||||
|
"identifier": "E-Mail oder Username",
|
||||||
|
"identifier_placeholder": "z. B. name@beispiel.de oder hochzeit_julia",
|
||||||
|
"username_or_email": "Username oder E-Mail",
|
||||||
|
"email": "E-Mail-Adresse",
|
||||||
|
"email_placeholder": "ihre@email.de",
|
||||||
|
"password": "Passwort",
|
||||||
|
"password_placeholder": "Ihr Passwort",
|
||||||
|
"remember": "Angemeldet bleiben",
|
||||||
|
"forgot": "Passwort vergessen?",
|
||||||
|
"submit": "Anmelden",
|
||||||
|
"oauth_divider": "oder",
|
||||||
|
"google_cta": "Mit Google anmelden",
|
||||||
|
"google_helper": "Nutze dein Google-Konto, um dich sicher bei der Eventverwaltung anzumelden.",
|
||||||
|
"no_account": "Noch keinen Zugang?",
|
||||||
|
"sign_up": "Jetzt registrieren"
|
||||||
|
},
|
||||||
|
"register": {
|
||||||
|
"title": "Registrieren",
|
||||||
|
"name": "Vollständiger Name",
|
||||||
|
"username": "Username",
|
||||||
|
"email": "E-Mail-Adresse",
|
||||||
|
"password": "Passwort",
|
||||||
|
"password_confirmation": "Passwort bestätigen",
|
||||||
|
"first_name": "Vorname",
|
||||||
|
"last_name": "Nachname",
|
||||||
|
"address": "Adresse",
|
||||||
|
"phone": "Telefonnummer",
|
||||||
|
"privacy_consent": "Ich stimme der Datenschutzerklärung zu und akzeptiere die Verarbeitung meiner persönlichen Daten.",
|
||||||
|
"privacy_policy_link": "Datenschutzerklärung",
|
||||||
|
"submit": "Registrieren",
|
||||||
|
"first_name_placeholder": "Vorname",
|
||||||
|
"last_name_placeholder": "Nachname",
|
||||||
|
"email_placeholder": "beispiel@email.de",
|
||||||
|
"address_placeholder": "Straße Hausnummer, PLZ Ort",
|
||||||
|
"phone_placeholder": "+49 170 1234567",
|
||||||
|
"username_placeholder": "z. B. hochzeit_julia",
|
||||||
|
"password_placeholder": "Mindestens 8 Zeichen",
|
||||||
|
"password_confirmation_placeholder": "Passwort erneut eingeben",
|
||||||
|
"server_error_title": "Registrierung konnte nicht abgeschlossen werden",
|
||||||
|
"server_error_message": "Auf unserer Seite ist ein Fehler aufgetreten. Bitte versuche es später erneut oder kontaktiere support@fotospiel.de.",
|
||||||
|
"session_expired_title": "Sicherheitsprüfung abgelaufen",
|
||||||
|
"session_expired_message": "Deine Sitzung ist abgelaufen. Lade die Seite neu und versuche es erneut."
|
||||||
|
},
|
||||||
|
"verification": {
|
||||||
|
"notice": "Bitte bestätigen Sie Ihre E-Mail-Adresse.",
|
||||||
|
"resend": "E-Mail erneut senden",
|
||||||
|
"success_title": "E-Mail bestätigt",
|
||||||
|
"success_message": "Deine E-Mail ist bestätigt. Du kannst dich jetzt anmelden.",
|
||||||
|
"checkout_success_message": "E-Mail bestätigt. Du kannst mit dem Checkout fortfahren.",
|
||||||
|
"toast_success": "E-Mail erfolgreich bestätigt.",
|
||||||
|
"expired_title": "Bestätigungslink abgelaufen",
|
||||||
|
"expired_message": "Dieser Bestätigungslink ist nicht mehr gültig. Fordere unten einen neuen Link an.",
|
||||||
|
"toast_error": "Bestätigungslink abgelaufen. Bitte fordere einen neuen Link an."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1 +1,80 @@
|
|||||||
{}
|
{
|
||||||
|
"login_failed": "Invalid email or password.",
|
||||||
|
"login_success": "You are now logged in.",
|
||||||
|
"registration_failed": "Registration failed.",
|
||||||
|
"registration_success": "Registration successful – proceed with purchase.",
|
||||||
|
"already_logged_in": "You are already logged in.",
|
||||||
|
"failed_credentials": "Wrong credentials.",
|
||||||
|
"header": {
|
||||||
|
"login": "Login",
|
||||||
|
"register": "Register",
|
||||||
|
"home": "Home",
|
||||||
|
"packages": "Packages",
|
||||||
|
"blog": "Blog",
|
||||||
|
"occasions": {
|
||||||
|
"wedding": "Wedding",
|
||||||
|
"birthday": "Birthday",
|
||||||
|
"corporate": "Corporate Event"
|
||||||
|
},
|
||||||
|
"contact": "Contact"
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"title": "Die Fotospiel App",
|
||||||
|
"description": "Sign in with your Fotospiel account to manage every event in one place.",
|
||||||
|
"brand": "Die Fotospiel App",
|
||||||
|
"logo_alt": "Fotospiel App logo",
|
||||||
|
"identifier": "Email or Username",
|
||||||
|
"identifier_placeholder": "you@example.com or username",
|
||||||
|
"username_or_email": "Username or Email",
|
||||||
|
"email": "Email Address",
|
||||||
|
"email_placeholder": "your@email.com",
|
||||||
|
"password": "Password",
|
||||||
|
"password_placeholder": "Your password",
|
||||||
|
"remember": "Stay logged in",
|
||||||
|
"forgot": "Forgot password?",
|
||||||
|
"submit": "Login",
|
||||||
|
"oauth_divider": "or",
|
||||||
|
"google_cta": "Continue with Google",
|
||||||
|
"google_helper": "Use your Google account to access the event dashboard securely.",
|
||||||
|
"no_account": "Don't have access yet?",
|
||||||
|
"sign_up": "Create an account"
|
||||||
|
},
|
||||||
|
"register": {
|
||||||
|
"title": "Register",
|
||||||
|
"name": "Full Name",
|
||||||
|
"username": "Username",
|
||||||
|
"email": "Email Address",
|
||||||
|
"password": "Password",
|
||||||
|
"password_confirmation": "Confirm password",
|
||||||
|
"first_name": "First Name",
|
||||||
|
"last_name": "Last Name",
|
||||||
|
"address": "Address",
|
||||||
|
"phone": "Phone Number",
|
||||||
|
"privacy_consent": "I agree to the privacy policy and accept the processing of my personal data.",
|
||||||
|
"privacy_policy_link": "Privacy Policy",
|
||||||
|
"submit": "Register",
|
||||||
|
"first_name_placeholder": "First name",
|
||||||
|
"last_name_placeholder": "Last name",
|
||||||
|
"email_placeholder": "you@example.com",
|
||||||
|
"address_placeholder": "Street, ZIP, City",
|
||||||
|
"phone_placeholder": "+1 555 123 4567",
|
||||||
|
"username_placeholder": "e.g. wedding_julia",
|
||||||
|
"password_placeholder": "At least 8 characters",
|
||||||
|
"password_confirmation_placeholder": "Repeat your password",
|
||||||
|
"server_error_title": "We couldn't finish your registration",
|
||||||
|
"server_error_message": "Something went wrong on our side. Please try again in a moment or contact support@fotospiel.de.",
|
||||||
|
"session_expired_title": "Security check expired",
|
||||||
|
"session_expired_message": "Your session expired. Refresh the page and try again."
|
||||||
|
},
|
||||||
|
"verification": {
|
||||||
|
"notice": "Please verify your email address.",
|
||||||
|
"resend": "Resend email",
|
||||||
|
"success_title": "Email verified",
|
||||||
|
"success_message": "Your email is confirmed. You can sign in now.",
|
||||||
|
"checkout_success_message": "Email confirmed. Continue your checkout to finish the order.",
|
||||||
|
"toast_success": "Email verified successfully.",
|
||||||
|
"expired_title": "Verification link expired",
|
||||||
|
"expired_message": "That verification link is no longer valid. Request a new email below.",
|
||||||
|
"toast_error": "Verification link expired. Request a new one."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { authorizedFetch } from './auth/tokens';
|
import { authorizedFetch } from './auth/tokens';
|
||||||
import { ApiError, emitApiErrorEvent } from './lib/apiError';
|
import { ApiError, emitApiErrorEvent } from './lib/apiError';
|
||||||
import type { EventLimitSummary } from './lib/limitWarnings';
|
import type { EventLimitSummary } from './lib/limitWarnings';
|
||||||
|
export type { EventLimitSummary };
|
||||||
import i18n from './i18n';
|
import i18n from './i18n';
|
||||||
|
|
||||||
type JsonValue = Record<string, unknown>;
|
type JsonValue = Record<string, unknown>;
|
||||||
@@ -217,6 +218,11 @@ export type PhotoboothStatus = {
|
|||||||
metrics?: PhotoboothStatusMetrics | null;
|
metrics?: PhotoboothStatusMetrics | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PhotoboothConnectCode = {
|
||||||
|
code: string;
|
||||||
|
expires_at: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type EventAddonCheckout = {
|
export type EventAddonCheckout = {
|
||||||
addon_key: string;
|
addon_key: string;
|
||||||
quantity?: number;
|
quantity?: number;
|
||||||
@@ -2040,6 +2046,35 @@ export async function disableEventPhotobooth(slug: string, options?: { mode?: 'f
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createEventPhotoboothConnectCode(
|
||||||
|
slug: string,
|
||||||
|
options?: { expires_in_minutes?: number }
|
||||||
|
): Promise<PhotoboothConnectCode> {
|
||||||
|
const body = options ? JSON.stringify(options) : undefined;
|
||||||
|
const headers = body ? { 'Content-Type': 'application/json' } : undefined;
|
||||||
|
|
||||||
|
const response = await authorizedFetch(`${photoboothEndpoint(slug)}/connect-codes`, {
|
||||||
|
method: 'POST',
|
||||||
|
body,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await jsonOrThrow<{ data?: JsonValue }>(response, 'Failed to create photobooth connect code');
|
||||||
|
const record = (data.data ?? {}) as Record<string, JsonValue>;
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: typeof record.code === 'string' ? record.code : '',
|
||||||
|
expires_at: typeof record.expires_at === 'string' ? record.expires_at : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendEventPhotoboothUploaderEmail(slug: string): Promise<void> {
|
||||||
|
const response = await authorizedFetch(`${photoboothEndpoint(slug)}/uploader-email`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
await jsonOrThrow<{ message?: string }>(response, 'Failed to send photobooth uploader email');
|
||||||
|
}
|
||||||
|
|
||||||
export async function submitTenantFeedback(payload: {
|
export async function submitTenantFeedback(payload: {
|
||||||
category: string;
|
category: string;
|
||||||
sentiment?: 'positive' | 'neutral' | 'negative';
|
sentiment?: 'positive' | 'neutral' | 'negative';
|
||||||
@@ -2457,7 +2492,7 @@ export async function getTenantPaddleTransactions(cursor?: string): Promise<{
|
|||||||
export async function createTenantPaddleCheckout(
|
export async function createTenantPaddleCheckout(
|
||||||
packageId: number,
|
packageId: number,
|
||||||
urls?: { success_url?: string; return_url?: string }
|
urls?: { success_url?: string; return_url?: string }
|
||||||
): Promise<{ checkout_url: string; id: string; expires_at?: string }> {
|
): Promise<{ checkout_url: string; id: string; expires_at?: string; checkout_session_id?: string }> {
|
||||||
const response = await authorizedFetch('/api/v1/tenant/packages/paddle-checkout', {
|
const response = await authorizedFetch('/api/v1/tenant/packages/paddle-checkout', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -2467,12 +2502,22 @@ export async function createTenantPaddleCheckout(
|
|||||||
return_url: urls?.return_url,
|
return_url: urls?.return_url,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
return await jsonOrThrow<{ checkout_url: string; id: string; expires_at?: string }>(
|
return await jsonOrThrow<{ checkout_url: string; id: string; expires_at?: string; checkout_session_id?: string }>(
|
||||||
response,
|
response,
|
||||||
'Failed to create checkout'
|
'Failed to create checkout'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getTenantPackageCheckoutStatus(
|
||||||
|
checkoutSessionId: string,
|
||||||
|
): Promise<{ status: string; completed_at?: string | null; reason?: string | null; checkout_url?: string | null }> {
|
||||||
|
const response = await authorizedFetch(`/api/v1/tenant/packages/checkout-session/${checkoutSessionId}/status`);
|
||||||
|
return await jsonOrThrow<{ status: string; completed_at?: string | null; reason?: string | null; checkout_url?: string | null }>(
|
||||||
|
response,
|
||||||
|
'Failed to load checkout status'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function createTenantBillingPortalSession(): Promise<{ url: string }> {
|
export async function createTenantBillingPortalSession(): Promise<{ url: string }> {
|
||||||
const response = await authorizedFetch('/api/v1/tenant/billing/portal', {
|
const response = await authorizedFetch('/api/v1/tenant/billing/portal', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export const ADMIN_AUTH_CALLBACK_PATH = adminPath('/auth/callback');
|
|||||||
export const ADMIN_EVENTS_PATH = adminPath('/mobile/events');
|
export const ADMIN_EVENTS_PATH = adminPath('/mobile/events');
|
||||||
export const ADMIN_SETTINGS_PATH = adminPath('/mobile/settings');
|
export const ADMIN_SETTINGS_PATH = adminPath('/mobile/settings');
|
||||||
export const ADMIN_PROFILE_PATH = adminPath('/mobile/profile');
|
export const ADMIN_PROFILE_PATH = adminPath('/mobile/profile');
|
||||||
|
export const ADMIN_PROFILE_ACCOUNT_PATH = adminPath('/mobile/profile/account');
|
||||||
export const ADMIN_FAQ_PATH = adminPath('/mobile/help');
|
export const ADMIN_FAQ_PATH = adminPath('/mobile/help');
|
||||||
export const ADMIN_BILLING_PATH = adminPath('/mobile/billing');
|
export const ADMIN_BILLING_PATH = adminPath('/mobile/billing');
|
||||||
export const ADMIN_PACKAGE_SHOP_PATH = adminPath('/mobile/billing/shop');
|
export const ADMIN_PACKAGE_SHOP_PATH = adminPath('/mobile/billing/shop');
|
||||||
|
|||||||
@@ -34,6 +34,27 @@
|
|||||||
"more": "Weitere Einträge konnten nicht geladen werden.",
|
"more": "Weitere Einträge konnten nicht geladen werden.",
|
||||||
"portal": "Paddle-Portal konnte nicht geöffnet werden."
|
"portal": "Paddle-Portal konnte nicht geöffnet werden."
|
||||||
},
|
},
|
||||||
|
"checkoutSuccess": "Checkout abgeschlossen. Dein Paket wird in Kürze aktiviert.",
|
||||||
|
"checkoutCancelled": "Checkout wurde abgebrochen.",
|
||||||
|
"checkoutActivated": "Dein Paket ist jetzt aktiv.",
|
||||||
|
"checkoutPendingTitle": "Paket wird aktiviert",
|
||||||
|
"checkoutPendingBody": "Das kann ein paar Minuten dauern. Wir aktualisieren den Status, sobald das Paket aktiv ist.",
|
||||||
|
"checkoutPendingBadge": "Ausstehend",
|
||||||
|
"checkoutPendingRefresh": "Aktualisieren",
|
||||||
|
"checkoutPendingDismiss": "Ausblenden",
|
||||||
|
"checkoutFailedTitle": "Checkout fehlgeschlagen",
|
||||||
|
"checkoutFailedBody": "Die Zahlung wurde nicht abgeschlossen. Du kannst es erneut versuchen oder den Support kontaktieren.",
|
||||||
|
"checkoutFailedBadge": "Fehlgeschlagen",
|
||||||
|
"checkoutFailedRetry": "Erneut versuchen",
|
||||||
|
"checkoutFailedDismiss": "Ausblenden",
|
||||||
|
"checkoutActionTitle": "Aktion erforderlich",
|
||||||
|
"checkoutActionBody": "Schließe die Zahlung ab, um das Paket zu aktivieren.",
|
||||||
|
"checkoutActionBadge": "Aktion nötig",
|
||||||
|
"checkoutActionButton": "Checkout fortsetzen",
|
||||||
|
"checkoutFailureReasons": {
|
||||||
|
"paddle_failed": "Die Zahlung wurde abgelehnt.",
|
||||||
|
"paddle_cancelled": "Der Checkout wurde abgebrochen."
|
||||||
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"invoices": {
|
"invoices": {
|
||||||
"title": "Rechnungen & Zahlungen",
|
"title": "Rechnungen & Zahlungen",
|
||||||
@@ -176,6 +197,8 @@
|
|||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"all": "Alle",
|
"all": "Alle",
|
||||||
|
"anonymous": "Anonym",
|
||||||
|
"error": "Etwas ist schiefgelaufen",
|
||||||
"loadMore": "Mehr laden",
|
"loadMore": "Mehr laden",
|
||||||
"processing": "Verarbeite …",
|
"processing": "Verarbeite …",
|
||||||
"select": "Auswählen",
|
"select": "Auswählen",
|
||||||
@@ -1145,15 +1168,23 @@
|
|||||||
"mode": "Modus"
|
"mode": "Modus"
|
||||||
},
|
},
|
||||||
"mode": {
|
"mode": {
|
||||||
"title": "Photobooth-Typ auswählen",
|
"title": "Uploader-Verbindung",
|
||||||
"description": "Wähle zwischen klassischem FTP und Sparkbooth HTTP-Upload. Umschalten generiert neue Zugangsdaten.",
|
"description": "Nutze die Fotospiel-Uploader-App für HTTP-Uploads. Beim Zurücksetzen werden neue Zugangsdaten generiert.",
|
||||||
"active": "Aktuell: {{mode}}"
|
"active": "Aktuell: {{mode}}",
|
||||||
|
"uploader": "Uploader-App (HTTP)"
|
||||||
|
},
|
||||||
|
"selector": {
|
||||||
|
"title": "Verbindung",
|
||||||
|
"description": "Nutze die Fotospiel-Uploader-App für HTTP-Uploads."
|
||||||
},
|
},
|
||||||
"credentials": {
|
"credentials": {
|
||||||
"heading": "FTP-Zugangsdaten",
|
"heading": "Zugangsdaten für die Uploader-App",
|
||||||
"description": "Teile die Zugangsdaten mit eurer Photobooth-Software.",
|
"description": "Teile die Zugangsdaten mit der Fotospiel-Uploader-App.",
|
||||||
"sparkboothTitle": "Sparkbooth-Upload (HTTP)",
|
"uploaderTitle": "Uploader-App (HTTP)",
|
||||||
"sparkboothDescription": "Trage URL, Benutzername und Passwort in Sparkbooth ein. Antworten sind JSON (optional XML).",
|
"uploaderDescription": "Trage URL, Benutzername und Passwort in die Fotospiel-Uploader-App ein. Antworten sind JSON (optional XML).",
|
||||||
|
"show": "Zugangsdaten anzeigen",
|
||||||
|
"hide": "Zugangsdaten verbergen",
|
||||||
|
"hidden": "Zugangsdaten verborgen. Tippe zum Anzeigen.",
|
||||||
"host": "Host",
|
"host": "Host",
|
||||||
"port": "Port",
|
"port": "Port",
|
||||||
"username": "Benutzername",
|
"username": "Benutzername",
|
||||||
@@ -1162,6 +1193,44 @@
|
|||||||
"postUrl": "Upload-URL",
|
"postUrl": "Upload-URL",
|
||||||
"responseFormat": "Antwort-Format"
|
"responseFormat": "Antwort-Format"
|
||||||
},
|
},
|
||||||
|
"connectCode": {
|
||||||
|
"label": "Verbindungscode",
|
||||||
|
"description": "Erstelle einen 6-stelligen Code für die Uploader-App.",
|
||||||
|
"expires": "Läuft ab: {{date}}",
|
||||||
|
"actions": {
|
||||||
|
"generate": "Verbindungscode erstellen",
|
||||||
|
"generated": "Verbindungscode erstellt"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"failed": "Verbindungscode konnte nicht erstellt werden."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uploader": {
|
||||||
|
"hint": "POST mit Mediendatei oder base64-Feld \"media\"; die App nutzt diese Zugangsdaten."
|
||||||
|
},
|
||||||
|
"steps": {
|
||||||
|
"activate": {
|
||||||
|
"title": "1. Photobooth aktivieren",
|
||||||
|
"description": "Schalte den Upload-Zugang fuer dieses Event frei."
|
||||||
|
},
|
||||||
|
"download": {
|
||||||
|
"title": "2. Uploader App herunterladen"
|
||||||
|
},
|
||||||
|
"access": {
|
||||||
|
"title": "3. Verbindungscode erstellen",
|
||||||
|
"description": "Der Code verbindet die App sicher mit deinem Event."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uploaderDownload": {
|
||||||
|
"title": "Fotospiel Uploader App",
|
||||||
|
"description": "Die Fotospiel Uploader App wird benötigt, damit Uploads stabil laufen, die Zugangsdaten geschützt bleiben und keine Dateien verloren gehen.",
|
||||||
|
"emailAction": "Download-Links per E-Mail senden",
|
||||||
|
"emailSuccess": "Download-Links wurden per E-Mail gesendet.",
|
||||||
|
"emailFailed": "E-Mail konnte nicht gesendet werden.",
|
||||||
|
"actionWindows": "Uploader herunterladen (Windows)",
|
||||||
|
"actionMac": "Uploader herunterladen (macOS)",
|
||||||
|
"actionLinux": "Uploader herunterladen (Linux)"
|
||||||
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"enable": "Photobooth aktivieren",
|
"enable": "Photobooth aktivieren",
|
||||||
"disable": "Deaktivieren",
|
"disable": "Deaktivieren",
|
||||||
@@ -1179,9 +1248,9 @@
|
|||||||
"title": "Setup-Checkliste",
|
"title": "Setup-Checkliste",
|
||||||
"description": "Durchlaufe die Schritte, bevor du Gästen Zugang gibst.",
|
"description": "Durchlaufe die Schritte, bevor du Gästen Zugang gibst.",
|
||||||
"enable": "Zugang aktivieren",
|
"enable": "Zugang aktivieren",
|
||||||
"enableCopy": "Aktiviere den FTP-Account für eure Photobooth-Software.",
|
"enableCopy": "Aktiviere die Verbindung für die Uploader-App.",
|
||||||
"share": "Zugang teilen",
|
"share": "Zugang teilen",
|
||||||
"shareCopy": "Übergib Host, Benutzer & Passwort an den Betreiber.",
|
"shareCopy": "Übergib URL, Benutzername & Passwort an den Betreiber.",
|
||||||
"monitor": "Uploads beobachten",
|
"monitor": "Uploads beobachten",
|
||||||
"monitorCopy": "Verfolge Uploads & Limits direkt im Dashboard."
|
"monitorCopy": "Verfolge Uploads & Limits direkt im Dashboard."
|
||||||
},
|
},
|
||||||
@@ -1408,7 +1477,7 @@
|
|||||||
"photobooth": {
|
"photobooth": {
|
||||||
"title": "Fotobox-Uploads",
|
"title": "Fotobox-Uploads",
|
||||||
"titleForEvent": "Fotobox-Uploads verwalten",
|
"titleForEvent": "Fotobox-Uploads verwalten",
|
||||||
"subtitle": "Erstelle FTP-Zugänge für Photobooth-Software und behalte Limits im Blick.",
|
"subtitle": "Erstelle Zugang für die Uploader-App und behalte Limits im Blick.",
|
||||||
"actions": {
|
"actions": {
|
||||||
"backToEvent": "Zur Detailansicht",
|
"backToEvent": "Zur Detailansicht",
|
||||||
"allEvents": "Zur Eventliste"
|
"allEvents": "Zur Eventliste"
|
||||||
@@ -1849,23 +1918,63 @@
|
|||||||
"titleShort": "Branding",
|
"titleShort": "Branding",
|
||||||
"previewTitle": "Guest-App-Vorschau",
|
"previewTitle": "Guest-App-Vorschau",
|
||||||
"previewSubtitle": "Aktuelle Farben & Schriften",
|
"previewSubtitle": "Aktuelle Farben & Schriften",
|
||||||
|
"previewCta": "Fotos hochladen",
|
||||||
"primary": "Primärfarbe",
|
"primary": "Primärfarbe",
|
||||||
"accent": "Akzentfarbe",
|
"accent": "Akzentfarbe",
|
||||||
|
"background": "Hintergrund",
|
||||||
|
"surface": "Fläche",
|
||||||
|
"lockedBranding": "Branding ist in diesem Paket gesperrt.",
|
||||||
|
"source": "Branding-Quelle",
|
||||||
|
"sourceHint": "Nutze das Tenant-Branding oder überschreibe es für dieses Event.",
|
||||||
|
"useDefault": "Tenant",
|
||||||
|
"useCustom": "Event",
|
||||||
|
"usingDefault": "Tenant-Branding aktiv",
|
||||||
|
"usingCustom": "Event-Branding aktiv",
|
||||||
|
"mode": "Theme",
|
||||||
|
"modeLight": "Hell",
|
||||||
|
"modeAuto": "Auto",
|
||||||
|
"modeDark": "Dunkel",
|
||||||
"colors": "Farben",
|
"colors": "Farben",
|
||||||
"primaryColor": "Primärfarbe",
|
"primaryColor": "Primärfarbe",
|
||||||
"accentColor": "Akzentfarbe",
|
"accentColor": "Akzentfarbe",
|
||||||
|
"backgroundColor": "Hintergrundfarbe",
|
||||||
|
"surfaceColor": "Flächenfarbe",
|
||||||
"fonts": "Schriften",
|
"fonts": "Schriften",
|
||||||
"headingFont": "Überschrift-Schrift",
|
"headingFont": "Überschrift-Schrift",
|
||||||
"headingFontPlaceholder": "SF Pro Display",
|
"headingFontPlaceholder": "SF Pro Display",
|
||||||
"bodyFont": "Fließtext-Schrift",
|
"bodyFont": "Fließtext-Schrift",
|
||||||
"bodyFontPlaceholder": "SF Pro Text",
|
"bodyFontPlaceholder": "SF Pro Text",
|
||||||
|
"fontSize": "Schriftgröße",
|
||||||
|
"fontSizeSmall": "S",
|
||||||
|
"fontSizeMedium": "M",
|
||||||
|
"fontSizeLarge": "L",
|
||||||
"logo": "Logo",
|
"logo": "Logo",
|
||||||
"logoAlt": "Logo",
|
"logoAlt": "Logo",
|
||||||
|
"logoModeUpload": "Upload",
|
||||||
|
"logoModeEmoticon": "Emoticon",
|
||||||
|
"logoValue": "Emoticon",
|
||||||
|
"logoValuePlaceholder": "🎉",
|
||||||
|
"logoPosition": "Position",
|
||||||
|
"positionLeft": "Links",
|
||||||
|
"positionCenter": "Zentriert",
|
||||||
|
"positionRight": "Rechts",
|
||||||
|
"logoSize": "Größe",
|
||||||
|
"logoSizeSmall": "S",
|
||||||
|
"logoSizeMedium": "M",
|
||||||
|
"logoSizeLarge": "L",
|
||||||
"replaceLogo": "Logo ersetzen",
|
"replaceLogo": "Logo ersetzen",
|
||||||
"removeLogo": "Entfernen",
|
"removeLogo": "Entfernen",
|
||||||
"logoHint": "Lade ein Logo hoch, um Einladungen und QR-Poster zu branden.",
|
"logoHint": "Logo hochladen oder Emoji für den Guest-Header nutzen.",
|
||||||
"uploadLogo": "Logo hochladen (max. 1 MB)",
|
"uploadLogo": "Logo hochladen (max. 1 MB)",
|
||||||
"logoTooLarge": "Logo muss unter 1 MB sein.",
|
"logoTooLarge": "Logo muss unter 1 MB sein.",
|
||||||
|
"buttons": "Buttons & Links",
|
||||||
|
"buttonsHint": "Stil, Radius und Link-Farbe für CTAs.",
|
||||||
|
"buttonFilled": "Gefüllt",
|
||||||
|
"buttonOutline": "Outline",
|
||||||
|
"buttonRadius": "Radius",
|
||||||
|
"buttonPrimary": "Button Primär",
|
||||||
|
"buttonSecondary": "Button Sekundär",
|
||||||
|
"linkColor": "Link-Farbe",
|
||||||
"save": "Branding speichern",
|
"save": "Branding speichern",
|
||||||
"saving": "Speichere...",
|
"saving": "Speichere...",
|
||||||
"saveSuccess": "Branding gespeichert.",
|
"saveSuccess": "Branding gespeichert.",
|
||||||
@@ -2323,7 +2432,7 @@
|
|||||||
"mobileProfile": {
|
"mobileProfile": {
|
||||||
"title": "Profil",
|
"title": "Profil",
|
||||||
"settings": "Einstellungen",
|
"settings": "Einstellungen",
|
||||||
"account": "Account & Sicherheit",
|
"account": "Account bearbeiten",
|
||||||
"language": "Sprache",
|
"language": "Sprache",
|
||||||
"languageDe": "Deutsch",
|
"languageDe": "Deutsch",
|
||||||
"languageEn": "Englisch",
|
"languageEn": "Englisch",
|
||||||
@@ -2875,16 +2984,25 @@
|
|||||||
"analytics": {
|
"analytics": {
|
||||||
"title": "Analytics",
|
"title": "Analytics",
|
||||||
"upgradeAction": "Upgrade auf Premium",
|
"upgradeAction": "Upgrade auf Premium",
|
||||||
|
"kpiTitle": "Event-Überblick",
|
||||||
|
"kpiUploads": "Uploads",
|
||||||
|
"kpiContributors": "Beitragende",
|
||||||
|
"kpiLikes": "Likes",
|
||||||
"activityTitle": "Aktivitäts-Zeitachse",
|
"activityTitle": "Aktivitäts-Zeitachse",
|
||||||
|
"timeframe": "Letzte {{hours}} Stunden",
|
||||||
|
"timeframeHint": "Ältere Aktivität ausgeblendet",
|
||||||
"uploadsPerHour": "Uploads pro Stunde",
|
"uploadsPerHour": "Uploads pro Stunde",
|
||||||
"noActivity": "Noch keine Uploads",
|
"noActivity": "Noch keine Uploads",
|
||||||
|
"emptyActionShareQr": "QR-Code teilen",
|
||||||
"contributorsTitle": "Top-Beitragende",
|
"contributorsTitle": "Top-Beitragende",
|
||||||
"likesCount": "{{count}} Likes",
|
"likesCount": "{{count}} Likes",
|
||||||
"likesCount_one": "{{count}} Like",
|
"likesCount_one": "{{count}} Like",
|
||||||
"likesCount_other": "{{count}} Likes",
|
"likesCount_other": "{{count}} Likes",
|
||||||
"noContributors": "Noch keine Beitragenden",
|
"noContributors": "Noch keine Beitragenden",
|
||||||
|
"emptyActionInvite": "Gäste einladen",
|
||||||
"tasksTitle": "Beliebte Aufgaben",
|
"tasksTitle": "Beliebte Aufgaben",
|
||||||
"noTasks": "Noch keine Aufgabenaktivität",
|
"noTasks": "Noch keine Aufgabenaktivität",
|
||||||
|
"emptyActionOpenTasks": "Aufgaben öffnen",
|
||||||
"lockedTitle": "Analytics freischalten",
|
"lockedTitle": "Analytics freischalten",
|
||||||
"lockedBody": "Erhalte tiefe Einblicke in die Interaktionen deines Events mit dem Premium-Paket."
|
"lockedBody": "Erhalte tiefe Einblicke in die Interaktionen deines Events mit dem Premium-Paket."
|
||||||
},
|
},
|
||||||
@@ -2893,6 +3011,26 @@
|
|||||||
"subtitle": "Wähle ein Paket, um mehr Funktionen und Limits freizuschalten.",
|
"subtitle": "Wähle ein Paket, um mehr Funktionen und Limits freizuschalten.",
|
||||||
"recommendationTitle": "Empfohlen für dich",
|
"recommendationTitle": "Empfohlen für dich",
|
||||||
"recommendationBody": "Das hervorgehobene Paket enthält das gewünschte Feature.",
|
"recommendationBody": "Das hervorgehobene Paket enthält das gewünschte Feature.",
|
||||||
|
"compare": {
|
||||||
|
"title": "Pakete vergleichen",
|
||||||
|
"helper": "Wische, um Pakete nebeneinander zu vergleichen.",
|
||||||
|
"toggleCards": "Karten",
|
||||||
|
"toggleCompare": "Vergleichen",
|
||||||
|
"headers": {
|
||||||
|
"plan": "Paket",
|
||||||
|
"price": "Preis"
|
||||||
|
},
|
||||||
|
"rows": {
|
||||||
|
"photos": "Fotos",
|
||||||
|
"guests": "Gäste",
|
||||||
|
"days": "Galerietage"
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"included": "Enthalten",
|
||||||
|
"notIncluded": "Nicht enthalten",
|
||||||
|
"unlimited": "Unbegrenzt"
|
||||||
|
}
|
||||||
|
},
|
||||||
"select": "Auswählen",
|
"select": "Auswählen",
|
||||||
"manage": "Paket verwalten",
|
"manage": "Paket verwalten",
|
||||||
"limits": {
|
"limits": {
|
||||||
@@ -2906,7 +3044,13 @@
|
|||||||
},
|
},
|
||||||
"features": {
|
"features": {
|
||||||
"advanced_analytics": "Erweiterte Analytics",
|
"advanced_analytics": "Erweiterte Analytics",
|
||||||
|
"basic_uploads": "Basis-Uploads",
|
||||||
"custom_branding": "Eigenes Branding",
|
"custom_branding": "Eigenes Branding",
|
||||||
|
"custom_tasks": "Benutzerdefinierte Aufgaben",
|
||||||
|
"limited_sharing": "Begrenztes Teilen",
|
||||||
|
"live_slideshow": "Live-Slideshow",
|
||||||
|
"priority_support": "Priorisierter Support",
|
||||||
|
"unlimited_sharing": "Unbegrenztes Teilen",
|
||||||
"watermark_removal": "Kein Wasserzeichen"
|
"watermark_removal": "Kein Wasserzeichen"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
@@ -2918,7 +3062,9 @@
|
|||||||
},
|
},
|
||||||
"badges": {
|
"badges": {
|
||||||
"recommended": "Empfohlen",
|
"recommended": "Empfohlen",
|
||||||
"active": "Aktiv"
|
"active": "Aktiv",
|
||||||
|
"upgrade": "Upgrade",
|
||||||
|
"downgrade": "Downgrade"
|
||||||
},
|
},
|
||||||
"confirmTitle": "Kauf bestätigen",
|
"confirmTitle": "Kauf bestätigen",
|
||||||
"confirmSubtitle": "Du upgradest auf:",
|
"confirmSubtitle": "Du upgradest auf:",
|
||||||
@@ -2931,6 +3077,7 @@
|
|||||||
"payNow": "Jetzt zahlen",
|
"payNow": "Jetzt zahlen",
|
||||||
"errors": {
|
"errors": {
|
||||||
"checkout": "Checkout fehlgeschlagen"
|
"checkout": "Checkout fehlgeschlagen"
|
||||||
}
|
},
|
||||||
|
"selectDisabled": "Nicht verfügbar"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"profile": {
|
"profile": {
|
||||||
"title": "Profil",
|
"title": "Profil",
|
||||||
"subtitle": "Verwalte deine Kontodaten und Zugangsdaten.",
|
"subtitle": "Verwalte deine Kontodaten und Zugangsdaten.",
|
||||||
|
"loading": "Lädt ...",
|
||||||
"sections": {
|
"sections": {
|
||||||
"account": {
|
"account": {
|
||||||
"heading": "Account-Informationen",
|
"heading": "Account-Informationen",
|
||||||
|
|||||||
@@ -34,6 +34,27 @@
|
|||||||
"more": "Unable to load more entries.",
|
"more": "Unable to load more entries.",
|
||||||
"portal": "Unable to open the Paddle portal."
|
"portal": "Unable to open the Paddle portal."
|
||||||
},
|
},
|
||||||
|
"checkoutSuccess": "Checkout completed. Your package will activate shortly.",
|
||||||
|
"checkoutCancelled": "Checkout was cancelled.",
|
||||||
|
"checkoutActivated": "Your package is now active.",
|
||||||
|
"checkoutPendingTitle": "Activating your package",
|
||||||
|
"checkoutPendingBody": "This can take a few minutes. We will update this screen once the package is active.",
|
||||||
|
"checkoutPendingBadge": "Pending",
|
||||||
|
"checkoutPendingRefresh": "Refresh",
|
||||||
|
"checkoutPendingDismiss": "Dismiss",
|
||||||
|
"checkoutFailedTitle": "Checkout failed",
|
||||||
|
"checkoutFailedBody": "The payment did not complete. You can try again or contact support.",
|
||||||
|
"checkoutFailedBadge": "Failed",
|
||||||
|
"checkoutFailedRetry": "Try again",
|
||||||
|
"checkoutFailedDismiss": "Dismiss",
|
||||||
|
"checkoutActionTitle": "Action required",
|
||||||
|
"checkoutActionBody": "Complete your payment to activate the package.",
|
||||||
|
"checkoutActionBadge": "Action needed",
|
||||||
|
"checkoutActionButton": "Continue checkout",
|
||||||
|
"checkoutFailureReasons": {
|
||||||
|
"paddle_failed": "The payment was declined.",
|
||||||
|
"paddle_cancelled": "The checkout was cancelled."
|
||||||
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"invoices": {
|
"invoices": {
|
||||||
"title": "Invoices & payments",
|
"title": "Invoices & payments",
|
||||||
@@ -172,6 +193,8 @@
|
|||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"all": "All",
|
"all": "All",
|
||||||
|
"anonymous": "Anonymous",
|
||||||
|
"error": "Something went wrong",
|
||||||
"loadMore": "Load more",
|
"loadMore": "Load more",
|
||||||
"processing": "Processing…",
|
"processing": "Processing…",
|
||||||
"select": "Select",
|
"select": "Select",
|
||||||
@@ -858,15 +881,23 @@
|
|||||||
"mode": "Mode"
|
"mode": "Mode"
|
||||||
},
|
},
|
||||||
"mode": {
|
"mode": {
|
||||||
"title": "Choose your photobooth type",
|
"title": "Uploader connection",
|
||||||
"description": "Pick classic FTP or Sparkbooth HTTP upload. Switching regenerates credentials.",
|
"description": "Use the Fotospiel uploader app for live HTTP uploads. Rotating access regenerates credentials.",
|
||||||
"active": "Current: {{mode}}"
|
"active": "Current: {{mode}}",
|
||||||
|
"uploader": "Uploader App (HTTP)"
|
||||||
|
},
|
||||||
|
"selector": {
|
||||||
|
"title": "Connection",
|
||||||
|
"description": "Use the Fotospiel uploader app for HTTP uploads."
|
||||||
},
|
},
|
||||||
"credentials": {
|
"credentials": {
|
||||||
"heading": "FTP credentials",
|
"heading": "Uploader app credentials",
|
||||||
"description": "Share these credentials with your photobooth software.",
|
"description": "Share these credentials with the Fotospiel uploader app.",
|
||||||
"sparkboothTitle": "Sparkbooth upload (HTTP)",
|
"uploaderTitle": "Uploader App (HTTP)",
|
||||||
"sparkboothDescription": "Enter URL, username and password in Sparkbooth. Responses default to JSON (XML optional).",
|
"uploaderDescription": "Enter URL, username and password in the Fotospiel uploader app. Responses default to JSON (XML optional).",
|
||||||
|
"show": "Show credentials",
|
||||||
|
"hide": "Hide credentials",
|
||||||
|
"hidden": "Credentials are hidden. Tap to show them.",
|
||||||
"host": "Host",
|
"host": "Host",
|
||||||
"port": "Port",
|
"port": "Port",
|
||||||
"username": "Username",
|
"username": "Username",
|
||||||
@@ -875,6 +906,44 @@
|
|||||||
"postUrl": "Upload URL",
|
"postUrl": "Upload URL",
|
||||||
"responseFormat": "Response format"
|
"responseFormat": "Response format"
|
||||||
},
|
},
|
||||||
|
"connectCode": {
|
||||||
|
"label": "Connect code",
|
||||||
|
"description": "Create a 6-digit code for the uploader app.",
|
||||||
|
"expires": "Expires: {{date}}",
|
||||||
|
"actions": {
|
||||||
|
"generate": "Generate connect code",
|
||||||
|
"generated": "Connect code created"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"failed": "Connect code could not be created."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uploader": {
|
||||||
|
"hint": "POST with media file or base64 \"media\" field; app uses these credentials."
|
||||||
|
},
|
||||||
|
"steps": {
|
||||||
|
"activate": {
|
||||||
|
"title": "1. Activate photobooth",
|
||||||
|
"description": "Enable upload access for this event."
|
||||||
|
},
|
||||||
|
"download": {
|
||||||
|
"title": "2. Download uploader app"
|
||||||
|
},
|
||||||
|
"access": {
|
||||||
|
"title": "3. Generate connect code",
|
||||||
|
"description": "The code securely pairs the app with your event."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uploaderDownload": {
|
||||||
|
"title": "Fotospiel Uploader App",
|
||||||
|
"description": "The Fotospiel Uploader App is required so uploads stay stable, credentials remain protected, and no files are lost.",
|
||||||
|
"emailAction": "Send download links by email",
|
||||||
|
"emailSuccess": "Download links were sent by email.",
|
||||||
|
"emailFailed": "Email could not be sent.",
|
||||||
|
"actionWindows": "Download uploader (Windows)",
|
||||||
|
"actionMac": "Download uploader (macOS)",
|
||||||
|
"actionLinux": "Download uploader (Linux)"
|
||||||
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"enable": "Activate photobooth",
|
"enable": "Activate photobooth",
|
||||||
"disable": "Disable",
|
"disable": "Disable",
|
||||||
@@ -892,9 +961,9 @@
|
|||||||
"title": "Setup checklist",
|
"title": "Setup checklist",
|
||||||
"description": "Complete each step before guests upload.",
|
"description": "Complete each step before guests upload.",
|
||||||
"enable": "Activate access",
|
"enable": "Activate access",
|
||||||
"enableCopy": "Enable the FTP account in your photobooth software.",
|
"enableCopy": "Enable the uploader app connection for this event.",
|
||||||
"share": "Share credentials",
|
"share": "Share credentials",
|
||||||
"shareCopy": "Hand over host, user, and password to the operator.",
|
"shareCopy": "Share URL, username, and password with the operator.",
|
||||||
"monitor": "Monitor uploads",
|
"monitor": "Monitor uploads",
|
||||||
"monitorCopy": "Watch uploads & limits in the dashboard."
|
"monitorCopy": "Watch uploads & limits in the dashboard."
|
||||||
},
|
},
|
||||||
@@ -1405,7 +1474,7 @@
|
|||||||
"photobooth": {
|
"photobooth": {
|
||||||
"title": "Photobooth uploads",
|
"title": "Photobooth uploads",
|
||||||
"titleForEvent": "Manage photobooth uploads",
|
"titleForEvent": "Manage photobooth uploads",
|
||||||
"subtitle": "Create FTP access for photobooth software and keep limits in sight.",
|
"subtitle": "Create uploader access for photobooth apps and keep limits in sight.",
|
||||||
"actions": {
|
"actions": {
|
||||||
"backToEvent": "Back to detail view",
|
"backToEvent": "Back to detail view",
|
||||||
"allEvents": "Back to event list"
|
"allEvents": "Back to event list"
|
||||||
@@ -1853,23 +1922,63 @@
|
|||||||
"titleShort": "Branding",
|
"titleShort": "Branding",
|
||||||
"previewTitle": "Guest app preview",
|
"previewTitle": "Guest app preview",
|
||||||
"previewSubtitle": "Current colors & fonts",
|
"previewSubtitle": "Current colors & fonts",
|
||||||
|
"previewCta": "Upload photos",
|
||||||
"primary": "Primary",
|
"primary": "Primary",
|
||||||
"accent": "Accent",
|
"accent": "Accent",
|
||||||
|
"background": "Background",
|
||||||
|
"surface": "Surface",
|
||||||
|
"lockedBranding": "Branding is locked for this package.",
|
||||||
|
"source": "Branding source",
|
||||||
|
"sourceHint": "Use tenant branding or override for this event.",
|
||||||
|
"useDefault": "Tenant",
|
||||||
|
"useCustom": "Event",
|
||||||
|
"usingDefault": "Tenant branding active",
|
||||||
|
"usingCustom": "Event branding active",
|
||||||
|
"mode": "Theme",
|
||||||
|
"modeLight": "Light",
|
||||||
|
"modeAuto": "Auto",
|
||||||
|
"modeDark": "Dark",
|
||||||
"colors": "Colors",
|
"colors": "Colors",
|
||||||
"primaryColor": "Primary color",
|
"primaryColor": "Primary color",
|
||||||
"accentColor": "Accent color",
|
"accentColor": "Accent color",
|
||||||
|
"backgroundColor": "Background color",
|
||||||
|
"surfaceColor": "Surface color",
|
||||||
"fonts": "Fonts",
|
"fonts": "Fonts",
|
||||||
"headingFont": "Headline font",
|
"headingFont": "Headline font",
|
||||||
"headingFontPlaceholder": "SF Pro Display",
|
"headingFontPlaceholder": "SF Pro Display",
|
||||||
"bodyFont": "Body font",
|
"bodyFont": "Body font",
|
||||||
"bodyFontPlaceholder": "SF Pro Text",
|
"bodyFontPlaceholder": "SF Pro Text",
|
||||||
|
"fontSize": "Font size",
|
||||||
|
"fontSizeSmall": "S",
|
||||||
|
"fontSizeMedium": "M",
|
||||||
|
"fontSizeLarge": "L",
|
||||||
"logo": "Logo",
|
"logo": "Logo",
|
||||||
"logoAlt": "Logo",
|
"logoAlt": "Logo",
|
||||||
|
"logoModeUpload": "Upload",
|
||||||
|
"logoModeEmoticon": "Emoticon",
|
||||||
|
"logoValue": "Emoticon",
|
||||||
|
"logoValuePlaceholder": "🎉",
|
||||||
|
"logoPosition": "Position",
|
||||||
|
"positionLeft": "Left",
|
||||||
|
"positionCenter": "Center",
|
||||||
|
"positionRight": "Right",
|
||||||
|
"logoSize": "Size",
|
||||||
|
"logoSizeSmall": "S",
|
||||||
|
"logoSizeMedium": "M",
|
||||||
|
"logoSizeLarge": "L",
|
||||||
"replaceLogo": "Replace logo",
|
"replaceLogo": "Replace logo",
|
||||||
"removeLogo": "Remove",
|
"removeLogo": "Remove",
|
||||||
"logoHint": "Upload a logo to brand guest invites and QR posters.",
|
"logoHint": "Upload a logo or use an emoji for the guest header.",
|
||||||
"uploadLogo": "Upload logo (max. 1 MB)",
|
"uploadLogo": "Upload logo (max. 1 MB)",
|
||||||
"logoTooLarge": "Logo must be under 1 MB.",
|
"logoTooLarge": "Logo must be under 1 MB.",
|
||||||
|
"buttons": "Buttons & links",
|
||||||
|
"buttonsHint": "Style, radius, and link color for CTA buttons.",
|
||||||
|
"buttonFilled": "Filled",
|
||||||
|
"buttonOutline": "Outline",
|
||||||
|
"buttonRadius": "Radius",
|
||||||
|
"buttonPrimary": "Button primary",
|
||||||
|
"buttonSecondary": "Button secondary",
|
||||||
|
"linkColor": "Link color",
|
||||||
"save": "Save branding",
|
"save": "Save branding",
|
||||||
"saving": "Saving...",
|
"saving": "Saving...",
|
||||||
"saveSuccess": "Branding saved.",
|
"saveSuccess": "Branding saved.",
|
||||||
@@ -2327,7 +2436,7 @@
|
|||||||
"mobileProfile": {
|
"mobileProfile": {
|
||||||
"title": "Profile",
|
"title": "Profile",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"account": "Account & security",
|
"account": "Edit account",
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
"languageDe": "Deutsch",
|
"languageDe": "Deutsch",
|
||||||
"languageEn": "English",
|
"languageEn": "English",
|
||||||
@@ -2879,16 +2988,25 @@
|
|||||||
"analytics": {
|
"analytics": {
|
||||||
"title": "Analytics",
|
"title": "Analytics",
|
||||||
"upgradeAction": "Upgrade to Premium",
|
"upgradeAction": "Upgrade to Premium",
|
||||||
|
"kpiTitle": "Event snapshot",
|
||||||
|
"kpiUploads": "Uploads",
|
||||||
|
"kpiContributors": "Contributors",
|
||||||
|
"kpiLikes": "Likes",
|
||||||
"activityTitle": "Activity Timeline",
|
"activityTitle": "Activity Timeline",
|
||||||
|
"timeframe": "Last {{hours}} hours",
|
||||||
|
"timeframeHint": "Older activity hidden",
|
||||||
"uploadsPerHour": "Uploads per hour",
|
"uploadsPerHour": "Uploads per hour",
|
||||||
"noActivity": "No uploads yet",
|
"noActivity": "No uploads yet",
|
||||||
|
"emptyActionShareQr": "Share your QR code",
|
||||||
"contributorsTitle": "Top Contributors",
|
"contributorsTitle": "Top Contributors",
|
||||||
"likesCount": "{{count}} likes",
|
"likesCount": "{{count}} likes",
|
||||||
"likesCount_one": "{{count}} like",
|
"likesCount_one": "{{count}} like",
|
||||||
"likesCount_other": "{{count}} likes",
|
"likesCount_other": "{{count}} likes",
|
||||||
"noContributors": "No contributors yet",
|
"noContributors": "No contributors yet",
|
||||||
|
"emptyActionInvite": "Invite guests",
|
||||||
"tasksTitle": "Popular Tasks",
|
"tasksTitle": "Popular Tasks",
|
||||||
"noTasks": "No task activity yet",
|
"noTasks": "No task activity yet",
|
||||||
|
"emptyActionOpenTasks": "Open tasks",
|
||||||
"lockedTitle": "Unlock Analytics",
|
"lockedTitle": "Unlock Analytics",
|
||||||
"lockedBody": "Get deep insights into your event engagement with the Premium package."
|
"lockedBody": "Get deep insights into your event engagement with the Premium package."
|
||||||
},
|
},
|
||||||
@@ -2897,6 +3015,26 @@
|
|||||||
"subtitle": "Choose a package to unlock more features and limits.",
|
"subtitle": "Choose a package to unlock more features and limits.",
|
||||||
"recommendationTitle": "Recommended for you",
|
"recommendationTitle": "Recommended for you",
|
||||||
"recommendationBody": "The highlighted package includes the feature you requested.",
|
"recommendationBody": "The highlighted package includes the feature you requested.",
|
||||||
|
"compare": {
|
||||||
|
"title": "Compare plans",
|
||||||
|
"helper": "Swipe to compare packages side by side.",
|
||||||
|
"toggleCards": "Cards",
|
||||||
|
"toggleCompare": "Compare",
|
||||||
|
"headers": {
|
||||||
|
"plan": "Plan",
|
||||||
|
"price": "Price"
|
||||||
|
},
|
||||||
|
"rows": {
|
||||||
|
"photos": "Photos",
|
||||||
|
"guests": "Guests",
|
||||||
|
"days": "Gallery days"
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"included": "Included",
|
||||||
|
"notIncluded": "Not included",
|
||||||
|
"unlimited": "Unlimited"
|
||||||
|
}
|
||||||
|
},
|
||||||
"select": "Select",
|
"select": "Select",
|
||||||
"manage": "Manage Plan",
|
"manage": "Manage Plan",
|
||||||
"limits": {
|
"limits": {
|
||||||
@@ -2910,7 +3048,13 @@
|
|||||||
},
|
},
|
||||||
"features": {
|
"features": {
|
||||||
"advanced_analytics": "Advanced Analytics",
|
"advanced_analytics": "Advanced Analytics",
|
||||||
|
"basic_uploads": "Basic uploads",
|
||||||
"custom_branding": "Custom Branding",
|
"custom_branding": "Custom Branding",
|
||||||
|
"custom_tasks": "Custom tasks",
|
||||||
|
"limited_sharing": "Limited sharing",
|
||||||
|
"live_slideshow": "Live slideshow",
|
||||||
|
"priority_support": "Priority support",
|
||||||
|
"unlimited_sharing": "Unlimited sharing",
|
||||||
"watermark_removal": "No Watermark"
|
"watermark_removal": "No Watermark"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
@@ -2922,7 +3066,9 @@
|
|||||||
},
|
},
|
||||||
"badges": {
|
"badges": {
|
||||||
"recommended": "Recommended",
|
"recommended": "Recommended",
|
||||||
"active": "Active"
|
"active": "Active",
|
||||||
|
"upgrade": "Upgrade",
|
||||||
|
"downgrade": "Downgrade"
|
||||||
},
|
},
|
||||||
"confirmTitle": "Confirm Purchase",
|
"confirmTitle": "Confirm Purchase",
|
||||||
"confirmSubtitle": "You are upgrading to:",
|
"confirmSubtitle": "You are upgrading to:",
|
||||||
@@ -2935,6 +3081,7 @@
|
|||||||
"payNow": "Pay Now",
|
"payNow": "Pay Now",
|
||||||
"errors": {
|
"errors": {
|
||||||
"checkout": "Checkout failed"
|
"checkout": "Checkout failed"
|
||||||
}
|
},
|
||||||
|
"selectDisabled": "Not available"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"profile": {
|
"profile": {
|
||||||
"title": "Profile",
|
"title": "Profile",
|
||||||
"subtitle": "Manage your account details and credentials.",
|
"subtitle": "Manage your account details and credentials.",
|
||||||
|
"loading": "Loading ...",
|
||||||
"sections": {
|
"sections": {
|
||||||
"account": {
|
"account": {
|
||||||
"heading": "Account information",
|
"heading": "Account information",
|
||||||
|
|||||||
123
resources/js/admin/lib/__tests__/brandingForm.test.ts
Normal file
123
resources/js/admin/lib/__tests__/brandingForm.test.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { extractBrandingForm } from '../brandingForm';
|
||||||
|
|
||||||
|
const defaults = {
|
||||||
|
primary: '#111111',
|
||||||
|
accent: '#222222',
|
||||||
|
background: '#ffffff',
|
||||||
|
surface: '#f0f0f0',
|
||||||
|
mode: 'auto' as const,
|
||||||
|
buttonStyle: 'filled' as const,
|
||||||
|
buttonRadius: 12,
|
||||||
|
buttonPrimary: '#111111',
|
||||||
|
buttonSecondary: '#222222',
|
||||||
|
linkColor: '#222222',
|
||||||
|
fontSize: 'm' as const,
|
||||||
|
logoMode: 'upload' as const,
|
||||||
|
logoPosition: 'left' as const,
|
||||||
|
logoSize: 'm' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('extractBrandingForm', () => {
|
||||||
|
it('prefers palette values when available', () => {
|
||||||
|
const settings = {
|
||||||
|
branding: {
|
||||||
|
palette: {
|
||||||
|
primary: '#aa0000',
|
||||||
|
secondary: '#00aa00',
|
||||||
|
background: '#000000',
|
||||||
|
surface: '#111111',
|
||||||
|
},
|
||||||
|
primary_color: '#bbbbbb',
|
||||||
|
secondary_color: '#cccccc',
|
||||||
|
background_color: '#dddddd',
|
||||||
|
surface_color: '#eeeeee',
|
||||||
|
mode: 'dark',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = extractBrandingForm(settings, defaults);
|
||||||
|
|
||||||
|
expect(result.primary).toBe('#aa0000');
|
||||||
|
expect(result.accent).toBe('#00aa00');
|
||||||
|
expect(result.background).toBe('#000000');
|
||||||
|
expect(result.surface).toBe('#111111');
|
||||||
|
expect(result.mode).toBe('dark');
|
||||||
|
expect(result.fontSize).toBe('m');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to legacy keys and defaults', () => {
|
||||||
|
const settings = {
|
||||||
|
branding: {
|
||||||
|
accent_color: '#123456',
|
||||||
|
background_color: '#abcdef',
|
||||||
|
mode: 'light',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = extractBrandingForm(settings, defaults);
|
||||||
|
|
||||||
|
expect(result.primary).toBe(defaults.primary);
|
||||||
|
expect(result.accent).toBe('#123456');
|
||||||
|
expect(result.background).toBe('#abcdef');
|
||||||
|
expect(result.surface).toBe('#abcdef');
|
||||||
|
expect(result.mode).toBe('light');
|
||||||
|
expect(result.buttonStyle).toBe(defaults.buttonStyle);
|
||||||
|
expect(result.buttonRadius).toBe(defaults.buttonRadius);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts buttons, logo, and typography settings', () => {
|
||||||
|
const settings = {
|
||||||
|
branding: {
|
||||||
|
typography: {
|
||||||
|
heading: 'Display Font',
|
||||||
|
body: 'Body Font',
|
||||||
|
size: 'l',
|
||||||
|
},
|
||||||
|
buttons: {
|
||||||
|
style: 'outline',
|
||||||
|
radius: 24,
|
||||||
|
primary: '#333333',
|
||||||
|
secondary: '#444444',
|
||||||
|
link_color: '#555555',
|
||||||
|
},
|
||||||
|
logo: {
|
||||||
|
mode: 'emoticon',
|
||||||
|
value: '🎉',
|
||||||
|
position: 'center',
|
||||||
|
size: 'l',
|
||||||
|
},
|
||||||
|
use_default_branding: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = extractBrandingForm(settings, defaults);
|
||||||
|
|
||||||
|
expect(result.headingFont).toBe('Display Font');
|
||||||
|
expect(result.bodyFont).toBe('Body Font');
|
||||||
|
expect(result.fontSize).toBe('l');
|
||||||
|
expect(result.buttonStyle).toBe('outline');
|
||||||
|
expect(result.buttonRadius).toBe(24);
|
||||||
|
expect(result.buttonPrimary).toBe('#333333');
|
||||||
|
expect(result.buttonSecondary).toBe('#444444');
|
||||||
|
expect(result.linkColor).toBe('#555555');
|
||||||
|
expect(result.logoMode).toBe('emoticon');
|
||||||
|
expect(result.logoValue).toBe('🎉');
|
||||||
|
expect(result.logoPosition).toBe('center');
|
||||||
|
expect(result.logoSize).toBe('l');
|
||||||
|
expect(result.useDefaultBranding).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes stored logo paths for previews', () => {
|
||||||
|
const settings = {
|
||||||
|
branding: {
|
||||||
|
logo_url: 'branding/logos/event-123.png',
|
||||||
|
logo_mode: 'upload',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = extractBrandingForm(settings, defaults);
|
||||||
|
|
||||||
|
expect(result.logoDataUrl).toBe('/storage/branding/logos/event-123.png');
|
||||||
|
});
|
||||||
|
});
|
||||||
149
resources/js/admin/lib/brandingForm.ts
Normal file
149
resources/js/admin/lib/brandingForm.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
export type BrandingFormValues = {
|
||||||
|
primary: string;
|
||||||
|
accent: string;
|
||||||
|
background: string;
|
||||||
|
surface: string;
|
||||||
|
headingFont: string;
|
||||||
|
bodyFont: string;
|
||||||
|
fontSize: 's' | 'm' | 'l';
|
||||||
|
logoDataUrl: string;
|
||||||
|
logoValue: string;
|
||||||
|
logoMode: 'upload' | 'emoticon';
|
||||||
|
logoPosition: 'left' | 'center' | 'right';
|
||||||
|
logoSize: 's' | 'm' | 'l';
|
||||||
|
mode: 'light' | 'dark' | 'auto';
|
||||||
|
buttonStyle: 'filled' | 'outline';
|
||||||
|
buttonRadius: number;
|
||||||
|
buttonPrimary: string;
|
||||||
|
buttonSecondary: string;
|
||||||
|
linkColor: string;
|
||||||
|
useDefaultBranding: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BrandingFormDefaults = Pick<
|
||||||
|
BrandingFormValues,
|
||||||
|
| 'primary'
|
||||||
|
| 'accent'
|
||||||
|
| 'background'
|
||||||
|
| 'surface'
|
||||||
|
| 'mode'
|
||||||
|
| 'buttonStyle'
|
||||||
|
| 'buttonRadius'
|
||||||
|
| 'buttonPrimary'
|
||||||
|
| 'buttonSecondary'
|
||||||
|
| 'linkColor'
|
||||||
|
| 'fontSize'
|
||||||
|
| 'logoMode'
|
||||||
|
| 'logoPosition'
|
||||||
|
| 'logoSize'
|
||||||
|
>;
|
||||||
|
|
||||||
|
type BrandingRecord = Record<string, unknown>;
|
||||||
|
|
||||||
|
const isRecord = (value: unknown): value is BrandingRecord =>
|
||||||
|
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||||
|
|
||||||
|
const readHexColor = (value: unknown, fallback: string): string => {
|
||||||
|
if (typeof value === 'string' && value.trim().startsWith('#')) {
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
const readEnum = <T extends string>(value: unknown, allowed: readonly T[], fallback: T): T => (
|
||||||
|
allowed.includes(value as T) ? (value as T) : fallback
|
||||||
|
);
|
||||||
|
|
||||||
|
const readNumber = (value: unknown, fallback: number): number => (
|
||||||
|
typeof value === 'number' && !Number.isNaN(value) ? value : fallback
|
||||||
|
);
|
||||||
|
|
||||||
|
const resolveAssetPreviewUrl = (value: string | null | undefined): string => {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed.startsWith('data:') || trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = trimmed.startsWith('/') ? trimmed.slice(1) : trimmed;
|
||||||
|
|
||||||
|
if (normalized.startsWith('storage/')) {
|
||||||
|
return `/${normalized}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.startsWith('branding/') || normalized.startsWith('tenant-branding/')) {
|
||||||
|
return `/storage/${normalized}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function extractBrandingForm(settings: unknown, defaults: BrandingFormDefaults): BrandingFormValues {
|
||||||
|
const settingsRecord = isRecord(settings) ? settings : {};
|
||||||
|
const branding = isRecord(settingsRecord.branding) ? (settingsRecord.branding as BrandingRecord) : settingsRecord;
|
||||||
|
const palette = isRecord(branding.palette) ? (branding.palette as BrandingRecord) : {};
|
||||||
|
const typography = isRecord(branding.typography) ? (branding.typography as BrandingRecord) : {};
|
||||||
|
const buttons = isRecord(branding.buttons) ? (branding.buttons as BrandingRecord) : {};
|
||||||
|
const logo = isRecord(branding.logo) ? (branding.logo as BrandingRecord) : {};
|
||||||
|
|
||||||
|
const primary = readHexColor(palette.primary, readHexColor(branding.primary_color, defaults.primary));
|
||||||
|
const accent = readHexColor(
|
||||||
|
palette.secondary,
|
||||||
|
readHexColor(branding.secondary_color, readHexColor(branding.accent_color, defaults.accent))
|
||||||
|
);
|
||||||
|
const background = readHexColor(palette.background, readHexColor(branding.background_color, defaults.background));
|
||||||
|
const surface = readHexColor(palette.surface, readHexColor(branding.surface_color, background));
|
||||||
|
|
||||||
|
const headingFont = typeof typography.heading === 'string' ? typography.heading : (branding.heading_font as string | undefined);
|
||||||
|
const bodyFont = typeof typography.body === 'string' ? typography.body : (branding.body_font as string | undefined);
|
||||||
|
const mode = readEnum(branding.mode, ['light', 'dark', 'auto'], defaults.mode);
|
||||||
|
const fontSize = readEnum(typography.size ?? branding.font_size, ['s', 'm', 'l'], defaults.fontSize);
|
||||||
|
|
||||||
|
const logoMode = readEnum(logo.mode ?? branding.logo_mode, ['upload', 'emoticon'], defaults.logoMode);
|
||||||
|
const logoValue = logoMode === 'emoticon'
|
||||||
|
? (typeof logo.value === 'string' ? logo.value : (branding.logo_value as string | undefined) ?? '')
|
||||||
|
: '';
|
||||||
|
const logoPosition = readEnum(logo.position ?? branding.logo_position, ['left', 'center', 'right'], defaults.logoPosition);
|
||||||
|
const logoSize = readEnum(logo.size ?? branding.logo_size, ['s', 'm', 'l'], defaults.logoSize);
|
||||||
|
const logoUploadValue =
|
||||||
|
typeof branding.logo_data_url === 'string'
|
||||||
|
? branding.logo_data_url
|
||||||
|
: logoMode === 'upload'
|
||||||
|
? (typeof logo.value === 'string' ? logo.value : (branding.logo_url as string | undefined))
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const buttonStyle = readEnum(buttons.style ?? branding.button_style, ['filled', 'outline'], defaults.buttonStyle);
|
||||||
|
const buttonRadius = readNumber(buttons.radius ?? branding.button_radius, defaults.buttonRadius);
|
||||||
|
const buttonPrimary = readHexColor(buttons.primary, readHexColor(branding.button_primary_color, primary));
|
||||||
|
const buttonSecondary = readHexColor(buttons.secondary, readHexColor(branding.button_secondary_color, accent));
|
||||||
|
const linkColor = readHexColor(buttons.link_color ?? buttons.linkColor, readHexColor(branding.link_color, accent));
|
||||||
|
|
||||||
|
return {
|
||||||
|
primary,
|
||||||
|
accent,
|
||||||
|
background,
|
||||||
|
surface,
|
||||||
|
headingFont: headingFont ?? '',
|
||||||
|
bodyFont: bodyFont ?? '',
|
||||||
|
fontSize,
|
||||||
|
logoDataUrl: resolveAssetPreviewUrl(logoUploadValue),
|
||||||
|
logoValue,
|
||||||
|
logoMode,
|
||||||
|
logoPosition,
|
||||||
|
logoSize,
|
||||||
|
mode,
|
||||||
|
buttonStyle,
|
||||||
|
buttonRadius,
|
||||||
|
buttonPrimary,
|
||||||
|
buttonSecondary,
|
||||||
|
linkColor,
|
||||||
|
useDefaultBranding: branding.use_default_branding === true,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ export type EventTabCounts = Partial<{
|
|||||||
tasks: number;
|
tasks: number;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
type Translator = (key: string, fallback: string) => string;
|
type Translator = any;
|
||||||
|
|
||||||
export function buildEventTabs(event: TenantEvent, translate: Translator, counts: EventTabCounts = {}) {
|
export function buildEventTabs(event: TenantEvent, translate: Translator, counts: EventTabCounts = {}) {
|
||||||
if (!event.slug) {
|
if (!event.slug) {
|
||||||
|
|||||||
@@ -48,6 +48,29 @@ export function formatEventDate(value?: string | null, locale = 'de-DE'): string
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatEventDateTime(value?: string | null, locale = 'de-DE'): string | null {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return new Intl.DateTimeFormat(locale, {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}).format(date);
|
||||||
|
} catch {
|
||||||
|
return date.toISOString().slice(0, 16).replace('T', ' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveEngagementMode(event?: TenantEvent | null): 'tasks' | 'photo_only' | null {
|
export function resolveEngagementMode(event?: TenantEvent | null): 'tasks' | 'photo_only' | null {
|
||||||
if (!event) {
|
if (!event) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -8,20 +8,11 @@ import { Pressable } from '@tamagui/react-native-web-lite';
|
|||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import { MobileShell, HeaderActionButton } from './components/MobileShell';
|
import { MobileShell, HeaderActionButton } from './components/MobileShell';
|
||||||
import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
|
import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
|
||||||
import React from 'react';
|
|
||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Package, Receipt, RefreshCcw, Sparkles } from 'lucide-react';
|
|
||||||
import { YStack, XStack } from '@tamagui/stacks';
|
|
||||||
import { SizableText as Text } from '@tamagui/text';
|
|
||||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
|
||||||
import toast from 'react-hot-toast';
|
|
||||||
import { MobileShell, HeaderActionButton } from './components/MobileShell';
|
|
||||||
import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
|
|
||||||
import {
|
import {
|
||||||
createTenantBillingPortalSession,
|
createTenantBillingPortalSession,
|
||||||
getTenantPackagesOverview,
|
getTenantPackagesOverview,
|
||||||
getTenantPaddleTransactions,
|
getTenantPaddleTransactions,
|
||||||
|
getTenantPackageCheckoutStatus,
|
||||||
TenantPackageSummary,
|
TenantPackageSummary,
|
||||||
PaddleTransactionSummary,
|
PaddleTransactionSummary,
|
||||||
} from '../api';
|
} from '../api';
|
||||||
@@ -37,6 +28,14 @@ import {
|
|||||||
getPackageFeatureLabel,
|
getPackageFeatureLabel,
|
||||||
getPackageLimitEntries,
|
getPackageLimitEntries,
|
||||||
} from './lib/packageSummary';
|
} from './lib/packageSummary';
|
||||||
|
import {
|
||||||
|
PendingCheckout,
|
||||||
|
loadPendingCheckout,
|
||||||
|
shouldClearPendingCheckout,
|
||||||
|
storePendingCheckout,
|
||||||
|
} from './lib/billingCheckout';
|
||||||
|
|
||||||
|
const CHECKOUT_POLL_INTERVAL_MS = 10000;
|
||||||
|
|
||||||
export default function MobileBillingPage() {
|
export default function MobileBillingPage() {
|
||||||
const { t } = useTranslation('management');
|
const { t } = useTranslation('management');
|
||||||
@@ -50,6 +49,11 @@ export default function MobileBillingPage() {
|
|||||||
const [loading, setLoading] = React.useState(true);
|
const [loading, setLoading] = React.useState(true);
|
||||||
const [error, setError] = React.useState<string | null>(null);
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
const [portalBusy, setPortalBusy] = React.useState(false);
|
const [portalBusy, setPortalBusy] = React.useState(false);
|
||||||
|
const [pendingCheckout, setPendingCheckout] = React.useState<PendingCheckout | null>(() => loadPendingCheckout());
|
||||||
|
const [checkoutStatus, setCheckoutStatus] = React.useState<string | null>(null);
|
||||||
|
const [checkoutStatusReason, setCheckoutStatusReason] = React.useState<string | null>(null);
|
||||||
|
const [checkoutActionUrl, setCheckoutActionUrl] = React.useState<string | null>(null);
|
||||||
|
const lastCheckoutStatusRef = React.useRef<string | null>(null);
|
||||||
const packagesRef = React.useRef<HTMLDivElement | null>(null);
|
const packagesRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
const invoicesRef = React.useRef<HTMLDivElement | null>(null);
|
const invoicesRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
const supportEmail = 'support@fotospiel.de';
|
const supportEmail = 'support@fotospiel.de';
|
||||||
@@ -105,6 +109,11 @@ export default function MobileBillingPage() {
|
|||||||
}
|
}
|
||||||
}, [portalBusy, t]);
|
}, [portalBusy, t]);
|
||||||
|
|
||||||
|
const persistPendingCheckout = React.useCallback((next: PendingCheckout | null) => {
|
||||||
|
setPendingCheckout(next);
|
||||||
|
storePendingCheckout(next);
|
||||||
|
}, []);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
void load();
|
void load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
@@ -118,6 +127,115 @@ export default function MobileBillingPage() {
|
|||||||
}
|
}
|
||||||
}, [location.hash, loading]);
|
}, [location.hash, loading]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!location.search) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
const checkout = params.get('checkout');
|
||||||
|
const packageId = params.get('package_id');
|
||||||
|
if (!checkout) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (checkout === 'success') {
|
||||||
|
const packageIdNumber = packageId ? Number(packageId) : null;
|
||||||
|
const existingSessionId = pendingCheckout?.checkoutSessionId ?? null;
|
||||||
|
const pendingEntry = {
|
||||||
|
packageId: Number.isFinite(packageIdNumber) ? packageIdNumber : null,
|
||||||
|
checkoutSessionId: existingSessionId,
|
||||||
|
startedAt: Date.now(),
|
||||||
|
};
|
||||||
|
persistPendingCheckout(pendingEntry);
|
||||||
|
toast.success(t('billing.checkoutSuccess', 'Checkout completed. Your package will activate shortly.'));
|
||||||
|
} else if (checkout === 'cancel') {
|
||||||
|
persistPendingCheckout(null);
|
||||||
|
toast(t('billing.checkoutCancelled', 'Checkout was cancelled.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
params.delete('checkout');
|
||||||
|
params.delete('package_id');
|
||||||
|
navigate(
|
||||||
|
{
|
||||||
|
pathname: location.pathname,
|
||||||
|
search: params.toString(),
|
||||||
|
hash: location.hash,
|
||||||
|
},
|
||||||
|
{ replace: true },
|
||||||
|
);
|
||||||
|
}, [location.hash, location.pathname, location.search, navigate, pendingCheckout?.checkoutSessionId, persistPendingCheckout, t]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!pendingCheckout) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldClearPendingCheckout(pendingCheckout, activePackage?.package_id ?? null)) {
|
||||||
|
persistPendingCheckout(null);
|
||||||
|
}
|
||||||
|
}, [activePackage?.package_id, pendingCheckout, persistPendingCheckout]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!pendingCheckout?.checkoutSessionId) {
|
||||||
|
setCheckoutStatus(null);
|
||||||
|
setCheckoutStatusReason(null);
|
||||||
|
setCheckoutActionUrl(null);
|
||||||
|
lastCheckoutStatusRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let active = true;
|
||||||
|
let intervalId: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
const poll = async () => {
|
||||||
|
try {
|
||||||
|
const result = await getTenantPackageCheckoutStatus(pendingCheckout.checkoutSessionId as string);
|
||||||
|
if (!active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCheckoutStatus(result.status);
|
||||||
|
setCheckoutStatusReason(result.reason ?? null);
|
||||||
|
setCheckoutActionUrl(typeof result.checkout_url === 'string' ? result.checkout_url : null);
|
||||||
|
|
||||||
|
const lastStatus = lastCheckoutStatusRef.current;
|
||||||
|
lastCheckoutStatusRef.current = result.status;
|
||||||
|
|
||||||
|
if (result.status === 'completed') {
|
||||||
|
persistPendingCheckout(null);
|
||||||
|
if (lastStatus !== 'completed') {
|
||||||
|
toast.success(t('billing.checkoutActivated', 'Your package is now active.'));
|
||||||
|
}
|
||||||
|
await load();
|
||||||
|
if (intervalId) {
|
||||||
|
clearInterval(intervalId);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status === 'failed' || result.status === 'cancelled') {
|
||||||
|
if (intervalId) {
|
||||||
|
clearInterval(intervalId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void poll();
|
||||||
|
intervalId = setInterval(poll, CHECKOUT_POLL_INTERVAL_MS);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
if (intervalId) {
|
||||||
|
clearInterval(intervalId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [load, pendingCheckout?.checkoutSessionId, persistPendingCheckout, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MobileShell
|
<MobileShell
|
||||||
activeTab="profile"
|
activeTab="profile"
|
||||||
@@ -137,6 +255,109 @@ export default function MobileBillingPage() {
|
|||||||
<CTAButton label={t('billing.actions.refresh', 'Refresh')} tone="ghost" onPress={load} />
|
<CTAButton label={t('billing.actions.refresh', 'Refresh')} tone="ghost" onPress={load} />
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
) : null}
|
) : null}
|
||||||
|
{pendingCheckout && (checkoutStatus === 'failed' || checkoutStatus === 'cancelled') ? (
|
||||||
|
<MobileCard borderColor={danger} backgroundColor="$red1" space="$2">
|
||||||
|
<XStack alignItems="center" justifyContent="space-between">
|
||||||
|
<YStack space="$0.5" flex={1}>
|
||||||
|
<Text fontSize="$sm" fontWeight="800" color={danger}>
|
||||||
|
{t('billing.checkoutFailedTitle', 'Checkout failed')}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="$xs" color={muted}>
|
||||||
|
{t(
|
||||||
|
'billing.checkoutFailedBody',
|
||||||
|
'The payment did not complete. You can try again or contact support.'
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
{checkoutStatusReason ? (
|
||||||
|
<Text fontSize="$xs" color={muted}>
|
||||||
|
{t(`billing.checkoutFailureReasons.${checkoutStatusReason}`, checkoutStatusReason)}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</YStack>
|
||||||
|
<PillBadge tone="danger">
|
||||||
|
{t('billing.checkoutFailedBadge', 'Failed')}
|
||||||
|
</PillBadge>
|
||||||
|
</XStack>
|
||||||
|
<XStack space="$2">
|
||||||
|
<CTAButton
|
||||||
|
label={t('billing.checkoutFailedRetry', 'Try again')}
|
||||||
|
onPress={() => navigate(adminPath('/mobile/billing/shop'))}
|
||||||
|
fullWidth={false}
|
||||||
|
/>
|
||||||
|
<CTAButton
|
||||||
|
label={t('billing.checkoutFailedDismiss', 'Dismiss')}
|
||||||
|
tone="ghost"
|
||||||
|
onPress={() => persistPendingCheckout(null)}
|
||||||
|
fullWidth={false}
|
||||||
|
/>
|
||||||
|
</XStack>
|
||||||
|
</MobileCard>
|
||||||
|
) : null}
|
||||||
|
{pendingCheckout && checkoutStatus === 'requires_customer_action' ? (
|
||||||
|
<MobileCard borderColor={accentSoft} backgroundColor={accentSoft} space="$2">
|
||||||
|
<XStack alignItems="center" justifyContent="space-between">
|
||||||
|
<YStack space="$0.5" flex={1}>
|
||||||
|
<Text fontSize="$sm" fontWeight="800" color={textStrong}>
|
||||||
|
{t('billing.checkoutActionTitle', 'Action required')}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="$xs" color={muted}>
|
||||||
|
{t('billing.checkoutActionBody', 'Complete your payment to activate the package.')}
|
||||||
|
</Text>
|
||||||
|
</YStack>
|
||||||
|
<PillBadge tone="warning">
|
||||||
|
{t('billing.checkoutActionBadge', 'Action needed')}
|
||||||
|
</PillBadge>
|
||||||
|
</XStack>
|
||||||
|
<XStack space="$2">
|
||||||
|
<CTAButton
|
||||||
|
label={t('billing.checkoutActionButton', 'Continue checkout')}
|
||||||
|
onPress={() => {
|
||||||
|
if (checkoutActionUrl && typeof window !== 'undefined') {
|
||||||
|
window.open(checkoutActionUrl, '_blank', 'noopener');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigate(adminPath('/mobile/billing/shop'));
|
||||||
|
}}
|
||||||
|
fullWidth={false}
|
||||||
|
/>
|
||||||
|
<CTAButton
|
||||||
|
label={t('billing.checkoutFailedDismiss', 'Dismiss')}
|
||||||
|
tone="ghost"
|
||||||
|
onPress={() => persistPendingCheckout(null)}
|
||||||
|
fullWidth={false}
|
||||||
|
/>
|
||||||
|
</XStack>
|
||||||
|
</MobileCard>
|
||||||
|
) : null}
|
||||||
|
{pendingCheckout && checkoutStatus !== 'failed' && checkoutStatus !== 'cancelled' && checkoutStatus !== 'requires_customer_action' ? (
|
||||||
|
<MobileCard borderColor={accentSoft} backgroundColor={accentSoft} space="$2">
|
||||||
|
<XStack alignItems="center" justifyContent="space-between">
|
||||||
|
<YStack space="$0.5" flex={1}>
|
||||||
|
<Text fontSize="$sm" fontWeight="800" color={textStrong}>
|
||||||
|
{t('billing.checkoutPendingTitle', 'Activating your package')}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="$xs" color={muted}>
|
||||||
|
{t(
|
||||||
|
'billing.checkoutPendingBody',
|
||||||
|
'This can take a few minutes. We will update this screen once the package is active.'
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</YStack>
|
||||||
|
<PillBadge tone="warning">
|
||||||
|
{t('billing.checkoutPendingBadge', 'Pending')}
|
||||||
|
</PillBadge>
|
||||||
|
</XStack>
|
||||||
|
<XStack space="$2">
|
||||||
|
<CTAButton label={t('billing.checkoutPendingRefresh', 'Refresh')} onPress={load} fullWidth={false} />
|
||||||
|
<CTAButton
|
||||||
|
label={t('billing.checkoutPendingDismiss', 'Dismiss')}
|
||||||
|
tone="ghost"
|
||||||
|
onPress={() => persistPendingCheckout(null)}
|
||||||
|
fullWidth={false}
|
||||||
|
/>
|
||||||
|
</XStack>
|
||||||
|
</MobileCard>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<MobileCard space="$2" ref={packagesRef as any}>
|
<MobileCard space="$2" ref={packagesRef as any}>
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" space="$2">
|
||||||
@@ -235,7 +456,6 @@ export default function MobileBillingPage() {
|
|||||||
))}
|
))}
|
||||||
</YStack>
|
</YStack>
|
||||||
)}
|
)}
|
||||||
{null}
|
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
|
|
||||||
<MobileCard space="$2">
|
<MobileCard space="$2">
|
||||||
@@ -263,7 +483,6 @@ export default function MobileBillingPage() {
|
|||||||
))}
|
))}
|
||||||
</YStack>
|
</YStack>
|
||||||
)}
|
)}
|
||||||
{null}
|
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
</MobileShell>
|
</MobileShell>
|
||||||
);
|
);
|
||||||
@@ -548,155 +767,3 @@ function formatDate(value: string | null | undefined): string {
|
|||||||
if (Number.isNaN(date.getTime())) return '—';
|
if (Number.isNaN(date.getTime())) return '—';
|
||||||
return date.toLocaleDateString(undefined, { day: '2-digit', month: 'short', year: 'numeric' });
|
return date.toLocaleDateString(undefined, { day: '2-digit', month: 'short', year: 'numeric' });
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderFeatureBadge(pkg: TenantPackageSummary, t: any, key: string, label: string) {
|
|
||||||
const value = (pkg.package_limits as any)?.[key] ?? (pkg as any)[key];
|
|
||||||
if (value === undefined || value === null) return null;
|
|
||||||
const enabled = value !== false;
|
|
||||||
return <PillBadge tone={enabled ? 'success' : 'muted'}>{enabled ? label : `${label} off`}</PillBadge>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function UsageBar({ metric }: { metric: PackageUsageMetric }) {
|
|
||||||
const { t } = useTranslation('management');
|
|
||||||
const { muted, textStrong, border, primary, subtle, warningText, danger } = useAdminTheme();
|
|
||||||
const labelMap: Record<PackageUsageMetric['key'], string> = {
|
|
||||||
events: t('mobileBilling.usage.events', 'Events'),
|
|
||||||
guests: t('mobileBilling.usage.guests', 'Guests'),
|
|
||||||
photos: t('mobileBilling.usage.photos', 'Photos'),
|
|
||||||
gallery: t('mobileBilling.usage.gallery', 'Gallery days'),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!metric.limit) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const status = getUsageState(metric);
|
|
||||||
const hasUsage = metric.used !== null;
|
|
||||||
const valueText = hasUsage
|
|
||||||
? t('mobileBilling.usage.value', { used: metric.used, limit: metric.limit })
|
|
||||||
: t('mobileBilling.usage.limit', { limit: metric.limit });
|
|
||||||
const remainingText = metric.remaining !== null
|
|
||||||
? t('mobileBilling.usage.remainingOf', {
|
|
||||||
remaining: metric.remaining,
|
|
||||||
limit: metric.limit,
|
|
||||||
defaultValue: 'Remaining {{remaining}} of {{limit}}',
|
|
||||||
})
|
|
||||||
: null;
|
|
||||||
const fill = usagePercent(metric);
|
|
||||||
const statusLabel =
|
|
||||||
status === 'danger'
|
|
||||||
? t('mobileBilling.usage.statusDanger', 'Limit reached')
|
|
||||||
: status === 'warning'
|
|
||||||
? t('mobileBilling.usage.statusWarning', 'Low')
|
|
||||||
: null;
|
|
||||||
const fillColor = status === 'danger' ? danger : status === 'warning' ? warningText : primary;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<YStack space="$1.5">
|
|
||||||
<XStack alignItems="center" justifyContent="space-between">
|
|
||||||
<Text fontSize="$xs" color={muted}>
|
|
||||||
{labelMap[metric.key]}
|
|
||||||
</Text>
|
|
||||||
<XStack alignItems="center" space="$1.5">
|
|
||||||
{statusLabel ? <PillBadge tone={status === 'danger' ? 'danger' : 'warning'}>{statusLabel}</PillBadge> : null}
|
|
||||||
<Text fontSize="$xs" color={textStrong} fontWeight="700">
|
|
||||||
{valueText}
|
|
||||||
</Text>
|
|
||||||
</XStack>
|
|
||||||
</XStack>
|
|
||||||
<YStack height={6} borderRadius={999} backgroundColor={border} overflow="hidden">
|
|
||||||
<YStack height="100%" width={`${fill}%`} backgroundColor={hasUsage ? fillColor : subtle} />
|
|
||||||
</YStack>
|
|
||||||
{remainingText ? (
|
|
||||||
<Text fontSize="$xs" color={muted}>
|
|
||||||
{remainingText}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
</YStack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatAmount(value: number | null | undefined, currency: string | null | undefined): string {
|
|
||||||
if (value === null || value === undefined) {
|
|
||||||
return '—';
|
|
||||||
}
|
|
||||||
const cur = currency ?? 'EUR';
|
|
||||||
try {
|
|
||||||
return new Intl.NumberFormat(undefined, { style: 'currency', currency: cur }).format(value);
|
|
||||||
} catch {
|
|
||||||
return `${value} ${cur}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function AddonRow({ addon }: { addon: TenantAddonHistoryEntry }) {
|
|
||||||
const { t } = useTranslation('management');
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { border, textStrong, text, muted, subtle, primary } = useAdminTheme();
|
|
||||||
const labels: Record<TenantAddonHistoryEntry['status'], { tone: 'success' | 'warning' | 'muted'; text: string }> = {
|
|
||||||
completed: { tone: 'success', text: t('mobileBilling.status.completed', 'Completed') },
|
|
||||||
pending: { tone: 'warning', text: t('mobileBilling.status.pending', 'Pending') },
|
|
||||||
failed: { tone: 'muted', text: t('mobileBilling.status.failed', 'Failed') },
|
|
||||||
};
|
|
||||||
const status = labels[addon.status];
|
|
||||||
const eventName =
|
|
||||||
(addon.event?.name && typeof addon.event.name === 'string' && addon.event.name) ||
|
|
||||||
(addon.event?.name && typeof addon.event.name === 'object' ? addon.event.name?.en ?? addon.event.name?.de ?? Object.values(addon.event.name)[0] : null) ||
|
|
||||||
null;
|
|
||||||
const eventPath = addon.event?.slug ? ADMIN_EVENT_VIEW_PATH(addon.event.slug) : null;
|
|
||||||
const hasImpact = Boolean(addon.extra_photos || addon.extra_guests || addon.extra_gallery_days);
|
|
||||||
const impactBadges = hasImpact ? (
|
|
||||||
<XStack space="$2" marginTop="$1.5" flexWrap="wrap">
|
|
||||||
{addon.extra_photos ? (
|
|
||||||
<PillBadge tone="muted">{t('mobileBilling.extra.photos', '+{{count}} photos', { count: addon.extra_photos })}</PillBadge>
|
|
||||||
) : null}
|
|
||||||
{addon.extra_guests ? (
|
|
||||||
<PillBadge tone="muted">{t('mobileBilling.extra.guests', '+{{count}} guests', { count: addon.extra_guests })}</PillBadge>
|
|
||||||
) : null}
|
|
||||||
{addon.extra_gallery_days ? (
|
|
||||||
<PillBadge tone="muted">{t('mobileBilling.extra.days', '+{{count}} days', { count: addon.extra_gallery_days })}</PillBadge>
|
|
||||||
) : null}
|
|
||||||
</XStack>
|
|
||||||
) : null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MobileCard borderColor={border} padding="$3" space="$1.5">
|
|
||||||
<XStack alignItems="center" justifyContent="space-between">
|
|
||||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
|
||||||
{addon.label ?? addon.addon_key}
|
|
||||||
</Text>
|
|
||||||
<PillBadge tone={status.tone}>{status.text}</PillBadge>
|
|
||||||
</XStack>
|
|
||||||
{eventName ? (
|
|
||||||
eventPath ? (
|
|
||||||
<Pressable onPress={() => navigate(eventPath)}>
|
|
||||||
<XStack alignItems="center" justifyContent="space-between">
|
|
||||||
<Text fontSize="$xs" color={textStrong} fontWeight="600">
|
|
||||||
{eventName}
|
|
||||||
</Text>
|
|
||||||
<Text fontSize="$xs" color={primary} fontWeight="700">
|
|
||||||
{t('mobileBilling.openEvent', 'Open event')}
|
|
||||||
</Text>
|
|
||||||
</XStack>
|
|
||||||
</Pressable>
|
|
||||||
) : (
|
|
||||||
<Text fontSize="$xs" color={subtle}>
|
|
||||||
{eventName}
|
|
||||||
</Text>
|
|
||||||
)
|
|
||||||
) : null}
|
|
||||||
{impactBadges}
|
|
||||||
<Text fontSize="$sm" color={text} marginTop="$1.5">
|
|
||||||
{formatAmount(addon.amount, addon.currency)}
|
|
||||||
</Text>
|
|
||||||
<Text fontSize="$xs" color={muted}>
|
|
||||||
{formatDate(addon.purchased_at)}
|
|
||||||
</Text>
|
|
||||||
</MobileCard>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
function formatDate(value: string | null | undefined): string {
|
|
||||||
if (!value) return '—';
|
|
||||||
const date = new Date(value);
|
|
||||||
if (Number.isNaN(date.getTime())) return '—';
|
|
||||||
return date.toLocaleDateString(undefined, { day: '2-digit', month: 'short', year: 'numeric' });
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { SizableText as Text } from '@tamagui/text';
|
|||||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||||
import { MobileShell, HeaderActionButton } from './components/MobileShell';
|
import { MobileShell, HeaderActionButton } from './components/MobileShell';
|
||||||
import { MobileCard, CTAButton, SkeletonCard } from './components/Primitives';
|
import { MobileCard, CTAButton, SkeletonCard } from './components/Primitives';
|
||||||
import { TenantEvent, getEvent, updateEvent, getTenantFonts, TenantFont, WatermarkSettings, trackOnboarding } from '../api';
|
import { TenantEvent, getEvent, updateEvent, getTenantFonts, getTenantSettings, TenantFont, WatermarkSettings, trackOnboarding } from '../api';
|
||||||
import { isAuthError } from '../auth/tokens';
|
import { isAuthError } from '../auth/tokens';
|
||||||
import { ApiError, getApiErrorMessage } from '../lib/apiError';
|
import { ApiError, getApiErrorMessage } from '../lib/apiError';
|
||||||
import { isBrandingAllowed } from '../lib/events';
|
import { isBrandingAllowed } from '../lib/events';
|
||||||
@@ -16,13 +16,45 @@ import toast from 'react-hot-toast';
|
|||||||
import { adminPath } from '../constants';
|
import { adminPath } from '../constants';
|
||||||
import { useBackNavigation } from './hooks/useBackNavigation';
|
import { useBackNavigation } from './hooks/useBackNavigation';
|
||||||
import { ADMIN_COLORS, ADMIN_GRADIENTS, useAdminTheme } from './theme';
|
import { ADMIN_COLORS, ADMIN_GRADIENTS, useAdminTheme } from './theme';
|
||||||
|
import { extractBrandingForm, type BrandingFormValues } from '../lib/brandingForm';
|
||||||
|
import { getContrastingTextColor } from '@/guest/lib/color';
|
||||||
|
|
||||||
type BrandingForm = {
|
const BRANDING_FORM_DEFAULTS = {
|
||||||
primary: string;
|
primary: ADMIN_COLORS.primary,
|
||||||
accent: string;
|
accent: ADMIN_COLORS.accent,
|
||||||
headingFont: string;
|
background: '#ffffff',
|
||||||
bodyFont: string;
|
surface: '#ffffff',
|
||||||
logoDataUrl: string;
|
mode: 'auto' as const,
|
||||||
|
buttonStyle: 'filled' as const,
|
||||||
|
buttonRadius: 12,
|
||||||
|
buttonPrimary: ADMIN_COLORS.primary,
|
||||||
|
buttonSecondary: ADMIN_COLORS.accent,
|
||||||
|
linkColor: ADMIN_COLORS.accent,
|
||||||
|
fontSize: 'm' as const,
|
||||||
|
logoMode: 'upload' as const,
|
||||||
|
logoPosition: 'left' as const,
|
||||||
|
logoSize: 'm' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
const BRANDING_FORM_BASE: BrandingFormValues = {
|
||||||
|
...BRANDING_FORM_DEFAULTS,
|
||||||
|
headingFont: '',
|
||||||
|
bodyFont: '',
|
||||||
|
logoDataUrl: '',
|
||||||
|
logoValue: '',
|
||||||
|
useDefaultBranding: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const FONT_SIZE_SCALE: Record<BrandingFormValues['fontSize'], number> = {
|
||||||
|
s: 0.94,
|
||||||
|
m: 1,
|
||||||
|
l: 1.08,
|
||||||
|
};
|
||||||
|
|
||||||
|
const LOGO_SIZE_PREVIEW: Record<BrandingFormValues['logoSize'], number> = {
|
||||||
|
s: 28,
|
||||||
|
m: 36,
|
||||||
|
l: 44,
|
||||||
};
|
};
|
||||||
|
|
||||||
type WatermarkPosition =
|
type WatermarkPosition =
|
||||||
@@ -58,13 +90,7 @@ export default function MobileBrandingPage() {
|
|||||||
const { textStrong, muted, subtle, border, primary, accentSoft, danger, surfaceMuted, surface } = useAdminTheme();
|
const { textStrong, muted, subtle, border, primary, accentSoft, danger, surfaceMuted, surface } = useAdminTheme();
|
||||||
|
|
||||||
const [event, setEvent] = React.useState<TenantEvent | null>(null);
|
const [event, setEvent] = React.useState<TenantEvent | null>(null);
|
||||||
const [form, setForm] = React.useState<BrandingForm>({
|
const [form, setForm] = React.useState<BrandingFormValues>(BRANDING_FORM_BASE);
|
||||||
primary: ADMIN_COLORS.primary,
|
|
||||||
accent: ADMIN_COLORS.accent,
|
|
||||||
headingFont: '',
|
|
||||||
bodyFont: '',
|
|
||||||
logoDataUrl: '',
|
|
||||||
});
|
|
||||||
const [watermarkForm, setWatermarkForm] = React.useState<WatermarkForm>({
|
const [watermarkForm, setWatermarkForm] = React.useState<WatermarkForm>({
|
||||||
mode: 'base',
|
mode: 'base',
|
||||||
assetPath: '',
|
assetPath: '',
|
||||||
@@ -86,6 +112,8 @@ export default function MobileBrandingPage() {
|
|||||||
const [fonts, setFonts] = React.useState<TenantFont[]>([]);
|
const [fonts, setFonts] = React.useState<TenantFont[]>([]);
|
||||||
const [fontsLoading, setFontsLoading] = React.useState(false);
|
const [fontsLoading, setFontsLoading] = React.useState(false);
|
||||||
const [fontsLoaded, setFontsLoaded] = React.useState(false);
|
const [fontsLoaded, setFontsLoaded] = React.useState(false);
|
||||||
|
const [tenantBranding, setTenantBranding] = React.useState<BrandingFormValues | null>(null);
|
||||||
|
const [tenantBrandingLoaded, setTenantBrandingLoaded] = React.useState(false);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!slug) return;
|
if (!slug) return;
|
||||||
@@ -94,7 +122,7 @@ export default function MobileBrandingPage() {
|
|||||||
try {
|
try {
|
||||||
const data = await getEvent(slug);
|
const data = await getEvent(slug);
|
||||||
setEvent(data);
|
setEvent(data);
|
||||||
setForm(extractBranding(data));
|
setForm(extractBrandingForm(data.settings ?? {}, BRANDING_FORM_DEFAULTS));
|
||||||
setWatermarkForm(extractWatermark(data));
|
setWatermarkForm(extractWatermark(data));
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -119,12 +147,42 @@ export default function MobileBrandingPage() {
|
|||||||
});
|
});
|
||||||
}, [showFontsSheet, fontsLoaded]);
|
}, [showFontsSheet, fontsLoaded]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (tenantBrandingLoaded) return;
|
||||||
|
let active = true;
|
||||||
|
getTenantSettings()
|
||||||
|
.then((payload) => {
|
||||||
|
if (!active) return;
|
||||||
|
setTenantBranding(extractBrandingForm(payload.settings ?? {}, BRANDING_FORM_DEFAULTS));
|
||||||
|
})
|
||||||
|
.catch(() => undefined)
|
||||||
|
.finally(() => {
|
||||||
|
if (active) {
|
||||||
|
setTenantBrandingLoaded(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [tenantBrandingLoaded]);
|
||||||
|
|
||||||
|
const previewForm = form.useDefaultBranding && tenantBranding ? tenantBranding : form;
|
||||||
const previewTitle = event ? renderName(event.name) : t('events.placeholders.untitled', 'Unbenanntes Event');
|
const previewTitle = event ? renderName(event.name) : t('events.placeholders.untitled', 'Unbenanntes Event');
|
||||||
const previewHeadingFont = form.headingFont || 'Fraunces';
|
const previewHeadingFont = previewForm.headingFont || 'Fraunces';
|
||||||
const previewBodyFont = form.bodyFont || 'Manrope';
|
const previewBodyFont = previewForm.bodyFont || 'Manrope';
|
||||||
|
const previewSurfaceText = getContrastingTextColor(previewForm.surface, '#ffffff', '#0f172a');
|
||||||
|
const previewScale = FONT_SIZE_SCALE[previewForm.fontSize] ?? 1;
|
||||||
|
const previewButtonColor = previewForm.buttonPrimary || previewForm.primary;
|
||||||
|
const previewButtonText = getContrastingTextColor(previewButtonColor, '#ffffff', '#0f172a');
|
||||||
|
const previewLogoSize = LOGO_SIZE_PREVIEW[previewForm.logoSize] ?? 36;
|
||||||
|
const previewLogoUrl = previewForm.logoMode === 'upload' ? previewForm.logoDataUrl : '';
|
||||||
|
const previewLogoValue = previewForm.logoMode === 'emoticon' ? previewForm.logoValue : '';
|
||||||
|
const previewInitials = getInitials(previewTitle);
|
||||||
const watermarkAllowed = event?.package?.watermark_allowed !== false;
|
const watermarkAllowed = event?.package?.watermark_allowed !== false;
|
||||||
const brandingAllowed = isBrandingAllowed(event ?? null);
|
const brandingAllowed = isBrandingAllowed(event ?? null);
|
||||||
const watermarkLocked = watermarkAllowed && !brandingAllowed;
|
const watermarkLocked = watermarkAllowed && !brandingAllowed;
|
||||||
|
const brandingDisabled = !brandingAllowed || form.useDefaultBranding;
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
if (!event?.slug) return;
|
if (!event?.slug) return;
|
||||||
@@ -155,18 +213,38 @@ export default function MobileBrandingPage() {
|
|||||||
is_active: event.is_active ?? undefined,
|
is_active: event.is_active ?? undefined,
|
||||||
};
|
};
|
||||||
const settings = { ...(event.settings ?? {}) };
|
const settings = { ...(event.settings ?? {}) };
|
||||||
|
const logoUploadValue = form.logoMode === 'upload' ? form.logoDataUrl.trim() : '';
|
||||||
|
const logoIsDataUrl = logoUploadValue.startsWith('data:image/');
|
||||||
|
const normalizedLogoPath = logoIsDataUrl ? '' : normalizeBrandingPath(logoUploadValue);
|
||||||
|
const logoValue = form.logoMode === 'upload'
|
||||||
|
? (logoIsDataUrl ? logoUploadValue : normalizedLogoPath || null)
|
||||||
|
: (form.logoValue.trim() || null);
|
||||||
|
|
||||||
settings.branding = {
|
settings.branding = {
|
||||||
...(typeof settings.branding === 'object' ? (settings.branding as Record<string, unknown>) : {}),
|
...(typeof settings.branding === 'object' ? (settings.branding as Record<string, unknown>) : {}),
|
||||||
|
use_default_branding: form.useDefaultBranding,
|
||||||
primary_color: form.primary,
|
primary_color: form.primary,
|
||||||
|
secondary_color: form.accent,
|
||||||
accent_color: form.accent,
|
accent_color: form.accent,
|
||||||
|
background_color: form.background,
|
||||||
|
surface_color: form.surface,
|
||||||
|
font_family: form.bodyFont,
|
||||||
heading_font: form.headingFont,
|
heading_font: form.headingFont,
|
||||||
body_font: form.bodyFont,
|
body_font: form.bodyFont,
|
||||||
|
font_size: form.fontSize,
|
||||||
|
mode: form.mode,
|
||||||
|
button_style: form.buttonStyle,
|
||||||
|
button_radius: form.buttonRadius,
|
||||||
|
button_primary_color: form.buttonPrimary,
|
||||||
|
button_secondary_color: form.buttonSecondary,
|
||||||
|
link_color: form.linkColor,
|
||||||
typography: {
|
typography: {
|
||||||
...(typeof (settings.branding as Record<string, unknown> | undefined)?.typography === 'object'
|
...(typeof (settings.branding as Record<string, unknown> | undefined)?.typography === 'object'
|
||||||
? ((settings.branding as Record<string, unknown>).typography as Record<string, unknown>)
|
? ((settings.branding as Record<string, unknown>).typography as Record<string, unknown>)
|
||||||
: {}),
|
: {}),
|
||||||
heading: form.headingFont,
|
heading: form.headingFont,
|
||||||
body: form.bodyFont,
|
body: form.bodyFont,
|
||||||
|
size: form.fontSize,
|
||||||
},
|
},
|
||||||
palette: {
|
palette: {
|
||||||
...(typeof (settings.branding as Record<string, unknown> | undefined)?.palette === 'object'
|
...(typeof (settings.branding as Record<string, unknown> | undefined)?.palette === 'object'
|
||||||
@@ -174,16 +252,31 @@ export default function MobileBrandingPage() {
|
|||||||
: {}),
|
: {}),
|
||||||
primary: form.primary,
|
primary: form.primary,
|
||||||
secondary: form.accent,
|
secondary: form.accent,
|
||||||
|
background: form.background,
|
||||||
|
surface: form.surface,
|
||||||
|
},
|
||||||
|
buttons: {
|
||||||
|
...(typeof (settings.branding as Record<string, unknown> | undefined)?.buttons === 'object'
|
||||||
|
? ((settings.branding as Record<string, unknown>).buttons as Record<string, unknown>)
|
||||||
|
: {}),
|
||||||
|
style: form.buttonStyle,
|
||||||
|
radius: form.buttonRadius,
|
||||||
|
primary: form.buttonPrimary,
|
||||||
|
secondary: form.buttonSecondary,
|
||||||
|
link_color: form.linkColor,
|
||||||
|
},
|
||||||
|
logo_data_url: form.logoMode === 'upload' && logoIsDataUrl ? logoUploadValue : null,
|
||||||
|
logo_url: form.logoMode === 'upload' ? (normalizedLogoPath || null) : null,
|
||||||
|
logo_mode: form.logoMode,
|
||||||
|
logo_value: logoValue,
|
||||||
|
logo_position: form.logoPosition,
|
||||||
|
logo_size: form.logoSize,
|
||||||
|
logo: {
|
||||||
|
mode: form.logoMode,
|
||||||
|
value: logoValue,
|
||||||
|
position: form.logoPosition,
|
||||||
|
size: form.logoSize,
|
||||||
},
|
},
|
||||||
logo_data_url: form.logoDataUrl || null,
|
|
||||||
logo: form.logoDataUrl
|
|
||||||
? {
|
|
||||||
mode: 'upload',
|
|
||||||
value: form.logoDataUrl,
|
|
||||||
position: 'center',
|
|
||||||
size: 'm',
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
};
|
};
|
||||||
const watermarkPayload = buildWatermarkPayload(watermarkForm, watermarkAllowed, brandingAllowed);
|
const watermarkPayload = buildWatermarkPayload(watermarkForm, watermarkAllowed, brandingAllowed);
|
||||||
if (watermarkPayload) {
|
if (watermarkPayload) {
|
||||||
@@ -217,7 +310,7 @@ export default function MobileBrandingPage() {
|
|||||||
|
|
||||||
function handleReset() {
|
function handleReset() {
|
||||||
if (event) {
|
if (event) {
|
||||||
setForm(extractBranding(event));
|
setForm(extractBrandingForm(event.settings ?? {}, BRANDING_FORM_DEFAULTS));
|
||||||
setWatermarkForm(extractWatermark(event));
|
setWatermarkForm(extractWatermark(event));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -435,25 +528,143 @@ export default function MobileBrandingPage() {
|
|||||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||||
{t('events.branding.previewTitle', 'Guest App Preview')}
|
{t('events.branding.previewTitle', 'Guest App Preview')}
|
||||||
</Text>
|
</Text>
|
||||||
<YStack borderRadius={16} borderWidth={1} borderColor={border} backgroundColor={surfaceMuted} padding="$3" space="$2" alignItems="center">
|
<YStack borderRadius={16} borderWidth={1} borderColor={border} backgroundColor={previewForm.background} padding="$3" space="$2" alignItems="center">
|
||||||
<YStack width="100%" borderRadius={12} backgroundColor={surface} borderWidth={1} borderColor={border} overflow="hidden">
|
<YStack width="100%" borderRadius={12} backgroundColor={previewForm.surface} borderWidth={1} borderColor={border} overflow="hidden">
|
||||||
<YStack backgroundColor={form.primary} height={64} />
|
<YStack
|
||||||
<YStack padding="$3" space="$1.5">
|
height={64}
|
||||||
<Text fontSize="$md" fontWeight="800" color={textStrong} style={{ fontFamily: previewHeadingFont }}>
|
style={{ background: `linear-gradient(135deg, ${previewForm.primary}, ${previewForm.accent})` }}
|
||||||
|
/>
|
||||||
|
<YStack padding="$3" space="$2">
|
||||||
|
<XStack
|
||||||
|
alignItems="center"
|
||||||
|
space="$2"
|
||||||
|
flexDirection={previewForm.logoPosition === 'center' ? 'column' : previewForm.logoPosition === 'right' ? 'row-reverse' : 'row'}
|
||||||
|
justifyContent={previewForm.logoPosition === 'center' ? 'center' : 'flex-start'}
|
||||||
|
>
|
||||||
|
<YStack
|
||||||
|
width={previewLogoSize}
|
||||||
|
height={previewLogoSize}
|
||||||
|
borderRadius={previewLogoSize}
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
backgroundColor={previewForm.accent}
|
||||||
|
>
|
||||||
|
{previewLogoUrl ? (
|
||||||
|
<img
|
||||||
|
src={previewLogoUrl}
|
||||||
|
alt={t('events.branding.logoAlt', 'Logo')}
|
||||||
|
style={{ width: previewLogoSize - 6, height: previewLogoSize - 6, borderRadius: previewLogoSize, objectFit: 'cover' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Text fontSize="$sm" color={previewSurfaceText} fontWeight="700">
|
||||||
|
{previewLogoValue || previewInitials}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</YStack>
|
||||||
|
<YStack>
|
||||||
|
<Text
|
||||||
|
fontWeight="800"
|
||||||
|
color={previewSurfaceText}
|
||||||
|
style={{ fontFamily: previewHeadingFont, fontSize: 18 * previewScale }}
|
||||||
|
>
|
||||||
{previewTitle}
|
{previewTitle}
|
||||||
</Text>
|
</Text>
|
||||||
<Text fontSize="$sm" color={muted} style={{ fontFamily: previewBodyFont }}>
|
<Text
|
||||||
|
color={previewSurfaceText}
|
||||||
|
style={{ fontFamily: previewBodyFont, opacity: 0.7, fontSize: 13 * previewScale }}
|
||||||
|
>
|
||||||
{t('events.branding.previewSubtitle', 'Aktuelle Farben & Schriften')}
|
{t('events.branding.previewSubtitle', 'Aktuelle Farben & Schriften')}
|
||||||
</Text>
|
</Text>
|
||||||
|
</YStack>
|
||||||
|
</XStack>
|
||||||
<XStack space="$2" marginTop="$1">
|
<XStack space="$2" marginTop="$1">
|
||||||
<ColorSwatch color={form.primary} label={t('events.branding.primary', 'Primary')} />
|
<ColorSwatch color={previewForm.primary} label={t('events.branding.primary', 'Primary')} />
|
||||||
<ColorSwatch color={form.accent} label={t('events.branding.accent', 'Accent')} />
|
<ColorSwatch color={previewForm.accent} label={t('events.branding.accent', 'Accent')} />
|
||||||
|
<ColorSwatch color={previewForm.background} label={t('events.branding.background', 'Background')} />
|
||||||
|
<ColorSwatch color={previewForm.surface} label={t('events.branding.surface', 'Surface')} />
|
||||||
|
</XStack>
|
||||||
|
<XStack marginTop="$2">
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '8px 14px',
|
||||||
|
borderRadius: previewForm.buttonRadius,
|
||||||
|
background: previewForm.buttonStyle === 'outline' ? 'transparent' : previewButtonColor,
|
||||||
|
color: previewForm.buttonStyle === 'outline' ? previewForm.linkColor : previewButtonText,
|
||||||
|
border: previewForm.buttonStyle === 'outline' ? `1px solid ${previewForm.linkColor}` : 'none',
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: 13 * previewScale,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('events.branding.previewCta', 'Fotos hochladen')}
|
||||||
|
</div>
|
||||||
</XStack>
|
</XStack>
|
||||||
</YStack>
|
</YStack>
|
||||||
</YStack>
|
</YStack>
|
||||||
</YStack>
|
</YStack>
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
|
|
||||||
|
{!brandingAllowed ? (
|
||||||
|
<InfoBadge
|
||||||
|
icon={<Lock size={16} color={danger} />}
|
||||||
|
text={t('events.branding.lockedBranding', 'Branding ist in diesem Paket gesperrt.')}
|
||||||
|
tone="danger"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<MobileCard space="$3">
|
||||||
|
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||||
|
{t('events.branding.source', 'Branding Source')}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="$sm" color={muted}>
|
||||||
|
{t('events.branding.sourceHint', 'Nutze das Tenant-Branding oder überschreibe es für dieses Event.')}
|
||||||
|
</Text>
|
||||||
|
<XStack space="$2">
|
||||||
|
<ModeButton
|
||||||
|
label={t('events.branding.useDefault', 'Tenant')}
|
||||||
|
active={form.useDefaultBranding}
|
||||||
|
onPress={() => setForm((prev) => ({ ...prev, useDefaultBranding: true }))}
|
||||||
|
disabled={!brandingAllowed}
|
||||||
|
/>
|
||||||
|
<ModeButton
|
||||||
|
label={t('events.branding.useCustom', 'Event')}
|
||||||
|
active={!form.useDefaultBranding}
|
||||||
|
onPress={() => setForm((prev) => ({ ...prev, useDefaultBranding: false }))}
|
||||||
|
disabled={!brandingAllowed}
|
||||||
|
/>
|
||||||
|
</XStack>
|
||||||
|
<Text fontSize="$xs" color={muted}>
|
||||||
|
{form.useDefaultBranding
|
||||||
|
? t('events.branding.usingDefault', 'Tenant-Branding aktiv')
|
||||||
|
: t('events.branding.usingCustom', 'Event-Branding aktiv')}
|
||||||
|
</Text>
|
||||||
|
</MobileCard>
|
||||||
|
|
||||||
|
<MobileCard space="$3">
|
||||||
|
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||||
|
{t('events.branding.mode', 'Theme')}
|
||||||
|
</Text>
|
||||||
|
<XStack space="$2">
|
||||||
|
<ModeButton
|
||||||
|
label={t('events.branding.modeLight', 'Light')}
|
||||||
|
active={form.mode === 'light'}
|
||||||
|
onPress={() => setForm((prev) => ({ ...prev, mode: 'light' }))}
|
||||||
|
disabled={brandingDisabled}
|
||||||
|
/>
|
||||||
|
<ModeButton
|
||||||
|
label={t('events.branding.modeAuto', 'Auto')}
|
||||||
|
active={form.mode === 'auto'}
|
||||||
|
onPress={() => setForm((prev) => ({ ...prev, mode: 'auto' }))}
|
||||||
|
disabled={brandingDisabled}
|
||||||
|
/>
|
||||||
|
<ModeButton
|
||||||
|
label={t('events.branding.modeDark', 'Dark')}
|
||||||
|
active={form.mode === 'dark'}
|
||||||
|
onPress={() => setForm((prev) => ({ ...prev, mode: 'dark' }))}
|
||||||
|
disabled={brandingDisabled}
|
||||||
|
/>
|
||||||
|
</XStack>
|
||||||
|
</MobileCard>
|
||||||
|
|
||||||
<MobileCard space="$3">
|
<MobileCard space="$3">
|
||||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||||
{t('events.branding.colors', 'Colors')}
|
{t('events.branding.colors', 'Colors')}
|
||||||
@@ -462,11 +673,25 @@ export default function MobileBrandingPage() {
|
|||||||
label={t('events.branding.primary', 'Primary Color')}
|
label={t('events.branding.primary', 'Primary Color')}
|
||||||
value={form.primary}
|
value={form.primary}
|
||||||
onChange={(value) => setForm((prev) => ({ ...prev, primary: value }))}
|
onChange={(value) => setForm((prev) => ({ ...prev, primary: value }))}
|
||||||
|
disabled={brandingDisabled}
|
||||||
/>
|
/>
|
||||||
<ColorField
|
<ColorField
|
||||||
label={t('events.branding.accent', 'Accent Color')}
|
label={t('events.branding.accent', 'Accent Color')}
|
||||||
value={form.accent}
|
value={form.accent}
|
||||||
onChange={(value) => setForm((prev) => ({ ...prev, accent: value }))}
|
onChange={(value) => setForm((prev) => ({ ...prev, accent: value }))}
|
||||||
|
disabled={brandingDisabled}
|
||||||
|
/>
|
||||||
|
<ColorField
|
||||||
|
label={t('events.branding.backgroundColor', 'Background Color')}
|
||||||
|
value={form.background}
|
||||||
|
onChange={(value) => setForm((prev) => ({ ...prev, background: value }))}
|
||||||
|
disabled={brandingDisabled}
|
||||||
|
/>
|
||||||
|
<ColorField
|
||||||
|
label={t('events.branding.surfaceColor', 'Surface Color')}
|
||||||
|
value={form.surface}
|
||||||
|
onChange={(value) => setForm((prev) => ({ ...prev, surface: value }))}
|
||||||
|
disabled={brandingDisabled}
|
||||||
/>
|
/>
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
|
|
||||||
@@ -483,6 +708,7 @@ export default function MobileBrandingPage() {
|
|||||||
setFontField('heading');
|
setFontField('heading');
|
||||||
setShowFontsSheet(true);
|
setShowFontsSheet(true);
|
||||||
}}
|
}}
|
||||||
|
disabled={brandingDisabled}
|
||||||
/>
|
/>
|
||||||
<InputField
|
<InputField
|
||||||
label={t('events.branding.bodyFont', 'Body Font')}
|
label={t('events.branding.bodyFont', 'Body Font')}
|
||||||
@@ -493,13 +719,64 @@ export default function MobileBrandingPage() {
|
|||||||
setFontField('body');
|
setFontField('body');
|
||||||
setShowFontsSheet(true);
|
setShowFontsSheet(true);
|
||||||
}}
|
}}
|
||||||
|
disabled={brandingDisabled}
|
||||||
/>
|
/>
|
||||||
|
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||||
|
{t('events.branding.fontSize', 'Font Size')}
|
||||||
|
</Text>
|
||||||
|
<XStack space="$2">
|
||||||
|
<ModeButton
|
||||||
|
label={t('events.branding.fontSizeSmall', 'S')}
|
||||||
|
active={form.fontSize === 's'}
|
||||||
|
onPress={() => setForm((prev) => ({ ...prev, fontSize: 's' }))}
|
||||||
|
disabled={brandingDisabled}
|
||||||
|
/>
|
||||||
|
<ModeButton
|
||||||
|
label={t('events.branding.fontSizeMedium', 'M')}
|
||||||
|
active={form.fontSize === 'm'}
|
||||||
|
onPress={() => setForm((prev) => ({ ...prev, fontSize: 'm' }))}
|
||||||
|
disabled={brandingDisabled}
|
||||||
|
/>
|
||||||
|
<ModeButton
|
||||||
|
label={t('events.branding.fontSizeLarge', 'L')}
|
||||||
|
active={form.fontSize === 'l'}
|
||||||
|
onPress={() => setForm((prev) => ({ ...prev, fontSize: 'l' }))}
|
||||||
|
disabled={brandingDisabled}
|
||||||
|
/>
|
||||||
|
</XStack>
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
|
|
||||||
<MobileCard space="$3">
|
<MobileCard space="$3">
|
||||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||||
{t('events.branding.logo', 'Logo')}
|
{t('events.branding.logo', 'Logo')}
|
||||||
</Text>
|
</Text>
|
||||||
|
<Text fontSize="$sm" color={muted}>
|
||||||
|
{t('events.branding.logoHint', 'Upload a logo or use an emoji for the guest header.')}
|
||||||
|
</Text>
|
||||||
|
<XStack space="$2">
|
||||||
|
<ModeButton
|
||||||
|
label={t('events.branding.logoModeUpload', 'Upload')}
|
||||||
|
active={form.logoMode === 'upload'}
|
||||||
|
onPress={() => setForm((prev) => ({ ...prev, logoMode: 'upload' }))}
|
||||||
|
disabled={brandingDisabled}
|
||||||
|
/>
|
||||||
|
<ModeButton
|
||||||
|
label={t('events.branding.logoModeEmoticon', 'Emoticon')}
|
||||||
|
active={form.logoMode === 'emoticon'}
|
||||||
|
onPress={() => setForm((prev) => ({ ...prev, logoMode: 'emoticon' }))}
|
||||||
|
disabled={brandingDisabled}
|
||||||
|
/>
|
||||||
|
</XStack>
|
||||||
|
|
||||||
|
{form.logoMode === 'emoticon' ? (
|
||||||
|
<InputField
|
||||||
|
label={t('events.branding.logoValue', 'Emoticon')}
|
||||||
|
value={form.logoValue}
|
||||||
|
placeholder={t('events.branding.logoValuePlaceholder', '🎉')}
|
||||||
|
onChange={(value) => setForm((prev) => ({ ...prev, logoValue: value }))}
|
||||||
|
disabled={brandingDisabled}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<YStack
|
<YStack
|
||||||
borderRadius={14}
|
borderRadius={14}
|
||||||
borderWidth={1}
|
borderWidth={1}
|
||||||
@@ -521,8 +798,12 @@ export default function MobileBrandingPage() {
|
|||||||
<CTAButton
|
<CTAButton
|
||||||
label={t('events.branding.replaceLogo', 'Replace logo')}
|
label={t('events.branding.replaceLogo', 'Replace logo')}
|
||||||
onPress={() => document.getElementById('branding-logo-input')?.click()}
|
onPress={() => document.getElementById('branding-logo-input')?.click()}
|
||||||
|
disabled={brandingDisabled}
|
||||||
/>
|
/>
|
||||||
<Pressable onPress={() => setForm((prev) => ({ ...prev, logoDataUrl: '' }))}>
|
<Pressable
|
||||||
|
disabled={brandingDisabled}
|
||||||
|
onPress={() => setForm((prev) => ({ ...prev, logoDataUrl: '' }))}
|
||||||
|
>
|
||||||
<XStack
|
<XStack
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
space="$1.5"
|
space="$1.5"
|
||||||
@@ -543,10 +824,10 @@ export default function MobileBrandingPage() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<ImageIcon size={28} color={subtle} />
|
<ImageIcon size={28} color={subtle} />
|
||||||
<Text fontSize="$sm" color={muted} textAlign="center">
|
<Pressable
|
||||||
{t('events.branding.logoHint', 'Upload a logo to brand guest invites and QR posters.')}
|
disabled={brandingDisabled}
|
||||||
</Text>
|
onPress={() => document.getElementById('branding-logo-input')?.click()}
|
||||||
<Pressable onPress={() => document.getElementById('branding-logo-input')?.click()}>
|
>
|
||||||
<XStack
|
<XStack
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
space="$2"
|
space="$2"
|
||||||
@@ -570,6 +851,7 @@ export default function MobileBrandingPage() {
|
|||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
style={{ display: 'none' }}
|
style={{ display: 'none' }}
|
||||||
|
disabled={brandingDisabled}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const file = event.target.files?.[0];
|
const file = event.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
@@ -592,6 +874,104 @@ export default function MobileBrandingPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</YStack>
|
</YStack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||||
|
{t('events.branding.logoPosition', 'Position')}
|
||||||
|
</Text>
|
||||||
|
<XStack space="$2">
|
||||||
|
<ModeButton
|
||||||
|
label={t('events.branding.positionLeft', 'Left')}
|
||||||
|
active={form.logoPosition === 'left'}
|
||||||
|
onPress={() => setForm((prev) => ({ ...prev, logoPosition: 'left' }))}
|
||||||
|
disabled={brandingDisabled}
|
||||||
|
/>
|
||||||
|
<ModeButton
|
||||||
|
label={t('events.branding.positionCenter', 'Center')}
|
||||||
|
active={form.logoPosition === 'center'}
|
||||||
|
onPress={() => setForm((prev) => ({ ...prev, logoPosition: 'center' }))}
|
||||||
|
disabled={brandingDisabled}
|
||||||
|
/>
|
||||||
|
<ModeButton
|
||||||
|
label={t('events.branding.positionRight', 'Right')}
|
||||||
|
active={form.logoPosition === 'right'}
|
||||||
|
onPress={() => setForm((prev) => ({ ...prev, logoPosition: 'right' }))}
|
||||||
|
disabled={brandingDisabled}
|
||||||
|
/>
|
||||||
|
</XStack>
|
||||||
|
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||||
|
{t('events.branding.logoSize', 'Size')}
|
||||||
|
</Text>
|
||||||
|
<XStack space="$2">
|
||||||
|
<ModeButton
|
||||||
|
label={t('events.branding.logoSizeSmall', 'S')}
|
||||||
|
active={form.logoSize === 's'}
|
||||||
|
onPress={() => setForm((prev) => ({ ...prev, logoSize: 's' }))}
|
||||||
|
disabled={brandingDisabled}
|
||||||
|
/>
|
||||||
|
<ModeButton
|
||||||
|
label={t('events.branding.logoSizeMedium', 'M')}
|
||||||
|
active={form.logoSize === 'm'}
|
||||||
|
onPress={() => setForm((prev) => ({ ...prev, logoSize: 'm' }))}
|
||||||
|
disabled={brandingDisabled}
|
||||||
|
/>
|
||||||
|
<ModeButton
|
||||||
|
label={t('events.branding.logoSizeLarge', 'L')}
|
||||||
|
active={form.logoSize === 'l'}
|
||||||
|
onPress={() => setForm((prev) => ({ ...prev, logoSize: 'l' }))}
|
||||||
|
disabled={brandingDisabled}
|
||||||
|
/>
|
||||||
|
</XStack>
|
||||||
|
</MobileCard>
|
||||||
|
|
||||||
|
<MobileCard space="$3">
|
||||||
|
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||||
|
{t('events.branding.buttons', 'Buttons & Links')}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="$sm" color={muted}>
|
||||||
|
{t('events.branding.buttonsHint', 'Style, radius, and link color for CTA buttons.')}
|
||||||
|
</Text>
|
||||||
|
<XStack space="$2">
|
||||||
|
<ModeButton
|
||||||
|
label={t('events.branding.buttonFilled', 'Filled')}
|
||||||
|
active={form.buttonStyle === 'filled'}
|
||||||
|
onPress={() => setForm((prev) => ({ ...prev, buttonStyle: 'filled' }))}
|
||||||
|
disabled={brandingDisabled}
|
||||||
|
/>
|
||||||
|
<ModeButton
|
||||||
|
label={t('events.branding.buttonOutline', 'Outline')}
|
||||||
|
active={form.buttonStyle === 'outline'}
|
||||||
|
onPress={() => setForm((prev) => ({ ...prev, buttonStyle: 'outline' }))}
|
||||||
|
disabled={brandingDisabled}
|
||||||
|
/>
|
||||||
|
</XStack>
|
||||||
|
<LabeledSlider
|
||||||
|
label={t('events.branding.buttonRadius', 'Radius')}
|
||||||
|
value={form.buttonRadius}
|
||||||
|
min={0}
|
||||||
|
max={32}
|
||||||
|
step={1}
|
||||||
|
onChange={(value) => setForm((prev) => ({ ...prev, buttonRadius: value }))}
|
||||||
|
disabled={brandingDisabled}
|
||||||
|
/>
|
||||||
|
<ColorField
|
||||||
|
label={t('events.branding.buttonPrimary', 'Button Primary')}
|
||||||
|
value={form.buttonPrimary}
|
||||||
|
onChange={(value) => setForm((prev) => ({ ...prev, buttonPrimary: value }))}
|
||||||
|
disabled={brandingDisabled}
|
||||||
|
/>
|
||||||
|
<ColorField
|
||||||
|
label={t('events.branding.buttonSecondary', 'Button Secondary')}
|
||||||
|
value={form.buttonSecondary}
|
||||||
|
onChange={(value) => setForm((prev) => ({ ...prev, buttonSecondary: value }))}
|
||||||
|
disabled={brandingDisabled}
|
||||||
|
/>
|
||||||
|
<ColorField
|
||||||
|
label={t('events.branding.linkColor', 'Link Color')}
|
||||||
|
value={form.linkColor}
|
||||||
|
onChange={(value) => setForm((prev) => ({ ...prev, linkColor: value }))}
|
||||||
|
disabled={brandingDisabled}
|
||||||
|
/>
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -671,26 +1051,6 @@ export default function MobileBrandingPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractBranding(event: TenantEvent): BrandingForm {
|
|
||||||
const source = (event.settings as Record<string, unknown>) ?? {};
|
|
||||||
const branding = (source.branding as Record<string, unknown>) ?? source;
|
|
||||||
const readColor = (key: string, fallback: string) => {
|
|
||||||
const value = branding[key];
|
|
||||||
return typeof value === 'string' && value.startsWith('#') ? value : fallback;
|
|
||||||
};
|
|
||||||
const readText = (key: string) => {
|
|
||||||
const value = branding[key];
|
|
||||||
return typeof value === 'string' ? value : '';
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
primary: readColor('primary_color', ADMIN_COLORS.primary),
|
|
||||||
accent: readColor('accent_color', ADMIN_COLORS.accent),
|
|
||||||
headingFont: readText('heading_font'),
|
|
||||||
bodyFont: readText('body_font'),
|
|
||||||
logoDataUrl: readText('logo_data_url'),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractWatermark(event: TenantEvent): WatermarkForm {
|
function extractWatermark(event: TenantEvent): WatermarkForm {
|
||||||
const settings = (event.settings as Record<string, unknown>) ?? {};
|
const settings = (event.settings as Record<string, unknown>) ?? {};
|
||||||
const wm = (settings.watermark as Record<string, unknown>) ?? {};
|
const wm = (settings.watermark as Record<string, unknown>) ?? {};
|
||||||
@@ -762,10 +1122,47 @@ function renderName(name: TenantEvent['name']): string {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function ColorField({ label, value, onChange }: { label: string; value: string; onChange: (next: string) => void }) {
|
function normalizeBrandingPath(value: string): string {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed.startsWith('http://') || trimmed.startsWith('https://') || trimmed.startsWith('data:')) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = trimmed.replace(/^\/+/, '');
|
||||||
|
|
||||||
|
if (normalized.startsWith('storage/')) {
|
||||||
|
return normalized.slice('storage/'.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInitials(name: string): string {
|
||||||
|
const words = name.split(' ').filter(Boolean);
|
||||||
|
if (words.length >= 2) {
|
||||||
|
return `${words[0][0]}${words[1][0]}`.toUpperCase();
|
||||||
|
}
|
||||||
|
return name.substring(0, 2).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function ColorField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (next: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
const { textStrong, muted, border, surface } = useAdminTheme();
|
const { textStrong, muted, border, surface } = useAdminTheme();
|
||||||
return (
|
return (
|
||||||
<YStack space="$2">
|
<YStack space="$2" opacity={disabled ? 0.6 : 1}>
|
||||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -774,6 +1171,7 @@ function ColorField({ label, value, onChange }: { label: string; value: string;
|
|||||||
type="color"
|
type="color"
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(event) => onChange(event.target.value)}
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
style={{ width: 52, height: 52, borderRadius: 12, border: `1px solid ${border}`, background: surface }}
|
style={{ width: 52, height: 52, borderRadius: 12, border: `1px solid ${border}`, background: surface }}
|
||||||
/>
|
/>
|
||||||
<Text fontSize="$sm" color={muted}>
|
<Text fontSize="$sm" color={muted}>
|
||||||
@@ -803,6 +1201,7 @@ function InputField({
|
|||||||
onChange,
|
onChange,
|
||||||
onPicker,
|
onPicker,
|
||||||
children,
|
children,
|
||||||
|
disabled,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
@@ -810,10 +1209,11 @@ function InputField({
|
|||||||
onChange: (next: string) => void;
|
onChange: (next: string) => void;
|
||||||
onPicker?: () => void;
|
onPicker?: () => void;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
|
disabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { textStrong, border, surface, primary } = useAdminTheme();
|
const { textStrong, border, surface, primary } = useAdminTheme();
|
||||||
return (
|
return (
|
||||||
<YStack space="$2">
|
<YStack space="$2" opacity={disabled ? 0.6 : 1}>
|
||||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -834,6 +1234,7 @@ function InputField({
|
|||||||
value={value}
|
value={value}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
onChange={(event) => onChange(event.target.value)}
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
height: '100%',
|
height: '100%',
|
||||||
@@ -846,7 +1247,7 @@ function InputField({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{onPicker ? (
|
{onPicker ? (
|
||||||
<Pressable onPress={onPicker}>
|
<Pressable onPress={onPicker} disabled={disabled}>
|
||||||
<ChevronDown size={16} color={primary} />
|
<ChevronDown size={16} color={primary} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -1078,3 +1479,34 @@ function TabButton({ label, active, onPress }: { label: string; active: boolean;
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ModeButton({
|
||||||
|
label,
|
||||||
|
active,
|
||||||
|
onPress,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
active: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
const { backdrop, surfaceMuted, border, surface } = useAdminTheme();
|
||||||
|
return (
|
||||||
|
<Pressable onPress={onPress} disabled={disabled} style={{ flex: 1, opacity: disabled ? 0.6 : 1 }}>
|
||||||
|
<XStack
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
paddingVertical="$2"
|
||||||
|
borderRadius={10}
|
||||||
|
backgroundColor={active ? backdrop : surfaceMuted}
|
||||||
|
borderWidth={1}
|
||||||
|
borderColor={active ? backdrop : border}
|
||||||
|
>
|
||||||
|
<Text fontSize="$xs" color={active ? surface : backdrop} fontWeight="700">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</XStack>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { Bell, CheckCircle2, Download, Image as ImageIcon, ListTodo, MessageCircle, QrCode, Settings, ShieldCheck, Smartphone, Users, Sparkles, TrendingUp } from 'lucide-react';
|
import { Bell, CalendarDays, Camera, CheckCircle2, ChevronDown, Download, Image as ImageIcon, Layout, ListTodo, MapPin, Megaphone, MessageCircle, Pencil, QrCode, Settings, ShieldCheck, Smartphone, Sparkles, TrendingUp, Tv, Users } from 'lucide-react';
|
||||||
import { YStack, XStack } from '@tamagui/stacks';
|
import { YStack, XStack } from '@tamagui/stacks';
|
||||||
import { SizableText as Text } from '@tamagui/text';
|
import { SizableText as Text } from '@tamagui/text';
|
||||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||||
import { MobileShell, renderEventLocation } from './components/MobileShell';
|
import { MobileShell } from './components/MobileShell';
|
||||||
import { MobileCard, CTAButton, KpiTile, ActionTile, PillBadge, SkeletonCard } from './components/Primitives';
|
import { MobileCard, CTAButton, KpiTile, ActionTile, PillBadge, SkeletonCard } from './components/Primitives';
|
||||||
import { MobileSheet } from './components/Sheet';
|
import { MobileSheet } from './components/Sheet';
|
||||||
import { adminPath, ADMIN_WELCOME_BASE_PATH } from '../constants';
|
import { adminPath, ADMIN_WELCOME_BASE_PATH } from '../constants';
|
||||||
@@ -21,6 +21,7 @@ import { collectPackageFeatures, formatPackageLimit, getPackageFeatureLabel, get
|
|||||||
import { trackOnboarding } from '../api';
|
import { trackOnboarding } from '../api';
|
||||||
import { useAuth } from '../auth/context';
|
import { useAuth } from '../auth/context';
|
||||||
import { ADMIN_ACTION_COLORS, ADMIN_MOTION, useAdminTheme } from './theme';
|
import { ADMIN_ACTION_COLORS, ADMIN_MOTION, useAdminTheme } from './theme';
|
||||||
|
import { isPastEvent } from './eventDate';
|
||||||
|
|
||||||
type DeviceSetupProps = {
|
type DeviceSetupProps = {
|
||||||
installPrompt: ReturnType<typeof useInstallPrompt>;
|
installPrompt: ReturnType<typeof useInstallPrompt>;
|
||||||
@@ -32,6 +33,7 @@ type DeviceSetupProps = {
|
|||||||
export default function MobileDashboardPage() {
|
export default function MobileDashboardPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const { slug: slugParam } = useParams<{ slug?: string }>();
|
||||||
const { t, i18n } = useTranslation('management');
|
const { t, i18n } = useTranslation('management');
|
||||||
const { events, activeEvent, hasEvents, hasMultipleEvents, isLoading, selectEvent } = useEventContext();
|
const { events, activeEvent, hasEvents, hasMultipleEvents, isLoading, selectEvent } = useEventContext();
|
||||||
const { status } = useAuth();
|
const { status } = useAuth();
|
||||||
@@ -42,11 +44,12 @@ export default function MobileDashboardPage() {
|
|||||||
const [tourStep, setTourStep] = React.useState(0);
|
const [tourStep, setTourStep] = React.useState(0);
|
||||||
const [summaryOpen, setSummaryOpen] = React.useState(false);
|
const [summaryOpen, setSummaryOpen] = React.useState(false);
|
||||||
const [summarySeenOverride, setSummarySeenOverride] = React.useState<number | null>(null);
|
const [summarySeenOverride, setSummarySeenOverride] = React.useState<number | null>(null);
|
||||||
|
const [eventSwitcherOpen, setEventSwitcherOpen] = React.useState(false);
|
||||||
const onboardingTrackedRef = React.useRef(false);
|
const onboardingTrackedRef = React.useRef(false);
|
||||||
const installPrompt = useInstallPrompt();
|
const installPrompt = useInstallPrompt();
|
||||||
const pushState = useAdminPushSubscription();
|
const pushState = useAdminPushSubscription();
|
||||||
const devicePermissions = useDevicePermissions();
|
const devicePermissions = useDevicePermissions();
|
||||||
const { textStrong, muted, border, surface, accentSoft, primary } = useAdminTheme();
|
const { textStrong, muted, accentSoft, primary } = useAdminTheme();
|
||||||
const text = textStrong;
|
const text = textStrong;
|
||||||
const accentText = primary;
|
const accentText = primary;
|
||||||
|
|
||||||
@@ -84,6 +87,14 @@ export default function MobileDashboardPage() {
|
|||||||
const tourTargetSlug = activeEvent?.slug ?? effectiveEvents[0]?.slug ?? null;
|
const tourTargetSlug = activeEvent?.slug ?? effectiveEvents[0]?.slug ?? null;
|
||||||
const tourStepKeys = React.useMemo(() => resolveTourStepKeys(effectiveHasEvents), [effectiveHasEvents]);
|
const tourStepKeys = React.useMemo(() => resolveTourStepKeys(effectiveHasEvents), [effectiveHasEvents]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!slugParam || slugParam === activeEvent?.slug) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectEvent(slugParam);
|
||||||
|
}, [activeEvent?.slug, selectEvent, slugParam]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (status !== 'authenticated' || onboardingTrackedRef.current) {
|
if (status !== 'authenticated' || onboardingTrackedRef.current) {
|
||||||
return;
|
return;
|
||||||
@@ -424,7 +435,7 @@ export default function MobileDashboardPage() {
|
|||||||
onOpen={() => setSummaryOpen(true)}
|
onOpen={() => setSummaryOpen(true)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<EventPickerList events={effectiveEvents} locale={locale} text={text} muted={muted} border={border} />
|
<EventPickerList events={effectiveEvents} locale={locale} navigateOnSelect={false} />
|
||||||
{tourSheet}
|
{tourSheet}
|
||||||
{packageSummarySheet}
|
{packageSummarySheet}
|
||||||
</MobileShell>
|
</MobileShell>
|
||||||
@@ -434,8 +445,7 @@ export default function MobileDashboardPage() {
|
|||||||
return (
|
return (
|
||||||
<MobileShell
|
<MobileShell
|
||||||
activeTab="home"
|
activeTab="home"
|
||||||
title={resolveEventDisplayName(activeEvent ?? undefined)}
|
title={t('mobileDashboard.title', 'Dashboard')}
|
||||||
subtitle={formatEventDate(activeEvent?.event_date, locale) ?? undefined}
|
|
||||||
>
|
>
|
||||||
{showPackageSummaryBanner ? (
|
{showPackageSummaryBanner ? (
|
||||||
<PackageSummaryBanner
|
<PackageSummaryBanner
|
||||||
@@ -443,28 +453,18 @@ export default function MobileDashboardPage() {
|
|||||||
onOpen={() => setSummaryOpen(true)}
|
onOpen={() => setSummaryOpen(true)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<DeviceSetupCard
|
<EventHeaderCard
|
||||||
installPrompt={installPrompt}
|
|
||||||
pushState={pushState}
|
|
||||||
devicePermissions={devicePermissions}
|
|
||||||
onOpenSettings={() => navigate(adminPath('/mobile/settings'))}
|
|
||||||
/>
|
|
||||||
<FeaturedActions
|
|
||||||
tasksEnabled={tasksEnabled}
|
|
||||||
onReviewPhotos={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/photos`))}
|
|
||||||
onManageTasks={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/tasks`))}
|
|
||||||
onShowQr={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/qr`))}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SecondaryGrid
|
|
||||||
event={activeEvent}
|
event={activeEvent}
|
||||||
onGuests={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/members`))}
|
locale={locale}
|
||||||
onPrint={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/qr`))}
|
canSwitch={effectiveMultiple}
|
||||||
onInvites={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/members`))}
|
onSwitch={() => setEventSwitcherOpen(true)}
|
||||||
onSettings={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}`))}
|
onEdit={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/edit`))}
|
||||||
onAnalytics={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/analytics`))}
|
/>
|
||||||
|
<EventManagementGrid
|
||||||
|
event={activeEvent}
|
||||||
|
tasksEnabled={tasksEnabled}
|
||||||
|
onNavigate={(path) => navigate(path)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<KpiStrip
|
<KpiStrip
|
||||||
event={activeEvent}
|
event={activeEvent}
|
||||||
stats={stats}
|
stats={stats}
|
||||||
@@ -474,8 +474,20 @@ export default function MobileDashboardPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<AlertsAndHints event={activeEvent} stats={stats} tasksEnabled={tasksEnabled} />
|
<AlertsAndHints event={activeEvent} stats={stats} tasksEnabled={tasksEnabled} />
|
||||||
|
<DeviceSetupCard
|
||||||
|
installPrompt={installPrompt}
|
||||||
|
pushState={pushState}
|
||||||
|
devicePermissions={devicePermissions}
|
||||||
|
onOpenSettings={() => navigate(adminPath('/mobile/settings'))}
|
||||||
|
/>
|
||||||
{tourSheet}
|
{tourSheet}
|
||||||
{packageSummarySheet}
|
{packageSummarySheet}
|
||||||
|
<EventSwitcherSheet
|
||||||
|
open={eventSwitcherOpen}
|
||||||
|
onClose={() => setEventSwitcherOpen(false)}
|
||||||
|
events={effectiveEvents}
|
||||||
|
locale={locale}
|
||||||
|
/>
|
||||||
</MobileShell>
|
</MobileShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -976,8 +988,20 @@ function OnboardingEmptyState({ installPrompt, pushState, devicePermissions, onO
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function EventPickerList({ events, locale, text, muted, border }: { events: TenantEvent[]; locale: string; text: string; muted: string; border: string }) {
|
function EventPickerList({
|
||||||
|
events,
|
||||||
|
locale,
|
||||||
|
onPick,
|
||||||
|
navigateOnSelect = true,
|
||||||
|
}: {
|
||||||
|
events: TenantEvent[];
|
||||||
|
locale: string;
|
||||||
|
onPick?: (event: TenantEvent) => void;
|
||||||
|
navigateOnSelect?: boolean;
|
||||||
|
}) {
|
||||||
const { t } = useTranslation('management');
|
const { t } = useTranslation('management');
|
||||||
|
const { textStrong, muted, border } = useAdminTheme();
|
||||||
|
const text = textStrong;
|
||||||
const { selectEvent } = useEventContext();
|
const { selectEvent } = useEventContext();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [localEvents, setLocalEvents] = React.useState<TenantEvent[]>(events);
|
const [localEvents, setLocalEvents] = React.useState<TenantEvent[]>(events);
|
||||||
@@ -1008,7 +1032,8 @@ function EventPickerList({ events, locale, text, muted, border }: { events: Tena
|
|||||||
key={event.slug}
|
key={event.slug}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
selectEvent(event.slug ?? null);
|
selectEvent(event.slug ?? null);
|
||||||
if (event.slug) {
|
onPick?.(event);
|
||||||
|
if (navigateOnSelect && event.slug) {
|
||||||
navigate(adminPath(`/mobile/events/${event.slug}`));
|
navigate(adminPath(`/mobile/events/${event.slug}`));
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -1036,140 +1061,232 @@ function EventPickerList({ events, locale, text, muted, border }: { events: Tena
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FeaturedActions({
|
function EventSwitcherSheet({
|
||||||
tasksEnabled,
|
open,
|
||||||
onReviewPhotos,
|
onClose,
|
||||||
onManageTasks,
|
events,
|
||||||
onShowQr,
|
locale,
|
||||||
}: {
|
}: {
|
||||||
tasksEnabled: boolean;
|
open: boolean;
|
||||||
onReviewPhotos: () => void;
|
onClose: () => void;
|
||||||
onManageTasks: () => void;
|
events: TenantEvent[];
|
||||||
onShowQr: () => void;
|
locale: string;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation('management');
|
const { t } = useTranslation('management');
|
||||||
const { textStrong, muted, subtle } = useAdminTheme();
|
|
||||||
const text = textStrong;
|
|
||||||
const cards = [
|
|
||||||
{
|
|
||||||
key: 'photos',
|
|
||||||
label: t('mobileDashboard.photosLabel', 'Review photos'),
|
|
||||||
desc: t('mobileDashboard.photosDesc', 'Moderate uploads and highlights'),
|
|
||||||
icon: ImageIcon,
|
|
||||||
color: ADMIN_ACTION_COLORS.images,
|
|
||||||
action: onReviewPhotos,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'tasks',
|
|
||||||
label: t('mobileDashboard.tasksLabel', 'Manage tasks & challenges'),
|
|
||||||
desc: tasksEnabled
|
|
||||||
? t('mobileDashboard.tasksDesc', 'Assign and track progress')
|
|
||||||
: t('mobileDashboard.tasksDisabledDesc', 'Guests do not see tasks (task mode off)'),
|
|
||||||
icon: ListTodo,
|
|
||||||
color: ADMIN_ACTION_COLORS.tasks,
|
|
||||||
action: onManageTasks,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'qr',
|
|
||||||
label: t('mobileDashboard.qrLabel', 'Show / share QR code'),
|
|
||||||
desc: t('mobileDashboard.qrDesc', 'Posters, cards, and links'),
|
|
||||||
icon: QrCode,
|
|
||||||
color: ADMIN_ACTION_COLORS.qr,
|
|
||||||
action: onShowQr,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<YStack space="$2">
|
<MobileSheet open={open} title={t('mobileDashboard.pickEvent', 'Select an event')} onClose={onClose}>
|
||||||
{cards.map((card) => (
|
<EventPickerList events={events} locale={locale} navigateOnSelect={false} onPick={onClose} />
|
||||||
<Pressable key={card.key} onPress={card.action}>
|
</MobileSheet>
|
||||||
<MobileCard borderColor={`${card.color}44`} backgroundColor={`${card.color}0f`} space="$2.5">
|
|
||||||
<XStack alignItems="center" space="$3">
|
|
||||||
<XStack width={44} height={44} borderRadius={14} backgroundColor={card.color} alignItems="center" justifyContent="center">
|
|
||||||
<card.icon size={20} color="white" />
|
|
||||||
</XStack>
|
|
||||||
<YStack space="$1" flex={1}>
|
|
||||||
<Text fontSize="$md" fontWeight="800" color={text}>
|
|
||||||
{card.label}
|
|
||||||
</Text>
|
|
||||||
<Text fontSize="$xs" color={muted}>
|
|
||||||
{card.desc}
|
|
||||||
</Text>
|
|
||||||
</YStack>
|
|
||||||
<Text fontSize="$xl" color={subtle}>
|
|
||||||
˃
|
|
||||||
</Text>
|
|
||||||
</XStack>
|
|
||||||
</MobileCard>
|
|
||||||
</Pressable>
|
|
||||||
))}
|
|
||||||
</YStack>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SecondaryGrid({
|
function resolveLocation(event: TenantEvent | null, t: (key: string, fallback: string) => string): string {
|
||||||
|
if (!event) return t('events.detail.locationPlaceholder', 'Location');
|
||||||
|
const settings = (event.settings ?? {}) as Record<string, unknown>;
|
||||||
|
const candidate =
|
||||||
|
(settings.location as string | undefined) ??
|
||||||
|
(settings.address as string | undefined) ??
|
||||||
|
(settings.city as string | undefined);
|
||||||
|
if (candidate && candidate.trim()) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
return t('events.detail.locationPlaceholder', 'Location');
|
||||||
|
}
|
||||||
|
|
||||||
|
function EventHeaderCard({
|
||||||
event,
|
event,
|
||||||
onGuests,
|
locale,
|
||||||
onPrint,
|
canSwitch,
|
||||||
onInvites,
|
onSwitch,
|
||||||
onSettings,
|
onEdit,
|
||||||
onAnalytics,
|
|
||||||
}: {
|
}: {
|
||||||
event: TenantEvent | null;
|
event: TenantEvent | null;
|
||||||
onGuests: () => void;
|
locale: string;
|
||||||
onPrint: () => void;
|
canSwitch: boolean;
|
||||||
onInvites: () => void;
|
onSwitch: () => void;
|
||||||
onSettings: () => void;
|
onEdit: () => void;
|
||||||
onAnalytics: () => void;
|
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation('management');
|
const { t } = useTranslation('management');
|
||||||
const { textStrong, muted, border, surface, accentSoft, primary } = useAdminTheme();
|
const { textStrong, muted, border, surface, accentSoft, primary } = useAdminTheme();
|
||||||
const text = textStrong;
|
|
||||||
|
if (!event) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateLabel = formatEventDate(event.event_date, locale) ?? t('events.detail.dateTbd', 'Date tbd');
|
||||||
|
const locationLabel = resolveLocation(event, t);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MobileCard space="$3" borderColor={border} backgroundColor={surface} position="relative">
|
||||||
|
<XStack alignItems="center" justifyContent="space-between" space="$2">
|
||||||
|
{canSwitch ? (
|
||||||
|
<Pressable onPress={onSwitch} aria-label={t('mobileDashboard.pickEvent', 'Select an event')}>
|
||||||
|
<XStack alignItems="center" space="$2">
|
||||||
|
<Text fontSize="$lg" fontWeight="800" color={textStrong}>
|
||||||
|
{resolveEventDisplayName(event)}
|
||||||
|
</Text>
|
||||||
|
<ChevronDown size={16} color={muted} />
|
||||||
|
</XStack>
|
||||||
|
</Pressable>
|
||||||
|
) : (
|
||||||
|
<Text fontSize="$lg" fontWeight="800" color={textStrong}>
|
||||||
|
{resolveEventDisplayName(event)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<PillBadge tone={event.status === 'published' ? 'success' : 'warning'}>
|
||||||
|
{event.status === 'published'
|
||||||
|
? t('events.status.published', 'Live')
|
||||||
|
: t('events.status.draft', 'Draft')}
|
||||||
|
</PillBadge>
|
||||||
|
</XStack>
|
||||||
|
|
||||||
|
<XStack alignItems="center" space="$2">
|
||||||
|
<CalendarDays size={16} color={muted} />
|
||||||
|
<Text fontSize="$sm" color={muted}>
|
||||||
|
{dateLabel}
|
||||||
|
</Text>
|
||||||
|
<MapPin size={16} color={muted} />
|
||||||
|
<Text fontSize="$sm" color={muted}>
|
||||||
|
{locationLabel}
|
||||||
|
</Text>
|
||||||
|
</XStack>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
aria-label={t('mobileEvents.edit', 'Edit event')}
|
||||||
|
onPress={onEdit}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: 16,
|
||||||
|
top: 16,
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 22,
|
||||||
|
backgroundColor: accentSoft,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
boxShadow: '0 6px 16px rgba(0,0,0,0.12)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil size={18} color={primary} />
|
||||||
|
</Pressable>
|
||||||
|
</MobileCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EventManagementGrid({
|
||||||
|
event,
|
||||||
|
tasksEnabled,
|
||||||
|
onNavigate,
|
||||||
|
}: {
|
||||||
|
event: TenantEvent | null;
|
||||||
|
tasksEnabled: boolean;
|
||||||
|
onNavigate: (path: string) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation('management');
|
||||||
|
const { textStrong } = useAdminTheme();
|
||||||
|
const slug = event?.slug ?? null;
|
||||||
const brandingAllowed = isBrandingAllowed(event ?? null);
|
const brandingAllowed = isBrandingAllowed(event ?? null);
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const tiles = [
|
const tiles = [
|
||||||
|
{
|
||||||
|
icon: Pencil,
|
||||||
|
label: t('mobileDashboard.shortcutSettings', 'Event settings'),
|
||||||
|
color: ADMIN_ACTION_COLORS.settings,
|
||||||
|
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/edit`)) : undefined,
|
||||||
|
disabled: !slug,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Sparkles,
|
||||||
|
label: tasksEnabled
|
||||||
|
? t('events.quick.tasks', 'Tasks & Checklists')
|
||||||
|
: `${t('events.quick.tasks', 'Tasks & Checklists')} (${t('common:states.disabled', 'Disabled')})`,
|
||||||
|
color: ADMIN_ACTION_COLORS.tasks,
|
||||||
|
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/tasks`)) : undefined,
|
||||||
|
disabled: !tasksEnabled || !slug,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: QrCode,
|
||||||
|
label: t('events.quick.qr', 'QR Code Layouts'),
|
||||||
|
color: ADMIN_ACTION_COLORS.qr,
|
||||||
|
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/qr`)) : undefined,
|
||||||
|
disabled: !slug,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: ImageIcon,
|
||||||
|
label: t('events.quick.images', 'Image Management'),
|
||||||
|
color: ADMIN_ACTION_COLORS.images,
|
||||||
|
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/photos`)) : undefined,
|
||||||
|
disabled: !slug,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Tv,
|
||||||
|
label: t('events.quick.liveShow', 'Live Show queue'),
|
||||||
|
color: ADMIN_ACTION_COLORS.images,
|
||||||
|
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/live-show`)) : undefined,
|
||||||
|
disabled: !slug,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Settings,
|
||||||
|
label: t('events.quick.liveShowSettings', 'Live Show settings'),
|
||||||
|
color: ADMIN_ACTION_COLORS.images,
|
||||||
|
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/live-show/settings`)) : undefined,
|
||||||
|
disabled: !slug,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
icon: Users,
|
icon: Users,
|
||||||
label: t('mobileDashboard.shortcutGuests', 'Guest management'),
|
label: t('events.quick.guests', 'Guest Management'),
|
||||||
color: ADMIN_ACTION_COLORS.guests,
|
color: ADMIN_ACTION_COLORS.guests,
|
||||||
action: onGuests,
|
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/members`)) : undefined,
|
||||||
|
disabled: !slug,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Megaphone,
|
||||||
|
label: t('events.quick.guestMessages', 'Guest messages'),
|
||||||
|
color: ADMIN_ACTION_COLORS.guestMessages,
|
||||||
|
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/guest-notifications`)) : undefined,
|
||||||
|
disabled: !slug,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Layout,
|
||||||
|
label: t('events.quick.branding', 'Branding & Theme'),
|
||||||
|
color: ADMIN_ACTION_COLORS.branding,
|
||||||
|
onPress: slug && brandingAllowed ? () => onNavigate(adminPath(`/mobile/events/${slug}/branding`)) : undefined,
|
||||||
|
disabled: !brandingAllowed || !slug,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Camera,
|
||||||
|
label: t('events.quick.photobooth', 'Photobooth'),
|
||||||
|
color: ADMIN_ACTION_COLORS.photobooth,
|
||||||
|
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/photobooth`)) : undefined,
|
||||||
|
disabled: !slug,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: TrendingUp,
|
icon: TrendingUp,
|
||||||
label: t('mobileDashboard.shortcutAnalytics', 'Analytics'),
|
label: t('mobileDashboard.shortcutAnalytics', 'Analytics'),
|
||||||
color: ADMIN_ACTION_COLORS.analytics,
|
color: ADMIN_ACTION_COLORS.analytics,
|
||||||
action: onAnalytics,
|
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/analytics`)) : undefined,
|
||||||
},
|
disabled: !slug,
|
||||||
{
|
|
||||||
icon: QrCode,
|
|
||||||
label: t('mobileDashboard.shortcutPrints', 'Print & poster downloads'),
|
|
||||||
color: ADMIN_ACTION_COLORS.qr,
|
|
||||||
action: onPrint,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: Sparkles,
|
|
||||||
label: t('mobileDashboard.shortcutInvites', 'Team / helper invites'),
|
|
||||||
color: ADMIN_ACTION_COLORS.invites,
|
|
||||||
action: onInvites,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: Settings,
|
|
||||||
label: t('mobileDashboard.shortcutSettings', 'Event settings'),
|
|
||||||
color: ADMIN_ACTION_COLORS.success,
|
|
||||||
action: onSettings,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: Sparkles,
|
|
||||||
label: t('mobileDashboard.shortcutBranding', 'Branding & moderation'),
|
|
||||||
color: ADMIN_ACTION_COLORS.branding,
|
|
||||||
action: brandingAllowed ? onSettings : undefined,
|
|
||||||
disabled: !brandingAllowed,
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if (event && isPastEvent(event.event_date)) {
|
||||||
|
tiles.push({
|
||||||
|
icon: Sparkles,
|
||||||
|
label: t('events.quick.recap', 'Recap & Archive'),
|
||||||
|
color: ADMIN_ACTION_COLORS.recap,
|
||||||
|
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/recap`)) : undefined,
|
||||||
|
disabled: !slug,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<YStack space="$2" marginTop="$2">
|
<YStack space="$2">
|
||||||
<Text fontSize="$sm" fontWeight="800" color={text}>
|
<Text fontSize="$sm" fontWeight="800" color={textStrong}>
|
||||||
{t('mobileDashboard.shortcutsTitle', 'Shortcuts')}
|
{t('events.detail.managementTitle', 'Event management')}
|
||||||
</Text>
|
</Text>
|
||||||
<XStack flexWrap="wrap" space="$2">
|
<XStack flexWrap="wrap" space="$2">
|
||||||
{tiles.map((tile, index) => (
|
{tiles.map((tile, index) => (
|
||||||
@@ -1178,22 +1295,12 @@ function SecondaryGrid({
|
|||||||
icon={tile.icon}
|
icon={tile.icon}
|
||||||
label={tile.label}
|
label={tile.label}
|
||||||
color={tile.color}
|
color={tile.color}
|
||||||
onPress={tile.action}
|
onPress={tile.onPress}
|
||||||
disabled={tile.disabled}
|
disabled={tile.disabled}
|
||||||
delayMs={index * ADMIN_MOTION.tileStaggerMs}
|
delayMs={index * ADMIN_MOTION.tileStaggerMs}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</XStack>
|
</XStack>
|
||||||
{event ? (
|
|
||||||
<MobileCard backgroundColor={surface} borderColor={border} space="$1.5">
|
|
||||||
<Text fontSize="$sm" fontWeight="700" color={text}>
|
|
||||||
{resolveEventDisplayName(event)}
|
|
||||||
</Text>
|
|
||||||
<Text fontSize="$xs" color={muted}>
|
|
||||||
{renderEventLocation(event)}
|
|
||||||
</Text>
|
|
||||||
</MobileCard>
|
|
||||||
) : null}
|
|
||||||
</YStack>
|
</YStack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,25 +2,24 @@ import React from 'react';
|
|||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { BarChart2, TrendingUp, Users, ListTodo, Lock, Trophy, Calendar } from 'lucide-react';
|
import { TrendingUp, Users, ListTodo, Lock, Trophy } from 'lucide-react';
|
||||||
import { YStack, XStack } from '@tamagui/stacks';
|
import { YStack, XStack } from '@tamagui/stacks';
|
||||||
import { SizableText as Text } from '@tamagui/text';
|
import { SizableText as Text } from '@tamagui/text';
|
||||||
import { format, parseISO } from 'date-fns';
|
import { format, parseISO } from 'date-fns';
|
||||||
import { de, enGB } from 'date-fns/locale';
|
import { de, enGB } from 'date-fns/locale';
|
||||||
|
|
||||||
import { MobileShell } from './components/MobileShell';
|
import { MobileShell } from './components/MobileShell';
|
||||||
import { MobileCard, CTAButton, SkeletonCard } from './components/Primitives';
|
import { MobileCard, CTAButton, KpiTile, SkeletonCard } from './components/Primitives';
|
||||||
import { getEventAnalytics, EventAnalytics } from '../api';
|
import { getEventAnalytics, EventAnalytics } from '../api';
|
||||||
import { ApiError } from '../lib/apiError';
|
import { ApiError } from '../lib/apiError';
|
||||||
import { useAdminTheme } from './theme';
|
import { useAdminTheme } from './theme';
|
||||||
|
import { resolveMaxCount, resolveTimelineHours } from './lib/analytics';
|
||||||
import { adminPath } from '../constants';
|
import { adminPath } from '../constants';
|
||||||
import { useEventContext } from '../context/EventContext';
|
|
||||||
|
|
||||||
export default function MobileEventAnalyticsPage() {
|
export default function MobileEventAnalyticsPage() {
|
||||||
const { slug } = useParams<{ slug: string }>();
|
const { slug } = useParams<{ slug: string }>();
|
||||||
const { t, i18n } = useTranslation('management');
|
const { t, i18n } = useTranslation('management');
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { activeEvent } = useEventContext();
|
|
||||||
const { textStrong, muted, border, surface, primary, accentSoft } = useAdminTheme();
|
const { textStrong, muted, border, surface, primary, accentSoft } = useAdminTheme();
|
||||||
|
|
||||||
const dateLocale = i18n.language.startsWith('de') ? de : enGB;
|
const dateLocale = i18n.language.startsWith('de') ? de : enGB;
|
||||||
@@ -36,7 +35,7 @@ export default function MobileEventAnalyticsPage() {
|
|||||||
|
|
||||||
if (isFeatureLocked) {
|
if (isFeatureLocked) {
|
||||||
return (
|
return (
|
||||||
<MobileShell title={t('analytics.title', 'Analytics')} activeTab="events">
|
<MobileShell title={t('analytics.title', 'Analytics')} activeTab="home">
|
||||||
<MobileCard
|
<MobileCard
|
||||||
space="$4"
|
space="$4"
|
||||||
padding="$6"
|
padding="$6"
|
||||||
@@ -75,7 +74,7 @@ export default function MobileEventAnalyticsPage() {
|
|||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<MobileShell title={t('analytics.title', 'Analytics')} activeTab="events">
|
<MobileShell title={t('analytics.title', 'Analytics')} activeTab="home">
|
||||||
<YStack space="$3">
|
<YStack space="$3">
|
||||||
<SkeletonCard height={200} />
|
<SkeletonCard height={200} />
|
||||||
<SkeletonCard height={150} />
|
<SkeletonCard height={150} />
|
||||||
@@ -87,7 +86,7 @@ export default function MobileEventAnalyticsPage() {
|
|||||||
|
|
||||||
if (error || !data) {
|
if (error || !data) {
|
||||||
return (
|
return (
|
||||||
<MobileShell title={t('analytics.title', 'Analytics')} activeTab="events">
|
<MobileShell title={t('analytics.title', 'Analytics')} activeTab="home">
|
||||||
<MobileCard borderColor={border} padding="$4">
|
<MobileCard borderColor={border} padding="$4">
|
||||||
<Text color={muted}>{t('common.error', 'Something went wrong')}</Text>
|
<Text color={muted}>{t('common.error', 'Something went wrong')}</Text>
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
@@ -99,18 +98,47 @@ export default function MobileEventAnalyticsPage() {
|
|||||||
const hasTimeline = timeline.length > 0;
|
const hasTimeline = timeline.length > 0;
|
||||||
const hasContributors = contributors.length > 0;
|
const hasContributors = contributors.length > 0;
|
||||||
const hasTasks = tasks.length > 0;
|
const hasTasks = tasks.length > 0;
|
||||||
|
const fallbackHours = 12;
|
||||||
|
const rawTimelineHours = resolveTimelineHours(timeline.map((point) => point.timestamp), fallbackHours);
|
||||||
|
const timeframeHours = Math.min(rawTimelineHours, fallbackHours);
|
||||||
|
const isTimeframeCapped = rawTimelineHours > fallbackHours;
|
||||||
|
|
||||||
// Prepare chart data
|
// Prepare chart data
|
||||||
const maxCount = Math.max(...timeline.map((p) => p.count), 1);
|
const maxTimelineCount = resolveMaxCount(timeline.map((point) => point.count));
|
||||||
|
const maxTaskCount = resolveMaxCount(tasks.map((task) => task.count));
|
||||||
|
const totalUploads = timeline.reduce((total, point) => total + point.count, 0);
|
||||||
|
const totalLikes = contributors.reduce((total, contributor) => total + contributor.likes, 0);
|
||||||
|
const totalContributors = contributors.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MobileShell
|
<MobileShell
|
||||||
title={t('analytics.title', 'Analytics')}
|
title={t('analytics.title', 'Analytics')}
|
||||||
subtitle={activeEvent?.name as string}
|
activeTab="home"
|
||||||
activeTab="events"
|
onBack={() => navigate(-1)}
|
||||||
showBack
|
|
||||||
>
|
>
|
||||||
<YStack space="$4">
|
<YStack space="$4">
|
||||||
|
<YStack space="$2">
|
||||||
|
<Text fontSize="$sm" fontWeight="800" color={textStrong}>
|
||||||
|
{t('analytics.kpiTitle', 'Event snapshot')}
|
||||||
|
</Text>
|
||||||
|
<XStack space="$2" flexWrap="wrap">
|
||||||
|
<KpiTile
|
||||||
|
icon={TrendingUp}
|
||||||
|
label={t('analytics.kpiUploads', 'Uploads')}
|
||||||
|
value={totalUploads}
|
||||||
|
/>
|
||||||
|
<KpiTile
|
||||||
|
icon={Users}
|
||||||
|
label={t('analytics.kpiContributors', 'Contributors')}
|
||||||
|
value={totalContributors}
|
||||||
|
/>
|
||||||
|
<KpiTile
|
||||||
|
icon={Trophy}
|
||||||
|
label={t('analytics.kpiLikes', 'Likes')}
|
||||||
|
value={totalLikes}
|
||||||
|
/>
|
||||||
|
</XStack>
|
||||||
|
</YStack>
|
||||||
{/* Activity Timeline */}
|
{/* Activity Timeline */}
|
||||||
<MobileCard space="$3" borderColor={border} backgroundColor={surface}>
|
<MobileCard space="$3" borderColor={border} backgroundColor={surface}>
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" space="$2">
|
||||||
@@ -119,12 +147,22 @@ export default function MobileEventAnalyticsPage() {
|
|||||||
{t('analytics.activityTitle', 'Activity Timeline')}
|
{t('analytics.activityTitle', 'Activity Timeline')}
|
||||||
</Text>
|
</Text>
|
||||||
</XStack>
|
</XStack>
|
||||||
|
<YStack space="$0.5">
|
||||||
|
<Text fontSize="$xs" color={muted}>
|
||||||
|
{t('analytics.timeframe', 'Last {{hours}} hours', { hours: timeframeHours })}
|
||||||
|
</Text>
|
||||||
|
{isTimeframeCapped ? (
|
||||||
|
<Text fontSize="$xs" color={muted}>
|
||||||
|
{t('analytics.timeframeHint', 'Older activity hidden')}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</YStack>
|
||||||
|
|
||||||
{hasTimeline ? (
|
{hasTimeline ? (
|
||||||
<YStack height={180} justifyContent="flex-end" space="$2">
|
<YStack height={180} justifyContent="flex-end" space="$2">
|
||||||
<XStack alignItems="flex-end" justifyContent="space-between" height={150} gap="$1">
|
<XStack alignItems="flex-end" justifyContent="space-between" height={150} gap="$1">
|
||||||
{timeline.map((point, index) => {
|
{timeline.map((point, index) => {
|
||||||
const heightPercent = (point.count / maxCount) * 100;
|
const heightPercent = (point.count / maxTimelineCount) * 100;
|
||||||
const date = parseISO(point.timestamp);
|
const date = parseISO(point.timestamp);
|
||||||
// Show label every 3rd point or if few points
|
// Show label every 3rd point or if few points
|
||||||
const showLabel = timeline.length < 8 || index % 3 === 0;
|
const showLabel = timeline.length < 8 || index % 3 === 0;
|
||||||
@@ -141,7 +179,7 @@ export default function MobileEventAnalyticsPage() {
|
|||||||
/>
|
/>
|
||||||
{showLabel && (
|
{showLabel && (
|
||||||
<Text fontSize={10} color={muted} numberOfLines={1}>
|
<Text fontSize={10} color={muted} numberOfLines={1}>
|
||||||
{format(date, 'HH:mm')}
|
{format(date, 'HH:mm', { locale: dateLocale })}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</YStack>
|
</YStack>
|
||||||
@@ -153,7 +191,11 @@ export default function MobileEventAnalyticsPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
</YStack>
|
</YStack>
|
||||||
) : (
|
) : (
|
||||||
<EmptyState message={t('analytics.noActivity', 'No uploads yet')} />
|
<EmptyState
|
||||||
|
message={t('analytics.noActivity', 'No uploads yet')}
|
||||||
|
actionLabel={t('analytics.emptyActionShareQr', 'Share your QR code')}
|
||||||
|
onAction={() => slug && navigate(adminPath(`/mobile/events/${slug}/qr`))}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
|
|
||||||
@@ -199,7 +241,11 @@ export default function MobileEventAnalyticsPage() {
|
|||||||
))}
|
))}
|
||||||
</YStack>
|
</YStack>
|
||||||
) : (
|
) : (
|
||||||
<EmptyState message={t('analytics.noContributors', 'No contributors yet')} />
|
<EmptyState
|
||||||
|
message={t('analytics.noContributors', 'No contributors yet')}
|
||||||
|
actionLabel={t('analytics.emptyActionInvite', 'Invite guests')}
|
||||||
|
onAction={() => slug && navigate(adminPath(`/mobile/events/${slug}/members`))}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
|
|
||||||
@@ -215,7 +261,6 @@ export default function MobileEventAnalyticsPage() {
|
|||||||
{hasTasks ? (
|
{hasTasks ? (
|
||||||
<YStack space="$3">
|
<YStack space="$3">
|
||||||
{tasks.map((task) => {
|
{tasks.map((task) => {
|
||||||
const maxTaskCount = Math.max(...tasks.map(t => t.count), 1);
|
|
||||||
const percent = (task.count / maxTaskCount) * 100;
|
const percent = (task.count / maxTaskCount) * 100;
|
||||||
return (
|
return (
|
||||||
<YStack key={task.task_id} space="$1">
|
<YStack key={task.task_id} space="$1">
|
||||||
@@ -240,7 +285,11 @@ export default function MobileEventAnalyticsPage() {
|
|||||||
})}
|
})}
|
||||||
</YStack>
|
</YStack>
|
||||||
) : (
|
) : (
|
||||||
<EmptyState message={t('analytics.noTasks', 'No task activity yet')} />
|
<EmptyState
|
||||||
|
message={t('analytics.noTasks', 'No task activity yet')}
|
||||||
|
actionLabel={t('analytics.emptyActionOpenTasks', 'Open tasks')}
|
||||||
|
onAction={() => slug && navigate(adminPath(`/mobile/events/${slug}/tasks`))}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
</YStack>
|
</YStack>
|
||||||
@@ -248,13 +297,24 @@ export default function MobileEventAnalyticsPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function EmptyState({ message }: { message: string }) {
|
function EmptyState({
|
||||||
|
message,
|
||||||
|
actionLabel,
|
||||||
|
onAction,
|
||||||
|
}: {
|
||||||
|
message: string;
|
||||||
|
actionLabel?: string;
|
||||||
|
onAction?: () => void;
|
||||||
|
}) {
|
||||||
const { muted } = useAdminTheme();
|
const { muted } = useAdminTheme();
|
||||||
return (
|
return (
|
||||||
<YStack padding="$4" alignItems="center" justifyContent="center">
|
<YStack padding="$4" alignItems="center" justifyContent="center" space="$2">
|
||||||
<Text fontSize="$sm" color={muted}>
|
<Text fontSize="$sm" color={muted}>
|
||||||
{message}
|
{message}
|
||||||
</Text>
|
</Text>
|
||||||
|
{actionLabel && onAction ? (
|
||||||
|
<CTAButton label={actionLabel} tone="ghost" fullWidth={false} onPress={onAction} />
|
||||||
|
) : null}
|
||||||
</YStack>
|
</YStack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,343 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { CalendarDays, MapPin, Settings, Users, Camera, Sparkles, QrCode, Image, Shield, Layout, RefreshCcw, Pencil, Megaphone, Tv } from 'lucide-react';
|
|
||||||
import { YStack, XStack } from '@tamagui/stacks';
|
|
||||||
import { SizableText as Text } from '@tamagui/text';
|
|
||||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
|
||||||
import { MobileShell, HeaderActionButton } from './components/MobileShell';
|
|
||||||
import { MobileCard, PillBadge, KpiTile, ActionTile } from './components/Primitives';
|
|
||||||
import { TenantEvent, EventStats, EventToolkit, getEvent, getEventStats, getEventToolkit, getEvents } from '../api';
|
|
||||||
import { adminPath, ADMIN_EVENT_BRANDING_PATH, ADMIN_EVENT_GUEST_NOTIFICATIONS_PATH, ADMIN_EVENT_INVITES_PATH, ADMIN_EVENT_LIVE_SHOW_PATH, ADMIN_EVENT_LIVE_SHOW_SETTINGS_PATH, ADMIN_EVENT_MEMBERS_PATH, ADMIN_EVENT_PHOTOS_PATH, ADMIN_EVENT_TASKS_PATH } from '../constants';
|
|
||||||
import { isAuthError } from '../auth/tokens';
|
|
||||||
import { getApiErrorMessage } from '../lib/apiError';
|
|
||||||
import { MobileSheet } from './components/Sheet';
|
|
||||||
import { useEventContext } from '../context/EventContext';
|
|
||||||
import { formatEventDate, isBrandingAllowed, resolveEngagementMode, resolveEventDisplayName } from '../lib/events';
|
|
||||||
import { isPastEvent } from './eventDate';
|
|
||||||
import { useBackNavigation } from './hooks/useBackNavigation';
|
|
||||||
import { ADMIN_ACTION_COLORS, ADMIN_MOTION, useAdminTheme } from './theme';
|
|
||||||
|
|
||||||
export default function MobileEventDetailPage() {
|
|
||||||
const { slug: slugParam } = useParams<{ slug?: string }>();
|
|
||||||
const slug = slugParam ?? null;
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { t } = useTranslation('management');
|
|
||||||
|
|
||||||
const [event, setEvent] = React.useState<TenantEvent | null>(null);
|
|
||||||
const [stats, setStats] = React.useState<EventStats | null>(null);
|
|
||||||
const [toolkit, setToolkit] = React.useState<EventToolkit | null>(null);
|
|
||||||
const [loading, setLoading] = React.useState(true);
|
|
||||||
const [error, setError] = React.useState<string | null>(null);
|
|
||||||
const { events, activeEvent, selectEvent } = useEventContext();
|
|
||||||
const [showEventPicker, setShowEventPicker] = React.useState(false);
|
|
||||||
const back = useBackNavigation(adminPath('/mobile/events'));
|
|
||||||
const { textStrong, text, muted, danger, accentSoft } = useAdminTheme();
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!slug) return;
|
|
||||||
selectEvent(slug);
|
|
||||||
}, [slug, selectEvent]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!slug) return;
|
|
||||||
(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const [eventData, statsData, toolkitData] = await Promise.all([getEvent(slug), getEventStats(slug), getEventToolkit(slug)]);
|
|
||||||
setEvent(eventData);
|
|
||||||
setStats(statsData);
|
|
||||||
setToolkit(toolkitData);
|
|
||||||
setError(null);
|
|
||||||
} catch (err) {
|
|
||||||
if (!isAuthError(err)) {
|
|
||||||
try {
|
|
||||||
const list = await getEvents({ force: true });
|
|
||||||
const fallback = list.find((ev: TenantEvent) => ev.slug === slug) ?? null;
|
|
||||||
if (fallback) {
|
|
||||||
setEvent(fallback);
|
|
||||||
setError(null);
|
|
||||||
} else {
|
|
||||||
setError(getApiErrorMessage(err, t('events.errors.loadFailed', 'Event konnte nicht geladen werden.')));
|
|
||||||
}
|
|
||||||
} catch (fallbackErr) {
|
|
||||||
setError(getApiErrorMessage(fallbackErr, t('events.errors.loadFailed', 'Event konnte nicht geladen werden.')));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}, [slug, t]);
|
|
||||||
|
|
||||||
const tasksEnabled = resolveEngagementMode(event ?? activeEvent ?? null) !== 'photo_only';
|
|
||||||
const brandingAllowed = isBrandingAllowed(event ?? activeEvent ?? null);
|
|
||||||
|
|
||||||
const kpis = [
|
|
||||||
{
|
|
||||||
label: t('events.detail.kpi.guests', 'Guests Registered'),
|
|
||||||
value: toolkit?.invites?.summary.total ?? event?.active_invites_count ?? '—',
|
|
||||||
icon: Users,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t('events.detail.kpi.photos', 'Images Uploaded'),
|
|
||||||
value: stats?.uploads_total ?? event?.photo_count ?? '—',
|
|
||||||
icon: Camera,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
if (tasksEnabled) {
|
|
||||||
kpis.unshift({
|
|
||||||
label: t('events.detail.kpi.tasks', 'Active Tasks'),
|
|
||||||
value: event?.tasks_count ?? toolkit?.tasks?.summary?.total ?? '—',
|
|
||||||
icon: Sparkles,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MobileShell
|
|
||||||
activeTab="home"
|
|
||||||
title={resolveEventDisplayName(event ?? activeEvent ?? undefined)}
|
|
||||||
subtitle={
|
|
||||||
event?.event_date || activeEvent?.event_date
|
|
||||||
? formatDate(event?.event_date ?? activeEvent?.event_date, t)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
onBack={back}
|
|
||||||
headerActions={
|
|
||||||
<XStack space="$3" alignItems="center">
|
|
||||||
<HeaderActionButton onPress={() => navigate(adminPath('/mobile/settings'))} ariaLabel={t('mobileSettings.title', 'Settings')}>
|
|
||||||
<Settings size={18} color={textStrong} />
|
|
||||||
</HeaderActionButton>
|
|
||||||
<HeaderActionButton onPress={() => navigate(0)} ariaLabel={t('common.refresh', 'Refresh')}>
|
|
||||||
<RefreshCcw size={18} color={textStrong} />
|
|
||||||
</HeaderActionButton>
|
|
||||||
</XStack>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{error ? (
|
|
||||||
<MobileCard>
|
|
||||||
<Text fontWeight="700" color={danger}>
|
|
||||||
{error}
|
|
||||||
</Text>
|
|
||||||
</MobileCard>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<MobileCard space="$3">
|
|
||||||
<Text fontSize="$lg" fontWeight="800" color={textStrong}>
|
|
||||||
{event ? renderName(event.name, t) : t('events.placeholders.untitled', 'Unbenanntes Event')}
|
|
||||||
</Text>
|
|
||||||
<XStack alignItems="center" space="$2">
|
|
||||||
<CalendarDays size={16} color={muted} />
|
|
||||||
<Text fontSize="$sm" color={muted}>
|
|
||||||
{formatDate(event?.event_date, t)}
|
|
||||||
</Text>
|
|
||||||
<MapPin size={16} color={muted} />
|
|
||||||
<Text fontSize="$sm" color={muted}>
|
|
||||||
{resolveLocation(event, t)}
|
|
||||||
</Text>
|
|
||||||
</XStack>
|
|
||||||
<PillBadge tone={event?.status === 'published' ? 'success' : 'warning'}>
|
|
||||||
{event?.status === 'published' ? t('events.status.published', 'Live') : t('events.status.draft', 'Draft')}
|
|
||||||
</PillBadge>
|
|
||||||
<Pressable
|
|
||||||
aria-label={t('mobileEvents.edit', 'Edit event')}
|
|
||||||
onPress={() => slug && navigate(adminPath(`/mobile/events/${slug}/edit`))}
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
right: 16,
|
|
||||||
top: 16,
|
|
||||||
width: 44,
|
|
||||||
height: 44,
|
|
||||||
borderRadius: 22,
|
|
||||||
backgroundColor: accentSoft,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
boxShadow: '0 6px 16px rgba(0,0,0,0.12)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Pencil size={18} color={textStrong} />
|
|
||||||
</Pressable>
|
|
||||||
</MobileCard>
|
|
||||||
|
|
||||||
<YStack space="$2">
|
|
||||||
{loading ? (
|
|
||||||
<XStack space="$2" flexWrap="wrap">
|
|
||||||
{Array.from({ length: 3 }).map((_, idx) => (
|
|
||||||
<MobileCard key={`kpi-${idx}`} height={90} width="32%" />
|
|
||||||
))}
|
|
||||||
</XStack>
|
|
||||||
) : (
|
|
||||||
<XStack space="$2" flexWrap="wrap">
|
|
||||||
{kpis.map((kpi) => (
|
|
||||||
<KpiTile key={kpi.label} icon={kpi.icon} label={kpi.label} value={kpi.value} />
|
|
||||||
))}
|
|
||||||
</XStack>
|
|
||||||
)}
|
|
||||||
</YStack>
|
|
||||||
|
|
||||||
<MobileSheet
|
|
||||||
open={showEventPicker}
|
|
||||||
onClose={() => setShowEventPicker(false)}
|
|
||||||
title={t('events.detail.pickEvent', 'Event wählen')}
|
|
||||||
footer={null}
|
|
||||||
bottomOffsetPx={120}
|
|
||||||
>
|
|
||||||
<YStack space="$2">
|
|
||||||
{events.length === 0 ? (
|
|
||||||
<Text fontSize={12.5} color={muted}>
|
|
||||||
{t('events.list.empty.description', 'Starte jetzt mit deinem ersten Event.')}
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
events.map((ev) => (
|
|
||||||
<Pressable
|
|
||||||
key={ev.slug}
|
|
||||||
onPress={() => {
|
|
||||||
selectEvent(ev.slug ?? null);
|
|
||||||
setShowEventPicker(false);
|
|
||||||
navigate(adminPath(`/mobile/events/${ev.slug}`));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<XStack alignItems="center" justifyContent="space-between" paddingVertical="$2">
|
|
||||||
<YStack space="$1">
|
|
||||||
<Text fontSize={13} fontWeight="700" color={textStrong}>
|
|
||||||
{renderName(ev.name, t)}
|
|
||||||
</Text>
|
|
||||||
<XStack alignItems="center" space="$1.5">
|
|
||||||
<CalendarDays size={14} color={muted} />
|
|
||||||
<Text fontSize={12} color={muted}>
|
|
||||||
{formatDate(ev.event_date, t)}
|
|
||||||
</Text>
|
|
||||||
</XStack>
|
|
||||||
</YStack>
|
|
||||||
<PillBadge tone={ev.slug === activeEvent?.slug ? 'success' : 'muted'}>
|
|
||||||
{ev.slug === activeEvent?.slug ? t('events.detail.active', 'Aktiv') : t('events.actions.open', 'Öffnen')}
|
|
||||||
</PillBadge>
|
|
||||||
</XStack>
|
|
||||||
</Pressable>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</YStack>
|
|
||||||
</MobileSheet>
|
|
||||||
|
|
||||||
<YStack space="$2">
|
|
||||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
|
||||||
{t('events.detail.managementTitle', 'Event Management')}
|
|
||||||
</Text>
|
|
||||||
<XStack flexWrap="wrap" space="$2">
|
|
||||||
<ActionTile
|
|
||||||
icon={Sparkles}
|
|
||||||
label={
|
|
||||||
tasksEnabled
|
|
||||||
? t('events.quick.tasks', 'Tasks & Checklists')
|
|
||||||
: `${t('events.quick.tasks', 'Tasks & Checklists')} (${t('common:states.disabled', 'Disabled')})`
|
|
||||||
}
|
|
||||||
color={ADMIN_ACTION_COLORS.tasks}
|
|
||||||
onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/tasks`))}
|
|
||||||
delayMs={0}
|
|
||||||
/>
|
|
||||||
<ActionTile
|
|
||||||
icon={QrCode}
|
|
||||||
label={t('events.quick.qr', 'QR Code Layouts')}
|
|
||||||
color={ADMIN_ACTION_COLORS.qr}
|
|
||||||
onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/qr`))}
|
|
||||||
delayMs={ADMIN_MOTION.tileStaggerMs}
|
|
||||||
/>
|
|
||||||
<ActionTile
|
|
||||||
icon={Image}
|
|
||||||
label={t('events.quick.images', 'Image Management')}
|
|
||||||
color={ADMIN_ACTION_COLORS.images}
|
|
||||||
onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/photos`))}
|
|
||||||
delayMs={ADMIN_MOTION.tileStaggerMs * 2}
|
|
||||||
/>
|
|
||||||
<ActionTile
|
|
||||||
icon={Tv}
|
|
||||||
label={t('events.quick.liveShow', 'Live Show queue')}
|
|
||||||
color={ADMIN_ACTION_COLORS.images}
|
|
||||||
onPress={() => slug && navigate(ADMIN_EVENT_LIVE_SHOW_PATH(slug))}
|
|
||||||
disabled={!slug}
|
|
||||||
delayMs={ADMIN_MOTION.tileStaggerMs * 3}
|
|
||||||
/>
|
|
||||||
<ActionTile
|
|
||||||
icon={Settings}
|
|
||||||
label={t('events.quick.liveShowSettings', 'Live Show settings')}
|
|
||||||
color={ADMIN_ACTION_COLORS.images}
|
|
||||||
onPress={() => slug && navigate(ADMIN_EVENT_LIVE_SHOW_SETTINGS_PATH(slug))}
|
|
||||||
disabled={!slug}
|
|
||||||
delayMs={ADMIN_MOTION.tileStaggerMs * 4}
|
|
||||||
/>
|
|
||||||
<ActionTile
|
|
||||||
icon={Users}
|
|
||||||
label={t('events.quick.guests', 'Guest Management')}
|
|
||||||
color={ADMIN_ACTION_COLORS.guests}
|
|
||||||
onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/members`))}
|
|
||||||
delayMs={ADMIN_MOTION.tileStaggerMs * 5}
|
|
||||||
/>
|
|
||||||
<ActionTile
|
|
||||||
icon={Megaphone}
|
|
||||||
label={t('events.quick.guestMessages', 'Guest messages')}
|
|
||||||
color={ADMIN_ACTION_COLORS.guestMessages}
|
|
||||||
onPress={() => slug && navigate(ADMIN_EVENT_GUEST_NOTIFICATIONS_PATH(slug))}
|
|
||||||
disabled={!slug}
|
|
||||||
delayMs={ADMIN_MOTION.tileStaggerMs * 6}
|
|
||||||
/>
|
|
||||||
<ActionTile
|
|
||||||
icon={Layout}
|
|
||||||
label={t('events.quick.branding', 'Branding & Theme')}
|
|
||||||
color={ADMIN_ACTION_COLORS.branding}
|
|
||||||
onPress={
|
|
||||||
brandingAllowed ? () => navigate(adminPath(`/mobile/events/${slug ?? ''}/branding`)) : undefined
|
|
||||||
}
|
|
||||||
disabled={!brandingAllowed}
|
|
||||||
delayMs={ADMIN_MOTION.tileStaggerMs * 7}
|
|
||||||
/>
|
|
||||||
<ActionTile
|
|
||||||
icon={Camera}
|
|
||||||
label={t('events.quick.photobooth', 'Photobooth')}
|
|
||||||
color={ADMIN_ACTION_COLORS.photobooth}
|
|
||||||
onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/photobooth`))}
|
|
||||||
delayMs={ADMIN_MOTION.tileStaggerMs * 8}
|
|
||||||
/>
|
|
||||||
{isPastEvent(event?.event_date) ? (
|
|
||||||
<ActionTile
|
|
||||||
icon={Sparkles}
|
|
||||||
label={t('events.quick.recap', 'Recap & Archive')}
|
|
||||||
color={ADMIN_ACTION_COLORS.recap}
|
|
||||||
onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/recap`))}
|
|
||||||
delayMs={ADMIN_MOTION.tileStaggerMs * 9}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</XStack>
|
|
||||||
</YStack>
|
|
||||||
</MobileShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderName(name: TenantEvent['name'], t: (key: string, fallback: string) => string): string {
|
|
||||||
const fallback = t('events.placeholders.untitled', 'Untitled event');
|
|
||||||
if (typeof name === 'string' && name.trim()) return name;
|
|
||||||
if (name && typeof name === 'object') {
|
|
||||||
return name.de ?? name.en ?? Object.values(name)[0] ?? fallback;
|
|
||||||
}
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(iso: string | null | undefined, t: (key: string, fallback: string) => string): string {
|
|
||||||
if (!iso) return t('events.detail.dateTbd', 'Date tbd');
|
|
||||||
const date = new Date(iso);
|
|
||||||
if (Number.isNaN(date.getTime())) return t('events.detail.dateTbd', 'Date tbd');
|
|
||||||
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveLocation(event: TenantEvent | null, t: (key: string, fallback: string) => string): string {
|
|
||||||
if (!event) return t('events.detail.locationPlaceholder', 'Location');
|
|
||||||
const settings = (event.settings ?? {}) as Record<string, unknown>;
|
|
||||||
const candidate =
|
|
||||||
(settings.location as string | undefined) ??
|
|
||||||
(settings.address as string | undefined) ??
|
|
||||||
(settings.city as string | undefined);
|
|
||||||
if (candidate && candidate.trim()) {
|
|
||||||
return candidate;
|
|
||||||
}
|
|
||||||
return t('events.detail.locationPlaceholder', 'Location');
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user