Compare commits

..

44 Commits

Author SHA1 Message Date
Codex Agent
a9fa1546f7 bd sync: 2026-01-23 09:20:33 2026-01-23 09:20:34 +01:00
Codex Agent
7c6eee187c bd sync: 2026-01-23 08:56:22 2026-01-23 08:56:23 +01:00
Codex Agent
fbd46b8e5c bd sync: 2026-01-21 12:55:26 2026-01-21 12:55:26 +01:00
Codex Agent
886b336a08 bd sync: 2026-01-19 18:50:20 2026-01-19 18:50:20 +01:00
Codex Agent
02237735ec bd sync: 2026-01-18 11:02:27 2026-01-18 11:02:28 +01:00
Codex Agent
5e420a0dd8 bd sync: 2026-01-15 19:54:28 2026-01-15 19:54:28 +01:00
Codex Agent
2a55ae934f bd sync: 2026-01-13 11:04:44 2026-01-13 11:04:44 +01:00
Codex Agent
e4100f7800 Polish uploader UI and queue handling
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 17:35:05 +01:00
Codex Agent
7786e3d134 Switch photobooth uploader to Avalonia
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 17:26:45 +01:00
Codex Agent
30f3d148bb bd sync: 2026-01-12 17:24:05
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 17:24:05 +01:00
Codex Agent
1970c259ed Restore photobooth uploader files after sync 2026-01-12 17:23:34 +01:00
Codex Agent
dc5c80cda4 bd sync: 2026-01-12 17:21:15
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 17:21:16 +01:00
Codex Agent
75a9bcee12 Migrate photobooth uploader to Avalonia 2026-01-12 17:20:35 +01:00
Codex Agent
6fe363640f Reapply photobooth uploader changes after sync
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 17:10:47 +01:00
Codex Agent
3df0542013 bd sync: 2026-01-12 17:10:05
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 17:10:05 +01:00
Codex Agent
4f4a527010 Reapply photobooth uploader changes 2026-01-12 17:09:37 +01:00
Codex Agent
e69c94ad20 bd sync: 2026-01-12 17:07:55
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 17:07:55 +01:00
Codex Agent
5afa96251b Fix WinUI build settings for linux tooling 2026-01-12 17:07:28 +01:00
Codex Agent
24f053d4c4 Add photobooth connect codes and uploader pipeline
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 17:02:50 +01:00
Codex Agent
ec360ed860 bd sync: 2026-01-12 17:02:15
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 17:02:15 +01:00
Codex Agent
83e78d7c66 Update backend photobooth connect API 2026-01-12 16:59:49 +01:00
Codex Agent
9b1c5bf978 bd sync: 2026-01-12 16:57:37
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 16:57:37 +01:00
Codex Agent
fb23a0a2f3 Add photobooth connect codes and uploader scaffold 2026-01-12 16:56:51 +01:00
Codex Agent
2287e7f32c Fix tenant photo moderation and guest updates
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 14:35:57 +01:00
Codex Agent
cceed361b7 feat: add checkout action banner
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 13:35:43 +01:00
Codex Agent
02363792c8 feat: poll checkout status and show failures
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 13:31:30 +01:00
Codex Agent
e93a00f0fc fix: block non-upgrade package selection
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 12:40:18 +01:00
Codex Agent
c1be7dd1ef fix: add package feature labels
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 12:32:43 +01:00
Codex Agent
f01a0e823b fix: handle array package features
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 12:29:19 +01:00
Codex Agent
915aede66e feat: add package comparison view
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 12:25:35 +01:00
Codex Agent
b854e3feaa Show billing activation banner
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 12:07:37 +01:00
Codex Agent
4bcaef53f7 Redirect checkout to billing with status
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 11:49:10 +01:00
Codex Agent
8f1d3a3eb6 Disallow downgrades in package shop
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 11:45:12 +01:00
Codex Agent
ab2cf3e023 Highlight upgrades in package shop
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 11:38:16 +01:00
Codex Agent
ce0ab269c9 Cap analytics timeframe label
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 11:30:00 +01:00
Codex Agent
dce24bb86a Compute analytics timeframe dynamically
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 11:12:50 +01:00
Codex Agent
03bf178d61 Enhance analytics snapshot and empty states
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 11:07:23 +01:00
Codex Agent
8ebaf6c31d Refine analytics page and i18n
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 11:03:55 +01:00
Codex Agent
1b6dc63ec6 Clamp package summary remaining counts
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 10:50:35 +01:00
Codex Agent
accc63f4a2 Add pending test files
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 10:46:18 +01:00
Codex Agent
59e318e7b9 Ignore beads sync artifacts
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 10:42:58 +01:00
Codex Agent
3de1d3deab Misc unrelated updates
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 10:31:31 +01:00
Codex Agent
e9afbeb028 Unify admin home with event overview 2026-01-12 10:31:05 +01:00
Codex Agent
3e2b63f71f Paddle Coupon Sync prüft nun zuerst, ob der Discount schon existiert. 2026-01-08 13:36:58 +01:00
100 changed files with 3991 additions and 949 deletions

View File

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

View File

@@ -1 +1 @@
fotospiel-app-9em
fotospiel-app-29r

5
.gitignore vendored
View File

@@ -13,6 +13,8 @@ fotospiel-tenant-app
/storage/*.key
/storage/pail
/vendor
/clients/photobooth-uploader/**/bin
/clients/photobooth-uploader/**/obj
.env
.env.backup
.env.production
@@ -26,3 +28,6 @@ yarn-error.log
/.vscode
test-results
GEMINI.md
.beads/.sync.lock
.beads/daemon-error
.beads/sync_base.jsonl

View File

@@ -40,7 +40,7 @@ class Login extends BaseLogin implements HasForms
}
// SuperAdmin-spezifisch: Prüfe auf SuperAdmin-Rolle, keine Tenant-Prüfung
if ($user->role !== 'super_admin') {
if (! $user->isSuperAdmin()) {
$authGuard->logout();
throw ValidationException::withMessages([

View File

@@ -45,11 +45,11 @@ class GuestPolicySettingsPage extends Page
public int $join_token_failure_decay_minutes = 5;
public int $join_token_access_limit = 120;
public int $join_token_access_limit = 300;
public int $join_token_access_decay_minutes = 1;
public int $join_token_download_limit = 60;
public int $join_token_download_limit = 120;
public int $join_token_download_decay_minutes = 1;
@@ -69,9 +69,9 @@ class GuestPolicySettingsPage extends Page
$this->per_device_upload_limit = (int) ($settings->per_device_upload_limit ?? 50);
$this->join_token_failure_limit = (int) ($settings->join_token_failure_limit ?? 10);
$this->join_token_failure_decay_minutes = (int) ($settings->join_token_failure_decay_minutes ?? 5);
$this->join_token_access_limit = (int) ($settings->join_token_access_limit ?? 120);
$this->join_token_access_limit = (int) ($settings->join_token_access_limit ?? 300);
$this->join_token_access_decay_minutes = (int) ($settings->join_token_access_decay_minutes ?? 1);
$this->join_token_download_limit = (int) ($settings->join_token_download_limit ?? 60);
$this->join_token_download_limit = (int) ($settings->join_token_download_limit ?? 120);
$this->join_token_download_decay_minutes = (int) ($settings->join_token_download_decay_minutes ?? 1);
$this->join_token_ttl_hours = (int) ($settings->join_token_ttl_hours ?? 168);
$this->share_link_ttl_hours = (int) ($settings->share_link_ttl_hours ?? 48);

View File

@@ -3,9 +3,12 @@
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\Checkout\CheckoutSessionStatusRequest;
use App\Models\CheckoutSession;
use App\Models\Package;
use App\Models\PackagePurchase;
use App\Models\TenantPackage;
use App\Services\Checkout\CheckoutSessionService;
use App\Services\Paddle\PaddleCheckoutService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -14,7 +17,10 @@ use Illuminate\Validation\ValidationException;
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
{
@@ -165,23 +171,82 @@ class PackageController extends Controller
$package = Package::findOrFail($request->integer('package_id'));
$tenant = $request->attributes->get('tenant');
$user = $request->user();
if (! $tenant) {
throw ValidationException::withMessages(['tenant' => 'Tenant context missing.']);
}
if (! $user) {
throw ValidationException::withMessages(['user' => 'User context missing.']);
}
if (! $package->paddle_price_id) {
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 = [
'success_url' => $request->input('success_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);
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

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\Photobooth\PhotoboothConnectRedeemRequest;
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' => [
'upload_url' => route('api.v1.photobooth.sparkbooth.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'),
],
]);
}
}

View File

@@ -16,6 +16,7 @@ use App\Models\Package;
use App\Models\PackagePurchase;
use App\Models\Photo;
use App\Models\Tenant;
use App\Models\User;
use App\Services\EventJoinTokenService;
use App\Support\ApiError;
use Illuminate\Http\JsonResponse;
@@ -88,12 +89,15 @@ class EventController extends Controller
$tenant = Tenant::findOrFail($tenantId);
}
$actor = $request->user();
$isSuperAdmin = $actor instanceof User && $actor->isSuperAdmin();
// Package check is now handled by middleware
$validated = $request->validated();
$tenantId = $tenant->id;
$requestedPackageId = $validated['package_id'] ?? null;
$requestedPackageId = $isSuperAdmin ? $request->integer('package_id') : null;
unset($validated['package_id']);
$tenantPackage = $tenant->tenantPackages()
@@ -108,6 +112,10 @@ class EventController extends Controller
$package = Package::query()->find($requestedPackageId);
}
if (! $package && $isSuperAdmin) {
$package = $this->resolveOwnerPackage();
}
if (! $package && $tenantPackage) {
$package = $tenantPackage->package ?? Package::query()->find($tenantPackage->package_id);
}
@@ -121,7 +129,7 @@ class EventController extends Controller
$requiresWaiver = $package->isEndcustomer();
$latestPurchase = $requiresWaiver ? $this->resolveLatestPackagePurchase($tenant, $package) : 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')) {
throw ValidationException::withMessages([
@@ -182,7 +190,7 @@ class EventController extends Controller
$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);
EventPackage::create([
@@ -193,7 +201,7 @@ class EventController extends Controller
'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);
if (! $tenant->consumeEventAllowance(1, 'event.create', $note)) {
@@ -229,6 +237,15 @@ class EventController extends Controller
->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
{
$timestamp = now();

View File

@@ -135,7 +135,7 @@ class EventMemberController extends Controller
$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([
'email' => __('Dieser Benutzer ist einem anderen Mandanten zugeordnet.'),
]);
@@ -143,9 +143,9 @@ class EventMemberController extends Controller
$user->tenant_id = $tenant->id;
if ($role === 'tenant_admin' && $user->role !== 'super_admin') {
if ($role === 'tenant_admin' && ! $user->isSuperAdmin()) {
$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';
}

View File

@@ -525,13 +525,13 @@ class PhotoController extends Controller
]);
// 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(
'insufficient_scope',
'Insufficient Scopes',
'You are not allowed to moderate photos for this event.',
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
{
$accessToken = $request->user()?->currentAccessToken();
if ($accessToken && $accessToken->can($scope)) {
return true;
}
$scopes = $request->user()->scopes ?? ($request->attributes->get('decoded_token')['scopes'] ?? []);
if (! is_array($scopes)) {

View File

@@ -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.');
}
}
}

View File

@@ -193,11 +193,11 @@ class TenantAdminTokenController extends Controller
$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';
}
if ($user->role === 'super_admin') {
if ($user->isSuperAdmin()) {
$abilities[] = 'super-admin';
}
@@ -219,7 +219,7 @@ class TenantAdminTokenController extends Controller
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;
}

View File

@@ -9,8 +9,8 @@ use App\Models\User;
use App\Notifications\TenantFeedbackSubmitted;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Illuminate\Support\Facades\Notification;
use Illuminate\Validation\Rule;
class TenantFeedbackController extends Controller
{
@@ -56,7 +56,7 @@ class TenantFeedbackController extends Controller
]);
$recipients = User::query()
->where('role', 'super_admin')
->whereIn('role', ['super_admin', 'superadmin'])
->whereNotNull('email')
->get();

View File

@@ -80,7 +80,7 @@ class TenantAdminPasswordResetController extends Controller
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;
}

View File

@@ -155,7 +155,7 @@ class AuthenticatedSessionController extends Controller
}
// Super admins go to Filament superadmin panel
if ($user && $user->role === 'super_admin') {
if ($user && $user->isSuperAdmin()) {
return '/super-admin';
}

View File

@@ -12,7 +12,7 @@ class TenantAdminAuthController extends Controller
$user = Auth::user();
// 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');
}

View File

@@ -46,7 +46,7 @@ class TenantAdminGoogleController extends Controller
/** @var User|null $user */
$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.');
}

View File

@@ -3,6 +3,7 @@
namespace App\Http\Middleware;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Packages\PackageLimitEvaluator;
use App\Support\ApiError;
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);
if ($violation !== null) {
@@ -43,6 +44,24 @@ class CreditCheckMiddleware
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
{
return $request->isMethod('post')

View File

@@ -42,7 +42,7 @@ class EnsureTenantAdminToken
/** @var Tenant|null $tenant */
$tenant = $user->tenant;
if (! $tenant && $user->role === 'super_admin') {
if (! $tenant && $user->isSuperAdmin()) {
$requestedTenantId = $this->resolveRequestedTenantId($request);
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.');
}
if ($tenant) {
$request->attributes->set('tenant_id', $tenant->id);
$request->attributes->set('tenant', $tenant);
} elseif ($user->role === 'super_admin') {
} elseif ($user->isSuperAdmin()) {
$requestedTenantId = $this->resolveRequestedTenantId($request);
if ($requestedTenantId !== null) {
$request->attributes->set('tenant_id', $requestedTenantId);
@@ -96,7 +96,7 @@ class EnsureTenantAdminToken
*/
protected function allowedRoles(): array
{
return ['tenant_admin', 'super_admin', 'admin'];
return ['tenant_admin', 'super_admin', 'superadmin', 'admin'];
}
protected function forbiddenRoleMessage(): string

View File

@@ -9,7 +9,7 @@ class EnsureTenantCollaboratorToken extends EnsureTenantAdminToken
{
protected function allowedRoles(): array
{
return ['tenant_admin', 'super_admin', 'admin', 'member'];
return ['tenant_admin', 'super_admin', 'superadmin', 'admin', 'member'];
}
protected function forbiddenRoleMessage(): string

View File

@@ -3,6 +3,7 @@
namespace App\Http\Middleware;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Packages\PackageLimitEvaluator;
use App\Support\ApiError;
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);
if ($violation !== null) {
@@ -43,6 +44,24 @@ class PackageMiddleware
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
{
return $request->isMethod('post') && (

View File

@@ -112,7 +112,7 @@ class RedirectIfAuthenticated extends BaseMiddleware
return '/event-admin/dashboard';
}
if ($user && $user->role === 'super_admin') {
if ($user && $user->isSuperAdmin()) {
return '/super-admin';
}

View File

@@ -4,9 +4,9 @@ namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;
class SuperAdminAuth
{
@@ -21,17 +21,17 @@ class SuperAdminAuth
return $next($request);
}
if (!Auth::check()) {
if (! Auth::check()) {
abort(403, 'Nicht angemeldet.');
}
$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') {
abort(403, 'Zugriff nur für SuperAdmin. User ID: ' . $user->id . ', Role: ' . $user->role);
if (! $user->isSuperAdmin()) {
abort(403, 'Zugriff nur für SuperAdmin. User ID: '.$user->id.', Role: '.$user->role);
}
return $next($request);
}
}
}

View File

@@ -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,
]);
}
}

View File

@@ -30,6 +30,7 @@ class EventStoreRequest extends FormRequest
'event_date' => ['required', 'date', 'after_or_equal:today'],
'location' => ['nullable', 'string', 'max:255'],
'event_type_id' => ['required', 'exists:event_types,id'],
'package_id' => ['nullable', 'integer', 'exists:packages,id'],
'max_participants' => ['nullable', 'integer', 'min:1', 'max:10000'],
'public_url' => ['nullable', 'url', 'max:500'],
'custom_domain' => ['nullable', 'string', 'max:255'],

View File

@@ -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'],
];
}
}

View File

@@ -5,11 +5,19 @@ namespace App\Listeners\GuestNotifications;
use App\Enums\GuestNotificationAudience;
use App\Enums\GuestNotificationType;
use App\Events\GuestPhotoUploaded;
use App\Models\GuestNotification;
use App\Models\Photo;
use App\Services\GuestNotificationService;
use Illuminate\Support\Carbon;
class SendPhotoUploadedNotification
{
private const DEDUPE_WINDOW_SECONDS = 30;
private const GROUP_WINDOW_MINUTES = 10;
private const MAX_GROUP_PHOTOS = 6;
/**
* @param int[] $milestones
*/
@@ -25,7 +33,20 @@ class SendPhotoUploadedNotification
? sprintf('%s hat gerade ein Foto gemacht 🎉', $guestLabel)
: '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,
GuestNotificationType::PHOTO_ACTIVITY,
$title,
@@ -34,11 +55,15 @@ class SendPhotoUploadedNotification
'audience_scope' => GuestNotificationAudience::ALL,
'payload' => [
'photo_id' => $event->photoId,
'photo_ids' => [$event->photoId],
'count' => 1,
],
'expires_at' => now()->addHours(3),
]
);
$this->markUploaderRead($notification, $event->guestIdentifier);
$this->maybeCreateMilestoneNotification($event, $guestLabel);
}
@@ -87,4 +112,94 @@ class SendPhotoUploadedNotification
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);
}
}

View File

@@ -41,9 +41,9 @@ class GuestPolicySetting extends Model
'per_device_upload_limit' => 50,
'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_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_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_ttl_hours' => 168,
'share_link_ttl_hours' => (int) config('share-links.ttl_hours', 48),

View 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);
}
}

View File

@@ -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.
*/
@@ -127,12 +137,12 @@ class User extends Authenticatable implements FilamentHasTenants, FilamentUser,
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 match ($panel->getId()) {
'superadmin' => $this->role === 'super_admin',
'superadmin' => $this->isSuperAdmin(),
'admin' => $this->role === 'tenant_admin',
default => false,
};
@@ -140,7 +150,7 @@ class User extends Authenticatable implements FilamentHasTenants, FilamentUser,
public function canAccessTenant(Model $tenant): bool
{
if ($this->role === 'super_admin') {
if ($this->isSuperAdmin()) {
return true;
}
@@ -155,7 +165,7 @@ class User extends Authenticatable implements FilamentHasTenants, FilamentUser,
public function getTenants(Panel $panel): array|Collection
{
if ($this->role === 'super_admin') {
if ($this->isSuperAdmin()) {
return Tenant::query()->orderBy('name')->get();
}

View File

@@ -12,12 +12,11 @@ class PurchaseHistoryPolicy
public function viewAny(User $user): bool
{
return $user->role === 'super_admin';
return $user->isSuperAdmin();
}
public function view(User $user, PurchaseHistory $purchaseHistory): bool
{
return $user->role === 'super_admin';
return $user->isSuperAdmin();
}
}

View File

@@ -15,7 +15,7 @@ class TenantPolicy
*/
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
{
return $user->role === 'super_admin';
return $user->isSuperAdmin();
}
/**
@@ -43,7 +43,7 @@ class TenantPolicy
*/
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
{
return $user->role === 'super_admin';
return $user->isSuperAdmin();
}
/**
@@ -59,6 +59,6 @@ class TenantPolicy
*/
public function suspend(User $user, Tenant $tenant): bool
{
return $user->role === 'super_admin';
return $user->isSuperAdmin();
}
}

View File

@@ -155,7 +155,15 @@ class AppServiceProvider extends ServiceProvider
$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) {

View File

@@ -46,7 +46,7 @@ class AuthServiceProvider extends ServiceProvider
});
Gate::before(function (User $user): ?bool {
return $user->role === 'super_admin' ? true : null;
return $user->isSuperAdmin() ? true : null;
});
}
}

View File

@@ -83,7 +83,7 @@ class SuperAdminAuditLogger
private function shouldLog(?User $actor): bool
{
if (! $actor || $actor->role !== 'super_admin') {
if (! $actor || ! $actor->isSuperAdmin()) {
return false;
}

View File

@@ -126,6 +126,36 @@ class GuestNotificationService
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');
if (is_array($cta)) {
$cta = [
@@ -142,6 +172,9 @@ class GuestNotificationService
$clean = array_filter([
'cta' => $cta,
'photo_id' => $photoId,
'photo_ids' => $photoIds,
'count' => $count,
]);
return $clean === [] ? null : $clean;

View File

@@ -17,6 +17,11 @@ class PaddleDiscountService
*/
public function createDiscount(Coupon $coupon): array
{
$existing = $this->findExistingDiscount($coupon->code);
if ($existing !== null) {
return $existing;
}
$payload = $this->buildDiscountPayload($coupon);
$response = $this->client->post('/discounts', $payload);
@@ -82,6 +87,35 @@ class PaddleDiscountService
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>
*/

View 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;
}
}

View File

@@ -24,15 +24,15 @@ class TenantAuth
}
$user = $request->user();
if ($user && in_array($user->role, ['tenant_admin', 'admin', 'super_admin', 'member'], true)) {
if ($user->role !== 'super_admin' || (int) $user->tenant_id === (int) $tenantId) {
if ($user && in_array($user->role, ['tenant_admin', 'admin', 'super_admin', 'superadmin', 'member'], true)) {
if (! $user->isSuperAdmin() || (int) $user->tenant_id === (int) $tenantId) {
return $user;
}
}
$user = User::query()
->where('tenant_id', $tenantId)
->whereIn('role', ['tenant_admin', 'admin', 'member'])
->whereIn('role', ['tenant_admin', 'admin', 'super_admin', 'superadmin', 'member'])
->orderByDesc('email_verified_at')
->orderBy('id')
->first();

View File

@@ -0,0 +1,10 @@
<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 />
</Application.Styles>
</Application>

View 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();
}
}

View File

@@ -0,0 +1,63 @@
<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="520" Height="360"
Title="Fotospiel Photobooth Uploader">
<Grid Margin="24" ColumnDefinitions="*,8,*">
<StackPanel Grid.Column="0" Spacing="12" MaxWidth="420">
<TextBlock Text="Fotospiel Photobooth Uploader" FontSize="20" FontWeight="SemiBold" />
<Border Background="#1F000000" Padding="12" CornerRadius="8">
<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>
<TextBlock Text="Gib den 6-stelligen Verbindungscode ein." TextWrapping="Wrap" />
<TextBox x:Name="CodeBox" MaxLength="6" Watermark="123456" />
<Button x:Name="ConnectButton" Content="Verbinden" Click="ConnectButton_Click" />
<StackPanel Spacing="6">
<TextBlock Text="Upload-Ordner" FontWeight="SemiBold" />
<TextBlock x:Name="FolderText" Text="Noch nicht ausgewählt." TextWrapping="Wrap" />
<Button x:Name="PickFolderButton" Content="Ordner auswählen" Click="PickFolderButton_Click" IsEnabled="False" />
</StackPanel>
<ToggleSwitch x:Name="QuietToggle" Content="Ruhiger Modus (nur Fehler anzeigen)" />
</StackPanel>
<StackPanel Grid.Column="2" Spacing="12" MaxWidth="380">
<Border Background="#1F000000" Padding="12" CornerRadius="8">
<StackPanel Spacing="6">
<TextBlock Text="Status" FontWeight="SemiBold" />
<TextBlock x:Name="StatusText" Text="Nicht verbunden." TextWrapping="Wrap" />
<TextBlock x:Name="LastUploadText" Text="Letzter Upload: —" />
</StackPanel>
</Border>
<StackPanel Spacing="6">
<TextBlock Text="Letzte Uploads" FontWeight="SemiBold" />
<ItemsControl x:Name="RecentUploadsList" ItemsSource="{Binding RecentUploads}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Background="#14000000" Padding="8" CornerRadius="6" Margin="0,0,0,6">
<Grid ColumnDefinitions="*,Auto" RowDefinitions="Auto,Auto">
<TextBlock Grid.Column="0" Grid.Row="0" Text="{Binding FileName}" />
<TextBlock Grid.Column="1" Grid.Row="0" Text="{Binding StatusLabel}" />
<TextBlock Grid.Column="0" Grid.Row="1" Text="{Binding UpdatedLabel}" Opacity="0.7" FontSize="11" />
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Button x:Name="RetryFailedButton" Content="Fehlgeschlagene erneut senden" Click="RetryFailedButton_Click" IsEnabled="False" />
</StackPanel>
</StackPanel>
</Grid>
</Window>

View File

@@ -0,0 +1,282 @@
using System;
using System.IO;
using System.Linq;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using Avalonia.Threading;
using PhotoboothUploader.Models;
using PhotoboothUploader.Services;
namespace PhotoboothUploader;
public partial class MainWindow : Window
{
private const string DefaultBaseUrl = "https://fotospiel.app";
private PhotoboothConnectClient _client;
private readonly SettingsStore _settingsStore = new();
private readonly UploadService _uploadService = new();
private PhotoboothSettings _settings;
private FileSystemWatcher? _watcher;
private readonly Dictionary<string, UploadItem> _uploadsByPath = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _failedPaths = new(StringComparer.OrdinalIgnoreCase);
public ObservableCollection<UploadItem> RecentUploads { get; } = new();
public MainWindow()
{
InitializeComponent();
_settings = _settingsStore.Load();
_settings.BaseUrl ??= DefaultBaseUrl;
_client = new PhotoboothConnectClient(_settings.BaseUrl);
_settingsStore.Save(_settings);
DataContext = this;
ApplySettings();
}
private async void ConnectButton_Click(object? sender, RoutedEventArgs e)
{
var code = (CodeBox.Text ?? string.Empty).Trim();
if (code.Length != 6 || code.Any(ch => ch is < '0' or > '9'))
{
StatusText.Text = "Bitte einen gültigen 6-stelligen Code eingeben.";
return;
}
ConnectButton.IsEnabled = false;
StatusText.Text = "Verbinde...";
var response = await _client.RedeemAsync(code);
if (response.Data is null)
{
StatusText.Text = response.Message ?? "Verbindung fehlgeschlagen.";
ConnectButton.IsEnabled = true;
return;
}
_settings.UploadUrl = ResolveUploadUrl(response.Data.UploadUrl);
_settings.Username = response.Data.Username;
_settings.Password = response.Data.Password;
_settings.ResponseFormat = response.Data.ResponseFormat;
_settingsStore.Save(_settings);
StatusText.Text = "Verbunden. Upload bereit.";
PickFolderButton.IsEnabled = true;
StartUploadPipelineIfReady();
ConnectButton.IsEnabled = true;
}
private async void PickFolderButton_Click(object? sender, RoutedEventArgs e)
{
var options = new FolderPickerOpenOptions
{
Title = "Upload-Ordner auswählen",
AllowMultiple = false,
};
var folders = await StorageProvider.OpenFolderPickerAsync(options);
var folder = folders.FirstOrDefault();
var localPath = folder?.TryGetLocalPath();
if (string.IsNullOrWhiteSpace(localPath))
{
return;
}
_settings.WatchFolder = localPath;
_settingsStore.Save(_settings);
FolderText.Text = localPath;
StartUploadPipelineIfReady();
}
private void ApplySettings()
{
if (!string.IsNullOrWhiteSpace(_settings.WatchFolder))
{
FolderText.Text = _settings.WatchFolder;
}
if (!string.IsNullOrWhiteSpace(_settings.UploadUrl))
{
StatusText.Text = "Verbunden. Upload bereit.";
PickFolderButton.IsEnabled = true;
StartUploadPipelineIfReady();
}
UpdateSteps();
}
private void StartUploadPipelineIfReady()
{
if (string.IsNullOrWhiteSpace(_settings.UploadUrl) || string.IsNullOrWhiteSpace(_settings.WatchFolder))
{
UpdateSteps();
return;
}
_uploadService.Start(_settings, OnQueued, OnUploading, OnSuccess, OnFailure);
StartWatcher(_settings.WatchFolder);
UpdateSteps();
}
private void StartWatcher(string folder)
{
_watcher?.Dispose();
_watcher = new FileSystemWatcher(folder)
{
IncludeSubdirectories = false,
EnableRaisingEvents = true,
};
_watcher.Created += OnFileChanged;
_watcher.Changed += OnFileChanged;
_watcher.Renamed += OnFileRenamed;
}
private void OnFileChanged(object sender, FileSystemEventArgs e)
{
if (!IsSupportedImage(e.FullPath))
{
return;
}
_uploadService.Enqueue(e.FullPath, OnQueued);
}
private void OnFileRenamed(object sender, RenamedEventArgs e)
{
if (!IsSupportedImage(e.FullPath))
{
return;
}
_uploadService.Enqueue(e.FullPath, OnQueued);
}
private bool IsSupportedImage(string path)
{
var extension = Path.GetExtension(path)?.ToLowerInvariant();
return extension is ".jpg" or ".jpeg" or ".png" or ".webp";
}
private void UpdateStatus(string message)
{
Dispatcher.UIThread.Post(() => StatusText.Text = message);
}
private void OnQueued(string path)
{
UpdateUpload(path, UploadStatus.Queued);
UpdateStatusIfAllowed($"Wartet: {Path.GetFileName(path)}", false);
}
private void OnUploading(string path)
{
UpdateUpload(path, UploadStatus.Uploading);
UpdateStatusIfAllowed($"Upload läuft: {Path.GetFileName(path)}", false);
}
private void OnSuccess(string path)
{
_failedPaths.Remove(path);
UpdateUpload(path, UploadStatus.Success);
UpdateStatusIfAllowed($"Hochgeladen: {Path.GetFileName(path)}", false);
}
private void OnFailure(string path)
{
_failedPaths.Add(path);
UpdateUpload(path, UploadStatus.Failed);
UpdateStatusIfAllowed($"Upload fehlgeschlagen: {Path.GetFileName(path)}", true);
UpdateRetryButton();
}
private void UpdateUpload(string path, UploadStatus status)
{
Dispatcher.UIThread.Post(() =>
{
if (!_uploadsByPath.TryGetValue(path, out var item))
{
item = new UploadItem(path);
_uploadsByPath[path] = item;
RecentUploads.Insert(0, item);
}
item.Status = status;
LastUploadText.Text = status == UploadStatus.Success
? $"Letzter Upload: {item.UpdatedLabel}"
: LastUploadText.Text;
while (RecentUploads.Count > 3)
{
var last = RecentUploads[^1];
_uploadsByPath.Remove(last.Path);
RecentUploads.RemoveAt(RecentUploads.Count - 1);
}
UpdateRetryButton();
});
}
private void UpdateStatusIfAllowed(string message, bool important)
{
var quiet = QuietToggle?.IsChecked ?? false;
if (quiet && !important)
{
return;
}
UpdateStatus(message);
}
private void UpdateRetryButton()
{
RetryFailedButton.IsEnabled = _failedPaths.Count > 0;
}
private void RetryFailedButton_Click(object? sender, RoutedEventArgs e)
{
foreach (var path in _failedPaths.ToList())
{
_uploadService.Enqueue(path, OnQueued);
}
_failedPaths.Clear();
UpdateRetryButton();
}
private void UpdateSteps()
{
var hasCode = !string.IsNullOrWhiteSpace(_settings.UploadUrl);
var hasFolder = !string.IsNullOrWhiteSpace(_settings.WatchFolder);
var ready = hasCode && hasFolder;
StepCodeText.Text = hasCode ? "1. Code eingeben ✓" : "1. Code eingeben";
StepFolderText.Text = hasFolder ? "2. Upload-Ordner wählen ✓" : "2. Upload-Ordner wählen";
StepReadyText.Text = ready ? "3. Upload läuft ✓" : "3. Upload läuft";
}
private string? ResolveUploadUrl(string? uploadUrl)
{
if (string.IsNullOrWhiteSpace(uploadUrl))
{
return uploadUrl;
}
if (Uri.TryCreate(uploadUrl, UriKind.Absolute, out _))
{
return uploadUrl;
}
var baseUri = new Uri(_settings.BaseUrl ?? DefaultBaseUrl, UriKind.Absolute);
return new Uri(baseUri, uploadUrl).ToString();
}
}

View File

@@ -0,0 +1,30 @@
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("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; }
}

View File

@@ -0,0 +1,11 @@
namespace PhotoboothUploader.Models;
public sealed class PhotoboothSettings
{
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; }
}

View File

@@ -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));
}
}

View File

@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ApplicationManifest>app.manifest</ApplicationManifest>
<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>
</Project>

View 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();
}

View File

@@ -0,0 +1,50 @@
using System;
using System.Net.Http;
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 readonly HttpClient _httpClient;
private readonly JsonSerializerOptions _jsonOptions = new()
{
PropertyNameCaseInsensitive = true,
};
public PhotoboothConnectClient(string baseUrl)
{
_httpClient = new HttpClient
{
BaseAddress = new Uri(baseUrl),
};
}
public async Task<PhotoboothConnectResponse> RedeemAsync(string code, CancellationToken cancellationToken = default)
{
var response = await _httpClient.PostAsJsonAsync("/api/v1/photobooth/connect", new { code }, cancellationToken);
var payload = await response.Content.ReadFromJsonAsync<PhotoboothConnectResponse>(_jsonOptions, cancellationToken);
if (payload is null)
{
return new PhotoboothConnectResponse
{
Message = response.ReasonPhrase ?? "Verbindung fehlgeschlagen.",
};
}
if (!response.IsSuccessStatusCode)
{
return new PhotoboothConnectResponse
{
Message = payload.Message ?? "Verbindung fehlgeschlagen.",
};
}
return payload;
}
}

View File

@@ -0,0 +1,45 @@
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 SettingsStore()
{
var basePath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Fotospiel",
"PhotoboothUploader");
Directory.CreateDirectory(basePath);
SettingsPath = Path.Combine(basePath, "settings.json");
}
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);
}
}

View File

@@ -0,0 +1,161 @@
using System;
using System.Collections.Concurrent;
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 readonly Channel<string> _queue = Channel.CreateUnbounded<string>();
private readonly ConcurrentDictionary<string, byte> _pending = new(StringComparer.OrdinalIgnoreCase);
private CancellationTokenSource? _cts;
public void Start(
PhotoboothSettings settings,
Action<string> onQueued,
Action<string> onUploading,
Action<string> onSuccess,
Action<string> onFailure)
{
Stop();
_cts = new CancellationTokenSource();
_ = Task.Run(() => WorkerAsync(settings, onQueued, onUploading, onSuccess, onFailure, _cts.Token));
}
public void Stop()
{
_cts?.Cancel();
_cts = null;
_pending.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> onFailure,
CancellationToken token)
{
if (string.IsNullOrWhiteSpace(settings.UploadUrl))
{
return;
}
using var client = new HttpClient();
while (await _queue.Reader.WaitToReadAsync(token))
{
while (_queue.Reader.TryRead(out var path))
{
try
{
onUploading(path);
await WaitForFileReadyAsync(path, token);
await UploadAsync(client, settings, path, token);
onSuccess(path);
}
catch (OperationCanceledException)
{
return;
}
catch
{
onFailure(path);
}
finally
{
_pending.TryRemove(path, out _);
}
}
}
}
private static async Task 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;
}
lastSize = size;
await Task.Delay(700, token);
}
}
private static async Task UploadAsync(HttpClient client, PhotoboothSettings settings, string path, CancellationToken token)
{
if (!File.Exists(path))
{
return;
}
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));
var response = await client.PostAsync(settings.UploadUrl, content, token);
response.EnsureSuccessStatusCode();
}
private static string ResolveContentType(string path)
{
return Path.GetExtension(path)?.ToLowerInvariant() switch
{
".png" => "image/png",
".webp" => "image/webp",
_ => "image/jpeg",
};
}
}

View 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>

View File

@@ -4,9 +4,9 @@ return [
'failure_limit' => (int) env('JOIN_TOKEN_FAILURE_LIMIT', 10),
'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),
'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),
];

View File

@@ -34,4 +34,8 @@ return [
'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'),
],
'connect_code' => [
'length' => (int) env('PHOTOBOOTH_CONNECT_CODE_LENGTH', 6),
'expires_minutes' => (int) env('PHOTOBOOTH_CONNECT_CODE_EXPIRES_MINUTES', 10),
],
];

View 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,
];
}
}

View File

@@ -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');
}
};

View 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
{
//
}
}

View File

@@ -2,9 +2,11 @@
namespace Database\Seeders;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
use App\Models\User;
use Illuminate\Support\Str;
class SuperAdminSeeder extends Seeder
{
@@ -12,12 +14,49 @@ class SuperAdminSeeder extends Seeder
{
$email = env('ADMIN_EMAIL', 'admin@example.com');
$password = env('ADMIN_PASSWORD', 'ChangeMe123!');
User::updateOrCreate(['email'=>$email], [
$user = User::updateOrCreate(['email' => $email], [
'first_name' => 'Super',
'last_name' => 'Admin',
'password' => Hash::make($password),
'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();
}
}
}

File diff suppressed because one or more lines are too long

View File

@@ -2458,7 +2458,7 @@ export async function getTenantPaddleTransactions(cursor?: string): Promise<{
export async function createTenantPaddleCheckout(
packageId: number,
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', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -2468,12 +2468,22 @@ export async function createTenantPaddleCheckout(
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,
'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 }> {
const response = await authorizedFetch('/api/v1/tenant/billing/portal', {
method: 'POST',

View File

@@ -34,6 +34,27 @@
"more": "Weitere Einträge konnten nicht geladen 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": {
"invoices": {
"title": "Rechnungen & Zahlungen",
@@ -176,6 +197,8 @@
},
"common": {
"all": "Alle",
"anonymous": "Anonym",
"error": "Etwas ist schiefgelaufen",
"loadMore": "Mehr laden",
"processing": "Verarbeite …",
"select": "Auswählen",
@@ -2875,16 +2898,25 @@
"analytics": {
"title": "Analytics",
"upgradeAction": "Upgrade auf Premium",
"kpiTitle": "Event-Überblick",
"kpiUploads": "Uploads",
"kpiContributors": "Beitragende",
"kpiLikes": "Likes",
"activityTitle": "Aktivitäts-Zeitachse",
"timeframe": "Letzte {{hours}} Stunden",
"timeframeHint": "Ältere Aktivität ausgeblendet",
"uploadsPerHour": "Uploads pro Stunde",
"noActivity": "Noch keine Uploads",
"emptyActionShareQr": "QR-Code teilen",
"contributorsTitle": "Top-Beitragende",
"likesCount": "{{count}} Likes",
"likesCount_one": "{{count}} Like",
"likesCount_other": "{{count}} Likes",
"noContributors": "Noch keine Beitragenden",
"emptyActionInvite": "Gäste einladen",
"tasksTitle": "Beliebte Aufgaben",
"noTasks": "Noch keine Aufgabenaktivität",
"emptyActionOpenTasks": "Aufgaben öffnen",
"lockedTitle": "Analytics freischalten",
"lockedBody": "Erhalte tiefe Einblicke in die Interaktionen deines Events mit dem Premium-Paket."
},
@@ -2893,6 +2925,26 @@
"subtitle": "Wähle ein Paket, um mehr Funktionen und Limits freizuschalten.",
"recommendationTitle": "Empfohlen für dich",
"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",
"manage": "Paket verwalten",
"limits": {
@@ -2906,7 +2958,13 @@
},
"features": {
"advanced_analytics": "Erweiterte Analytics",
"basic_uploads": "Basis-Uploads",
"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"
},
"status": {
@@ -2918,7 +2976,9 @@
},
"badges": {
"recommended": "Empfohlen",
"active": "Aktiv"
"active": "Aktiv",
"upgrade": "Upgrade",
"downgrade": "Downgrade"
},
"confirmTitle": "Kauf bestätigen",
"confirmSubtitle": "Du upgradest auf:",
@@ -2931,6 +2991,7 @@
"payNow": "Jetzt zahlen",
"errors": {
"checkout": "Checkout fehlgeschlagen"
}
},
"selectDisabled": "Nicht verfügbar"
}
}

View File

@@ -34,6 +34,27 @@
"more": "Unable to load more entries.",
"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": {
"invoices": {
"title": "Invoices & payments",
@@ -172,6 +193,8 @@
},
"common": {
"all": "All",
"anonymous": "Anonymous",
"error": "Something went wrong",
"loadMore": "Load more",
"processing": "Processing…",
"select": "Select",
@@ -2879,16 +2902,25 @@
"analytics": {
"title": "Analytics",
"upgradeAction": "Upgrade to Premium",
"kpiTitle": "Event snapshot",
"kpiUploads": "Uploads",
"kpiContributors": "Contributors",
"kpiLikes": "Likes",
"activityTitle": "Activity Timeline",
"timeframe": "Last {{hours}} hours",
"timeframeHint": "Older activity hidden",
"uploadsPerHour": "Uploads per hour",
"noActivity": "No uploads yet",
"emptyActionShareQr": "Share your QR code",
"contributorsTitle": "Top Contributors",
"likesCount": "{{count}} likes",
"likesCount_one": "{{count}} like",
"likesCount_other": "{{count}} likes",
"noContributors": "No contributors yet",
"emptyActionInvite": "Invite guests",
"tasksTitle": "Popular Tasks",
"noTasks": "No task activity yet",
"emptyActionOpenTasks": "Open tasks",
"lockedTitle": "Unlock Analytics",
"lockedBody": "Get deep insights into your event engagement with the Premium package."
},
@@ -2897,6 +2929,26 @@
"subtitle": "Choose a package to unlock more features and limits.",
"recommendationTitle": "Recommended for you",
"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",
"manage": "Manage Plan",
"limits": {
@@ -2910,7 +2962,13 @@
},
"features": {
"advanced_analytics": "Advanced Analytics",
"basic_uploads": "Basic uploads",
"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"
},
"status": {
@@ -2922,7 +2980,9 @@
},
"badges": {
"recommended": "Recommended",
"active": "Active"
"active": "Active",
"upgrade": "Upgrade",
"downgrade": "Downgrade"
},
"confirmTitle": "Confirm Purchase",
"confirmSubtitle": "You are upgrading to:",
@@ -2935,6 +2995,7 @@
"payNow": "Pay Now",
"errors": {
"checkout": "Checkout failed"
}
},
"selectDisabled": "Not available"
}
}

View File

@@ -12,6 +12,7 @@ import {
createTenantBillingPortalSession,
getTenantPackagesOverview,
getTenantPaddleTransactions,
getTenantPackageCheckoutStatus,
TenantPackageSummary,
PaddleTransactionSummary,
} from '../api';
@@ -27,6 +28,14 @@ import {
getPackageFeatureLabel,
getPackageLimitEntries,
} from './lib/packageSummary';
import {
PendingCheckout,
loadPendingCheckout,
shouldClearPendingCheckout,
storePendingCheckout,
} from './lib/billingCheckout';
const CHECKOUT_POLL_INTERVAL_MS = 10000;
export default function MobileBillingPage() {
const { t } = useTranslation('management');
@@ -40,6 +49,11 @@ export default function MobileBillingPage() {
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
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 invoicesRef = React.useRef<HTMLDivElement | null>(null);
const supportEmail = 'support@fotospiel.de';
@@ -95,6 +109,11 @@ export default function MobileBillingPage() {
}
}, [portalBusy, t]);
const persistPendingCheckout = React.useCallback((next: PendingCheckout | null) => {
setPendingCheckout(next);
storePendingCheckout(next);
}, []);
React.useEffect(() => {
void load();
}, [load]);
@@ -108,6 +127,115 @@ export default function MobileBillingPage() {
}
}, [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 (
<MobileShell
activeTab="profile"
@@ -127,6 +255,109 @@ export default function MobileBillingPage() {
<CTAButton label={t('billing.actions.refresh', 'Refresh')} tone="ghost" onPress={load} />
</MobileCard>
) : 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}>
<XStack alignItems="center" space="$2">
@@ -535,4 +766,4 @@ function formatDate(value: string | null | undefined): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '—';
return date.toLocaleDateString(undefined, { day: '2-digit', month: 'short', year: 'numeric' });
}
}

View File

@@ -1,12 +1,12 @@
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 { 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 { SizableText as Text } from '@tamagui/text';
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 { MobileSheet } from './components/Sheet';
import { adminPath, ADMIN_WELCOME_BASE_PATH } from '../constants';
@@ -21,6 +21,7 @@ import { collectPackageFeatures, formatPackageLimit, getPackageFeatureLabel, get
import { trackOnboarding } from '../api';
import { useAuth } from '../auth/context';
import { ADMIN_ACTION_COLORS, ADMIN_MOTION, useAdminTheme } from './theme';
import { isPastEvent } from './eventDate';
type DeviceSetupProps = {
installPrompt: ReturnType<typeof useInstallPrompt>;
@@ -32,6 +33,7 @@ type DeviceSetupProps = {
export default function MobileDashboardPage() {
const navigate = useNavigate();
const location = useLocation();
const { slug: slugParam } = useParams<{ slug?: string }>();
const { t, i18n } = useTranslation('management');
const { events, activeEvent, hasEvents, hasMultipleEvents, isLoading, selectEvent } = useEventContext();
const { status } = useAuth();
@@ -42,11 +44,12 @@ export default function MobileDashboardPage() {
const [tourStep, setTourStep] = React.useState(0);
const [summaryOpen, setSummaryOpen] = React.useState(false);
const [summarySeenOverride, setSummarySeenOverride] = React.useState<number | null>(null);
const [eventSwitcherOpen, setEventSwitcherOpen] = React.useState(false);
const onboardingTrackedRef = React.useRef(false);
const installPrompt = useInstallPrompt();
const pushState = useAdminPushSubscription();
const devicePermissions = useDevicePermissions();
const { textStrong, muted, border, surface, accentSoft, primary } = useAdminTheme();
const { textStrong, muted, accentSoft, primary } = useAdminTheme();
const text = textStrong;
const accentText = primary;
@@ -84,6 +87,14 @@ export default function MobileDashboardPage() {
const tourTargetSlug = activeEvent?.slug ?? effectiveEvents[0]?.slug ?? null;
const tourStepKeys = React.useMemo(() => resolveTourStepKeys(effectiveHasEvents), [effectiveHasEvents]);
React.useEffect(() => {
if (!slugParam || slugParam === activeEvent?.slug) {
return;
}
selectEvent(slugParam);
}, [activeEvent?.slug, selectEvent, slugParam]);
React.useEffect(() => {
if (status !== 'authenticated' || onboardingTrackedRef.current) {
return;
@@ -424,7 +435,7 @@ export default function MobileDashboardPage() {
onOpen={() => setSummaryOpen(true)}
/>
) : null}
<EventPickerList events={effectiveEvents} locale={locale} text={text} muted={muted} border={border} />
<EventPickerList events={effectiveEvents} locale={locale} navigateOnSelect={false} />
{tourSheet}
{packageSummarySheet}
</MobileShell>
@@ -434,8 +445,7 @@ export default function MobileDashboardPage() {
return (
<MobileShell
activeTab="home"
title={resolveEventDisplayName(activeEvent ?? undefined)}
subtitle={formatEventDate(activeEvent?.event_date, locale) ?? undefined}
title={t('mobileDashboard.title', 'Dashboard')}
>
{showPackageSummaryBanner ? (
<PackageSummaryBanner
@@ -443,28 +453,18 @@ export default function MobileDashboardPage() {
onOpen={() => setSummaryOpen(true)}
/>
) : null}
<DeviceSetupCard
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
<EventHeaderCard
event={activeEvent}
onGuests={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/members`))}
onPrint={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/qr`))}
onInvites={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/members`))}
onSettings={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}`))}
onAnalytics={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/analytics`))}
locale={locale}
canSwitch={effectiveMultiple}
onSwitch={() => setEventSwitcherOpen(true)}
onEdit={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/edit`))}
/>
<EventManagementGrid
event={activeEvent}
tasksEnabled={tasksEnabled}
onNavigate={(path) => navigate(path)}
/>
<KpiStrip
event={activeEvent}
stats={stats}
@@ -474,8 +474,20 @@ export default function MobileDashboardPage() {
/>
<AlertsAndHints event={activeEvent} stats={stats} tasksEnabled={tasksEnabled} />
<DeviceSetupCard
installPrompt={installPrompt}
pushState={pushState}
devicePermissions={devicePermissions}
onOpenSettings={() => navigate(adminPath('/mobile/settings'))}
/>
{tourSheet}
{packageSummarySheet}
<EventSwitcherSheet
open={eventSwitcherOpen}
onClose={() => setEventSwitcherOpen(false)}
events={effectiveEvents}
locale={locale}
/>
</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 { textStrong, muted, border } = useAdminTheme();
const text = textStrong;
const { selectEvent } = useEventContext();
const navigate = useNavigate();
const [localEvents, setLocalEvents] = React.useState<TenantEvent[]>(events);
@@ -1008,7 +1032,8 @@ function EventPickerList({ events, locale, text, muted, border }: { events: Tena
key={event.slug}
onPress={() => {
selectEvent(event.slug ?? null);
if (event.slug) {
onPick?.(event);
if (navigateOnSelect && event.slug) {
navigate(adminPath(`/mobile/events/${event.slug}`));
}
}}
@@ -1036,140 +1061,232 @@ function EventPickerList({ events, locale, text, muted, border }: { events: Tena
);
}
function FeaturedActions({
tasksEnabled,
onReviewPhotos,
onManageTasks,
onShowQr,
function EventSwitcherSheet({
open,
onClose,
events,
locale,
}: {
tasksEnabled: boolean;
onReviewPhotos: () => void;
onManageTasks: () => void;
onShowQr: () => void;
open: boolean;
onClose: () => void;
events: TenantEvent[];
locale: string;
}) {
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 (
<YStack space="$2">
{cards.map((card) => (
<Pressable key={card.key} onPress={card.action}>
<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>
<MobileSheet open={open} title={t('mobileDashboard.pickEvent', 'Select an event')} onClose={onClose}>
<EventPickerList events={events} locale={locale} navigateOnSelect={false} onPick={onClose} />
</MobileSheet>
);
}
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,
onGuests,
onPrint,
onInvites,
onSettings,
onAnalytics,
locale,
canSwitch,
onSwitch,
onEdit,
}: {
event: TenantEvent | null;
onGuests: () => void;
onPrint: () => void;
onInvites: () => void;
onSettings: () => void;
onAnalytics: () => void;
locale: string;
canSwitch: boolean;
onSwitch: () => void;
onEdit: () => void;
}) {
const { t } = useTranslation('management');
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);
if (!event) {
return null;
}
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,
label: t('mobileDashboard.shortcutGuests', 'Guest management'),
label: t('events.quick.guests', 'Guest Management'),
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,
label: t('mobileDashboard.shortcutAnalytics', 'Analytics'),
color: ADMIN_ACTION_COLORS.analytics,
action: onAnalytics,
},
{
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.settings,
action: onSettings,
},
{
icon: Sparkles,
label: t('mobileDashboard.shortcutBranding', 'Branding & moderation'),
color: ADMIN_ACTION_COLORS.branding,
action: brandingAllowed ? onSettings : undefined,
disabled: !brandingAllowed,
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/analytics`)) : undefined,
disabled: !slug,
},
];
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 (
<YStack space="$2" marginTop="$2">
<Text fontSize="$sm" fontWeight="800" color={text}>
{t('mobileDashboard.shortcutsTitle', 'Shortcuts')}
<YStack space="$2">
<Text fontSize="$sm" fontWeight="800" color={textStrong}>
{t('events.detail.managementTitle', 'Event management')}
</Text>
<XStack flexWrap="wrap" space="$2">
{tiles.map((tile, index) => (
@@ -1178,22 +1295,12 @@ function SecondaryGrid({
icon={tile.icon}
label={tile.label}
color={tile.color}
onPress={tile.action}
onPress={tile.onPress}
disabled={tile.disabled}
delayMs={index * ADMIN_MOTION.tileStaggerMs}
/>
))}
</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>
);
}

View File

@@ -2,25 +2,24 @@ import React from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
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 { SizableText as Text } from '@tamagui/text';
import { format, parseISO } from 'date-fns';
import { de, enGB } from 'date-fns/locale';
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 { ApiError } from '../lib/apiError';
import { useAdminTheme } from './theme';
import { resolveMaxCount, resolveTimelineHours } from './lib/analytics';
import { adminPath } from '../constants';
import { useEventContext } from '../context/EventContext';
export default function MobileEventAnalyticsPage() {
const { slug } = useParams<{ slug: string }>();
const { t, i18n } = useTranslation('management');
const navigate = useNavigate();
const { activeEvent } = useEventContext();
const { textStrong, muted, border, surface, primary, accentSoft } = useAdminTheme();
const dateLocale = i18n.language.startsWith('de') ? de : enGB;
@@ -99,18 +98,47 @@ export default function MobileEventAnalyticsPage() {
const hasTimeline = timeline.length > 0;
const hasContributors = contributors.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
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 (
<MobileShell
title={t('analytics.title', 'Analytics')}
subtitle={activeEvent?.name as string}
activeTab="home"
onBack={() => navigate(-1)}
>
<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 */}
<MobileCard space="$3" borderColor={border} backgroundColor={surface}>
<XStack alignItems="center" space="$2">
@@ -119,12 +147,22 @@ export default function MobileEventAnalyticsPage() {
{t('analytics.activityTitle', 'Activity Timeline')}
</Text>
</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 ? (
<YStack height={180} justifyContent="flex-end" space="$2">
<XStack alignItems="flex-end" justifyContent="space-between" height={150} gap="$1">
{timeline.map((point, index) => {
const heightPercent = (point.count / maxCount) * 100;
const heightPercent = (point.count / maxTimelineCount) * 100;
const date = parseISO(point.timestamp);
// Show label every 3rd point or if few points
const showLabel = timeline.length < 8 || index % 3 === 0;
@@ -141,7 +179,7 @@ export default function MobileEventAnalyticsPage() {
/>
{showLabel && (
<Text fontSize={10} color={muted} numberOfLines={1}>
{format(date, 'HH:mm')}
{format(date, 'HH:mm', { locale: dateLocale })}
</Text>
)}
</YStack>
@@ -153,7 +191,11 @@ export default function MobileEventAnalyticsPage() {
</Text>
</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>
@@ -199,7 +241,11 @@ export default function MobileEventAnalyticsPage() {
))}
</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>
@@ -215,7 +261,6 @@ export default function MobileEventAnalyticsPage() {
{hasTasks ? (
<YStack space="$3">
{tasks.map((task) => {
const maxTaskCount = Math.max(...tasks.map(t => t.count), 1);
const percent = (task.count / maxTaskCount) * 100;
return (
<YStack key={task.task_id} space="$1">
@@ -240,7 +285,11 @@ export default function MobileEventAnalyticsPage() {
})}
</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>
</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();
return (
<YStack padding="$4" alignItems="center" justifyContent="center">
<YStack padding="$4" alignItems="center" justifyContent="center" space="$2">
<Text fontSize="$sm" color={muted}>
{message}
</Text>
{actionLabel && onAction ? (
<CTAButton label={actionLabel} tone="ghost" fullWidth={false} onPress={onAction} />
) : null}
</YStack>
);
}

View File

@@ -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');
}

View File

@@ -9,7 +9,7 @@ import { MobileShell } from './components/MobileShell';
import { MobileCard, CTAButton } from './components/Primitives';
import { MobileField, MobileInput, MobileSelect, MobileTextArea } from './components/FormControls';
import { LegalConsentSheet } from './components/LegalConsentSheet';
import { createEvent, getEvent, updateEvent, getEventTypes, TenantEvent, TenantEventType, trackOnboarding } from '../api';
import { createEvent, getEvent, updateEvent, getEventTypes, getPackages, Package, TenantEvent, TenantEventType, trackOnboarding } from '../api';
import { resolveEventSlugAfterUpdate } from './eventFormNavigation';
import { adminPath } from '../constants';
import { isAuthError } from '../auth/tokens';
@@ -18,6 +18,7 @@ import toast from 'react-hot-toast';
import { useBackNavigation } from './hooks/useBackNavigation';
import { useAdminTheme } from './theme';
import { withAlpha } from './components/colors';
import { useAuth } from '../auth/context';
type FormState = {
name: string;
@@ -28,6 +29,7 @@ type FormState = {
published: boolean;
autoApproveUploads: boolean;
tasksEnabled: boolean;
packageId: number | null;
};
export default function MobileEventFormPage() {
@@ -36,7 +38,9 @@ export default function MobileEventFormPage() {
const isEdit = Boolean(slug);
const navigate = useNavigate();
const { t } = useTranslation(['management', 'common']);
const { user } = useAuth();
const { text, muted, subtle, danger, border, surface, primary } = useAdminTheme();
const isSuperAdmin = user?.role === 'super_admin' || user?.role === 'superadmin';
const [form, setForm] = React.useState<FormState>({
name: '',
@@ -47,9 +51,12 @@ export default function MobileEventFormPage() {
published: false,
autoApproveUploads: true,
tasksEnabled: true,
packageId: null,
});
const [eventTypes, setEventTypes] = React.useState<TenantEventType[]>([]);
const [typesLoading, setTypesLoading] = React.useState(false);
const [packages, setPackages] = React.useState<Package[]>([]);
const [packagesLoading, setPackagesLoading] = React.useState(false);
const [loading, setLoading] = React.useState(isEdit);
const [saving, setSaving] = React.useState(false);
const [consentOpen, setConsentOpen] = React.useState(false);
@@ -76,6 +83,7 @@ export default function MobileEventFormPage() {
tasksEnabled:
(data.settings?.engagement_mode as string | undefined) !== 'photo_only' &&
(data.engagement_mode as string | undefined) !== 'photo_only',
packageId: null,
});
setError(null);
} catch (err) {
@@ -106,6 +114,31 @@ export default function MobileEventFormPage() {
})();
}, []);
React.useEffect(() => {
if (!isSuperAdmin || isEdit) {
return;
}
(async () => {
setPackagesLoading(true);
try {
const data = await getPackages('endcustomer');
setPackages(data);
setForm((prev) => {
if (prev.packageId) {
return prev;
}
const preferred = data.find((pkg) => pkg.id === 3) ?? data[0] ?? null;
return { ...prev, packageId: preferred?.id ?? null };
});
} catch {
setPackages([]);
} finally {
setPackagesLoading(false);
}
})();
}, [isSuperAdmin, isEdit]);
async function handleSubmit() {
setSaving(true);
setError(null);
@@ -131,6 +164,7 @@ export default function MobileEventFormPage() {
event_type_id: form.eventTypeId ?? undefined,
event_date: form.date || undefined,
status: form.published ? 'published' : 'draft',
package_id: isSuperAdmin ? form.packageId ?? undefined : undefined,
settings: {
location: form.location,
guest_upload_visibility: form.autoApproveUploads ? 'immediate' : 'review',
@@ -153,6 +187,7 @@ export default function MobileEventFormPage() {
event_type_id: form.eventTypeId ?? undefined,
event_date: form.date || undefined,
status: form.published ? 'published' : 'draft',
package_id: isSuperAdmin ? form.packageId ?? undefined : undefined,
settings: {
location: form.location,
guest_upload_visibility: form.autoApproveUploads ? 'immediate' : 'review',
@@ -223,6 +258,31 @@ export default function MobileEventFormPage() {
/>
</MobileField>
{isSuperAdmin && !isEdit ? (
<MobileField label={t('eventForm.fields.package.label', 'Package')}>
{packagesLoading ? (
<Text fontSize="$sm" color={muted}>{t('eventForm.fields.package.loading', 'Loading packages…')}</Text>
) : packages.length === 0 ? (
<Text fontSize="$sm" color={muted}>{t('eventForm.fields.package.empty', 'No packages available yet.')}</Text>
) : (
<MobileSelect
value={form.packageId ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, packageId: Number(e.target.value) }))}
>
<option value="">{t('eventForm.fields.package.placeholder', 'Select package')}</option>
{packages.map((pkg) => (
<option key={pkg.id} value={pkg.id}>
{pkg.name || `#${pkg.id}`}
</option>
))}
</MobileSelect>
)}
<Text fontSize="$xs" color={muted}>
{t('eventForm.fields.package.help', 'This controls the events premium limits.')}
</Text>
</MobileField>
) : null}
<MobileField label={t('eventForm.fields.date.label', 'Date & time')}>
<XStack alignItems="center" space="$2">
<NativeDateTimeInput

View File

@@ -12,7 +12,7 @@ import { MobileField, MobileInput, MobileSelect } from './components/FormControl
import { getEvent, getLiveShowLink, rotateLiveShowLink, updateEvent, LiveShowLink, LiveShowSettings, TenantEvent } from '../api';
import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage } from '../lib/apiError';
import { formatEventDate, resolveEventDisplayName } from '../lib/events';
import { resolveEventDisplayName } from '../lib/events';
import { adminPath } from '../constants';
import { useBackNavigation } from './hooks/useBackNavigation';
import { useAdminTheme } from './theme';
@@ -262,8 +262,7 @@ export default function MobileEventLiveShowSettingsPage() {
return (
<MobileShell
activeTab="home"
title={event ? resolveEventDisplayName(event) : t('liveShowSettings.title', 'Live Show settings')}
subtitle={event?.event_date ? formatEventDate(event.event_date, locale) ?? undefined : undefined}
title={t('liveShowSettings.title', 'Live Show settings')}
onBack={back}
headerActions={
<HeaderActionButton onPress={() => load()} ariaLabel={t('common.refresh', 'Refresh')}>

View File

@@ -19,7 +19,7 @@ import {
} from '../api';
import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage } from '../lib/apiError';
import { formatEventDate, resolveEventDisplayName } from '../lib/events';
import { formatEventDate } from '../lib/events';
import toast from 'react-hot-toast';
import { adminPath } from '../constants';
import { useBackNavigation } from './hooks/useBackNavigation';
@@ -146,9 +146,7 @@ export default function MobileEventPhotoboothPage() {
: t('photobooth.credentials.heading', 'FTP (Classic)');
const isActive = Boolean(status?.enabled);
const title = event ? resolveEventDisplayName(event) : t('management.header.appName', 'Event Admin');
const subtitle =
event?.event_date ? formatEventDate(event.event_date, locale) : t('header.selectEvent', 'Select an event to continue');
const title = t('photobooth.title', 'Photobooth');
const handleToggle = (checked: boolean) => {
if (!slug || updating) return;
@@ -163,7 +161,6 @@ export default function MobileEventPhotoboothPage() {
<MobileShell
activeTab="home"
title={title}
subtitle={subtitle ?? undefined}
onBack={back}
headerActions={
<HeaderActionButton onPress={() => load()} ariaLabel={t('common.refresh', 'Refresh')}>

View File

@@ -152,7 +152,6 @@ export default function MobileEventRecapPage() {
<MobileShell
activeTab="home"
title={t('events.recap.title', 'Event Recap')}
subtitle={resolveName(event.name)}
onBack={back}
>
<YStack space="$4">
@@ -392,4 +391,4 @@ function formatDate(iso?: string | null): string {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return '';
return date.toLocaleDateString(undefined, { day: '2-digit', month: 'short', year: 'numeric' });
}
}

View File

@@ -691,6 +691,7 @@ export default function MobileNotificationsPage() {
}
}}
title={selectedNotification?.title ?? t('mobileNotifications.title', 'Notifications')}
snapPoints={[94]}
footer={
selectedNotification && !selectedNotification.is_read ? (
<CTAButton label={t('notificationLogs.markRead', 'Mark as read')} onPress={() => markSelectedRead()} />
@@ -705,7 +706,7 @@ export default function MobileNotificationsPage() {
<Text fontSize="$sm" color={muted}>
{selectedNotification.body}
</Text>
<XStack space="$2">
<XStack space="$2" flexWrap="wrap" style={{ rowGap: 8 }}>
<PillBadge tone={selectedNotification.tone === 'warning' ? 'warning' : 'muted'}>
{selectedNotification.scope}
</PillBadge>

View File

@@ -1,25 +1,31 @@
import React from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Check, ChevronRight, ShieldCheck, ShoppingBag, Sparkles, Star } from 'lucide-react';
import { Check, ChevronRight, ShieldCheck, Sparkles, X } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Checkbox } from '@tamagui/checkbox';
import toast from 'react-hot-toast';
import { MobileShell } from './components/MobileShell';
import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Primitives';
import { useAdminTheme } from './theme';
import { getPackages, createTenantPaddleCheckout, Package, getTenantPackagesOverview, TenantPackageSummary } from '../api';
import { getApiErrorMessage } from '../lib/apiError';
import { getPackages, Package, getTenantPackagesOverview, TenantPackageSummary } from '../api';
import { useQuery } from '@tanstack/react-query';
import {
buildPackageComparisonRows,
classifyPackageChange,
getEnabledPackageFeatures,
selectRecommendedPackageId,
} from './lib/packageShop';
import { usePackageCheckout } from './hooks/usePackageCheckout';
export default function MobilePackageShopPage() {
const { t } = useTranslation('management');
const navigate = useNavigate();
const location = useLocation();
const { textStrong, muted, border, primary, surface, accentSoft, warningText } = useAdminTheme();
const { textStrong, muted, border, primary, accentSoft } = useAdminTheme();
const [selectedPackage, setSelectedPackage] = React.useState<Package | null>(null);
const [viewMode, setViewMode] = React.useState<'cards' | 'compare'>('cards');
// Extract recommended feature from URL
const searchParams = new URLSearchParams(location.search);
@@ -57,19 +63,36 @@ export default function MobilePackageShopPage() {
);
}
const activePackageId = inventory?.activePackage?.package_id ?? null;
const activeCatalogPackage = (catalog ?? []).find((pkg) => pkg.id === activePackageId) ?? null;
const recommendedPackageId = selectRecommendedPackageId(catalog ?? [], recommendedFeature, activeCatalogPackage);
// Merge and sort packages
const sortedPackages = [...(catalog || [])].sort((a, b) => {
// 1. Recommended feature first
const aHasFeature = recommendedFeature && a.features?.[recommendedFeature];
const bHasFeature = recommendedFeature && b.features?.[recommendedFeature];
if (aHasFeature && !bHasFeature) return -1;
if (!aHasFeature && bHasFeature) return 1;
if (recommendedPackageId) {
if (a.id === recommendedPackageId && b.id !== recommendedPackageId) return -1;
if (b.id === recommendedPackageId && a.id !== recommendedPackageId) return 1;
}
// 2. Inventory status (Owned packages later if they are fully used, but usually we want to show active stuff)
// Actually, let's keep price sorting as secondary
return a.price - b.price;
});
const packageEntries = sortedPackages.map((pkg) => {
const owned = inventory?.packages?.find((entry) => entry.package_id === pkg.id);
const isActive = inventory?.activePackage?.package_id === pkg.id;
const isRecommended = recommendedPackageId ? pkg.id === recommendedPackageId : false;
const { isUpgrade, isDowngrade } = classifyPackageChange(pkg, activeCatalogPackage);
return {
pkg,
owned,
isActive,
isRecommended,
isUpgrade,
isDowngrade,
};
});
return (
<MobileShell title={t('shop.title', 'Upgrade Package')} onBack={() => navigate(-1)} activeTab="profile">
<YStack space="$4">
@@ -93,23 +116,45 @@ export default function MobilePackageShopPage() {
</Text>
</YStack>
<YStack space="$3">
{sortedPackages.map((pkg) => {
const owned = inventory?.packages?.find(p => p.package_id === pkg.id);
const isActive = inventory?.activePackage?.package_id === pkg.id;
const isRecommended = recommendedFeature && pkg.features?.[recommendedFeature];
{packageEntries.length > 1 ? (
<XStack space="$2" paddingHorizontal="$2">
<CTAButton
label={t('shop.compare.toggleCards', 'Cards')}
tone={viewMode === 'cards' ? 'primary' : 'ghost'}
fullWidth={false}
onPress={() => setViewMode('cards')}
style={{ flex: 1 }}
/>
<CTAButton
label={t('shop.compare.toggleCompare', 'Compare')}
tone={viewMode === 'compare' ? 'primary' : 'ghost'}
fullWidth={false}
onPress={() => setViewMode('compare')}
style={{ flex: 1 }}
/>
</XStack>
) : null}
return (
<YStack space="$3">
{viewMode === 'compare' ? (
<PackageShopCompareView
entries={packageEntries}
onSelect={(pkg) => setSelectedPackage(pkg)}
/>
) : (
packageEntries.map((entry) => (
<PackageShopCard
key={pkg.id}
pkg={pkg}
owned={owned}
isActive={isActive}
isRecommended={isRecommended}
onSelect={() => setSelectedPackage(pkg)}
key={entry.pkg.id}
pkg={entry.pkg}
owned={entry.owned}
isActive={entry.isActive}
isRecommended={entry.isRecommended}
isUpgrade={entry.isUpgrade}
isDowngrade={entry.isDowngrade}
onSelect={() => setSelectedPackage(entry.pkg)}
/>
);
})}
))
)}
</YStack>
</YStack>
</MobileShell>
@@ -121,34 +166,34 @@ function PackageShopCard({
owned,
isActive,
isRecommended,
isUpgrade,
isDowngrade,
onSelect
}: {
pkg: Package;
owned?: TenantPackageSummary;
isActive?: boolean;
isRecommended?: any;
isUpgrade?: boolean;
isDowngrade?: boolean;
onSelect: () => void
}) {
const { textStrong, muted, border, primary, accentSoft } = useAdminTheme();
const { t } = useTranslation('management');
const hasRemainingEvents = owned && (owned.remaining_events === null || owned.remaining_events > 0);
const statusLabel = isActive
? t('shop.status.active', 'Active Plan')
: owned
? (owned.remaining_events !== null
? t('shop.status.remaining', '{{count}} Events left', { count: owned.remaining_events })
: t('shop.status.owned', 'Purchased'))
: null;
const statusLabel = getPackageStatusLabel({ t, isActive, owned });
const isSubdued = Boolean((isDowngrade || !isUpgrade) && !isActive);
const canSelect = canSelectPackage(isUpgrade, isActive);
return (
<MobileCard
onPress={onSelect}
onPress={canSelect ? onSelect : undefined}
borderColor={isRecommended ? primary : (isActive ? '$green8' : border)}
borderWidth={isRecommended || isActive ? 2 : 1}
space="$3"
pressStyle={{ backgroundColor: accentSoft }}
pressStyle={canSelect ? { backgroundColor: accentSoft } : undefined}
backgroundColor={isActive ? '$green1' : undefined}
style={{ opacity: isSubdued ? 0.6 : 1 }}
>
<XStack justifyContent="space-between" alignItems="flex-start">
<YStack space="$1">
@@ -157,6 +202,8 @@ function PackageShopCard({
{pkg.name}
</Text>
{isRecommended && <PillBadge tone="warning">{t('shop.badges.recommended', 'Recommended')}</PillBadge>}
{isUpgrade && !isActive ? <PillBadge tone="success">{t('shop.badges.upgrade', 'Upgrade')}</PillBadge> : null}
{isDowngrade && !isActive ? <PillBadge tone="muted">{t('shop.badges.downgrade', 'Downgrade')}</PillBadge> : null}
{isActive && <PillBadge tone="success">{t('shop.badges.active', 'Active')}</PillBadge>}
</XStack>
@@ -187,19 +234,25 @@ function PackageShopCard({
) : null}
{/* Render specific feature if it was requested */}
{Object.entries(pkg.features || {})
.filter(([key, val]) => val === true && (!pkg.max_photos || key !== 'photos'))
.slice(0, 3)
.map(([key]) => (
<FeatureRow key={key} label={t(`shop.features.${key}`, key)} />
))
}
{getEnabledPackageFeatures(pkg)
.filter((key) => !pkg.max_photos || key !== 'photos')
.slice(0, 3)
.map((key) => (
<FeatureRow key={key} label={t(`shop.features.${key}`, key)} />
))}
</YStack>
<CTAButton
label={isActive ? t('shop.manage', 'Manage Plan') : t('shop.select', 'Select')}
onPress={onSelect}
tone={isActive ? 'ghost' : 'primary'}
label={
isActive
? t('shop.manage', 'Manage Plan')
: isUpgrade
? t('shop.select', 'Select')
: t('shop.selectDisabled', 'Not available')
}
onPress={canSelect ? onSelect : undefined}
tone={isActive || !isUpgrade ? 'ghost' : 'primary'}
disabled={!canSelect}
/>
</MobileCard>
);
@@ -215,28 +268,224 @@ function FeatureRow({ label }: { label: string }) {
)
}
type PackageEntry = {
pkg: Package;
owned?: TenantPackageSummary;
isActive: boolean;
isRecommended: boolean;
isUpgrade: boolean;
isDowngrade: boolean;
};
function PackageShopCompareView({
entries,
onSelect,
}: {
entries: PackageEntry[];
onSelect: (pkg: Package) => void;
}) {
const { t } = useTranslation('management');
const { textStrong, muted, border, primary, accentSoft } = useAdminTheme();
const comparisonRows = buildPackageComparisonRows(entries.map((entry) => entry.pkg));
const labelWidth = 140;
const columnWidth = 150;
const rows = [
{ id: 'meta.plan', type: 'meta' as const, label: t('shop.compare.headers.plan', 'Plan') },
{ id: 'meta.price', type: 'meta' as const, label: t('shop.compare.headers.price', 'Price') },
...comparisonRows,
];
const renderRowLabel = (row: typeof rows[number]) => {
if (row.type === 'meta') {
return row.label;
}
if (row.type === 'limit') {
if (row.limitKey === 'max_photos') {
return t('shop.compare.rows.photos', 'Photos');
}
if (row.limitKey === 'max_guests') {
return t('shop.compare.rows.guests', 'Guests');
}
return t('shop.compare.rows.days', 'Gallery days');
}
return t(`shop.features.${row.featureKey}`, row.featureKey);
};
const formatLimitValue = (value: number | null) => {
if (value === null) {
return t('shop.compare.values.unlimited', 'Unlimited');
}
return new Intl.NumberFormat().format(value);
};
return (
<MobileCard space="$3" borderColor={border}>
<YStack space="$1">
<Text fontSize="$md" fontWeight="700" color={textStrong}>
{t('shop.compare.title', 'Compare plans')}
</Text>
<Text fontSize="$xs" color={muted}>
{t('shop.compare.helper', 'Swipe to compare packages side by side.')}
</Text>
</YStack>
<XStack style={{ overflowX: 'auto' }}>
<YStack space="$1.5" minWidth={labelWidth + columnWidth * entries.length}>
{rows.map((row) => (
<XStack key={row.id} borderBottomWidth={1} borderColor={border}>
<YStack
width={labelWidth}
paddingVertical="$2"
paddingRight="$3"
justifyContent="center"
>
<Text fontSize="$xs" fontWeight="700" color={muted}>
{renderRowLabel(row)}
</Text>
</YStack>
{entries.map((entry) => {
const cellBackground = entry.isRecommended ? accentSoft : entry.isActive ? '$green1' : undefined;
let content: React.ReactNode = null;
if (row.type === 'meta') {
if (row.id === 'meta.plan') {
const statusLabel = getPackageStatusLabel({ t, isActive: entry.isActive, owned: entry.owned });
content = (
<YStack space="$1">
<Text fontSize="$sm" fontWeight="800" color={textStrong}>
{entry.pkg.name}
</Text>
<XStack space="$1.5" flexWrap="wrap">
{entry.isRecommended ? (
<PillBadge tone="warning">{t('shop.badges.recommended', 'Recommended')}</PillBadge>
) : null}
{entry.isUpgrade && !entry.isActive ? (
<PillBadge tone="success">{t('shop.badges.upgrade', 'Upgrade')}</PillBadge>
) : null}
{entry.isDowngrade && !entry.isActive ? (
<PillBadge tone="muted">{t('shop.badges.downgrade', 'Downgrade')}</PillBadge>
) : null}
{entry.isActive ? <PillBadge tone="success">{t('shop.badges.active', 'Active')}</PillBadge> : null}
</XStack>
{statusLabel ? (
<Text fontSize="$xs" color={muted}>
{statusLabel}
</Text>
) : null}
</YStack>
);
} else if (row.id === 'meta.price') {
content = (
<Text fontSize="$sm" fontWeight="700" color={primary}>
{new Intl.NumberFormat(undefined, { style: 'currency', currency: 'EUR' }).format(entry.pkg.price)}
</Text>
);
}
} else if (row.type === 'limit') {
const value = entry.pkg[row.limitKey] ?? null;
content = (
<Text fontSize="$sm" fontWeight="600" color={textStrong}>
{formatLimitValue(value)}
</Text>
);
} else if (row.type === 'feature') {
const enabled = getEnabledPackageFeatures(entry.pkg).includes(row.featureKey);
content = (
<XStack alignItems="center" space="$1.5">
{enabled ? (
<Check size={16} color={primary} />
) : (
<X size={14} color={muted} />
)}
<Text fontSize="$sm" color={enabled ? textStrong : muted}>
{enabled ? t('shop.compare.values.included', 'Included') : t('shop.compare.values.notIncluded', 'Not included')}
</Text>
</XStack>
);
}
return (
<YStack
key={`${row.id}-${entry.pkg.id}`}
width={columnWidth}
paddingVertical="$2"
paddingHorizontal="$2"
justifyContent="center"
backgroundColor={cellBackground}
>
{content}
</YStack>
);
})}
</XStack>
))}
<XStack paddingTop="$2">
<YStack width={labelWidth} />
{entries.map((entry) => {
const canSelect = canSelectPackage(entry.isUpgrade, entry.isActive);
const label = entry.isActive
? t('shop.manage', 'Manage Plan')
: entry.isUpgrade
? t('shop.select', 'Select')
: t('shop.selectDisabled', 'Not available');
return (
<YStack key={`cta-${entry.pkg.id}`} width={columnWidth} paddingHorizontal="$2">
<CTAButton
label={label}
onPress={canSelect ? () => onSelect(entry.pkg) : undefined}
disabled={!canSelect}
tone={entry.isActive || entry.isDowngrade ? 'ghost' : 'primary'}
/>
</YStack>
);
})}
</XStack>
</YStack>
</XStack>
</MobileCard>
);
}
function getPackageStatusLabel({
t,
isActive,
owned,
}: {
t: (key: string, fallback?: string, options?: Record<string, unknown>) => string;
isActive?: boolean;
owned?: TenantPackageSummary;
}): string | null {
if (isActive) {
return t('shop.status.active', 'Active Plan');
}
if (owned) {
return owned.remaining_events !== null
? t('shop.status.remaining', '{{count}} Events left', { count: owned.remaining_events })
: t('shop.status.owned', 'Purchased');
}
return null;
}
function canSelectPackage(isUpgrade?: boolean, isActive?: boolean): boolean {
return Boolean(isActive || isUpgrade);
}
function CheckoutConfirmation({ pkg, onCancel }: { pkg: Package; onCancel: () => void }) {
const { t } = useTranslation('management');
const { textStrong, muted, border, primary, danger } = useAdminTheme();
const { textStrong, muted, border, primary } = useAdminTheme();
const [agbAccepted, setAgbAccepted] = React.useState(false);
const [withdrawalAccepted, setWithdrawalAccepted] = React.useState(false);
const [busy, setBusy] = React.useState(false);
const { busy, startCheckout } = usePackageCheckout();
const canProceed = agbAccepted && withdrawalAccepted;
const handleCheckout = async () => {
if (!canProceed || busy) return;
setBusy(true);
try {
const { checkout_url } = await createTenantPaddleCheckout(pkg.id, {
success_url: window.location.href,
return_url: window.location.href,
});
window.location.href = checkout_url;
} catch (err) {
toast.error(getApiErrorMessage(err, t('shop.errors.checkout', 'Checkout failed')));
setBusy(false);
}
await startCheckout(pkg.id);
};
return (

View File

@@ -19,6 +19,7 @@ vi.mock('../../api', () => ({
getEvent: vi.fn(),
updateEvent: vi.fn(),
getEventTypes: vi.fn().mockResolvedValue([]),
getPackages: vi.fn().mockResolvedValue([]),
trackOnboarding: vi.fn(),
}));
@@ -81,6 +82,10 @@ vi.mock('../theme', () => ({
}),
}));
vi.mock('../../auth/context', () => ({
useAuth: () => ({ user: { role: 'tenant_admin' } }),
}));
import { getEventTypes } from '../../api';
import MobileEventFormPage from '../EventFormPage';

View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';
import { resolveMaxCount, resolveTimelineHours } from '../lib/analytics';
describe('resolveMaxCount', () => {
it('defaults to 1 for empty input', () => {
expect(resolveMaxCount([])).toBe(1);
});
it('returns the highest count', () => {
expect(resolveMaxCount([2, 5, 3])).toBe(5);
});
it('never returns less than 1', () => {
expect(resolveMaxCount([0])).toBe(1);
});
});
describe('resolveTimelineHours', () => {
it('uses fallback when data is missing', () => {
expect(resolveTimelineHours([], 12)).toBe(12);
});
it('calculates rounded hours from timestamps', () => {
const start = new Date('2024-01-01T10:00:00Z').toISOString();
const end = new Date('2024-01-01T21:00:00Z').toISOString();
expect(resolveTimelineHours([start, end], 12)).toBe(11);
});
it('never returns less than 1', () => {
const start = new Date('2024-01-01T10:00:00Z').toISOString();
expect(resolveTimelineHours([start, start], 12)).toBe(1);
});
});

View File

@@ -0,0 +1,42 @@
import { beforeEach, describe, expect, it } from 'vitest';
import {
CHECKOUT_STORAGE_KEY,
PENDING_CHECKOUT_TTL_MS,
isCheckoutExpired,
loadPendingCheckout,
shouldClearPendingCheckout,
storePendingCheckout,
} from '../lib/billingCheckout';
describe('billingCheckout helpers', () => {
beforeEach(() => {
sessionStorage.clear();
});
it('detects expired pending checkout', () => {
const pending = { packageId: 12, startedAt: 0 };
expect(isCheckoutExpired(pending, PENDING_CHECKOUT_TTL_MS + 1)).toBe(true);
});
it('keeps pending checkout when active package differs', () => {
const pending = { packageId: 12, startedAt: Date.now() };
expect(shouldClearPendingCheckout(pending, 18, pending.startedAt)).toBe(false);
});
it('clears pending checkout when active package matches', () => {
const now = Date.now();
const pending = { packageId: 12, startedAt: now };
expect(shouldClearPendingCheckout(pending, 12, now)).toBe(true);
});
it('stores and loads pending checkout from session storage', () => {
const pending = { packageId: 7, checkoutSessionId: 'sess_123', startedAt: Date.now() };
storePendingCheckout(pending);
expect(loadPendingCheckout(pending.startedAt)).toEqual(pending);
});
it('clears pending checkout storage', () => {
storePendingCheckout({ packageId: 7, checkoutSessionId: 'sess_123', startedAt: Date.now() });
storePendingCheckout(null);
expect(sessionStorage.getItem(CHECKOUT_STORAGE_KEY)).toBeNull();
});
});

View File

@@ -0,0 +1,83 @@
import { describe, expect, it } from 'vitest';
import {
buildPackageComparisonRows,
classifyPackageChange,
getEnabledPackageFeatures,
selectRecommendedPackageId,
} from '../lib/packageShop';
describe('classifyPackageChange', () => {
const active = {
id: 1,
price: 200,
max_photos: 100,
max_guests: 50,
gallery_days: 30,
features: { advanced_analytics: false },
} as any;
it('returns neutral when no active package', () => {
expect(classifyPackageChange(active, null)).toEqual({ isUpgrade: false, isDowngrade: false });
});
it('marks upgrade when candidate adds features', () => {
const candidate = { ...active, id: 2, price: 150, features: { advanced_analytics: true } } as any;
expect(classifyPackageChange(candidate, active)).toEqual({ isUpgrade: true, isDowngrade: false });
});
it('marks downgrade when candidate removes features or limits', () => {
const candidate = { ...active, id: 3, max_photos: 50, features: { advanced_analytics: false } } as any;
expect(classifyPackageChange(candidate, active)).toEqual({ isUpgrade: false, isDowngrade: true });
});
it('treats mixed changes as downgrade', () => {
const candidate = { ...active, id: 4, max_photos: 200, gallery_days: 10, features: { advanced_analytics: false } } as any;
expect(classifyPackageChange(candidate, active)).toEqual({ isUpgrade: false, isDowngrade: true });
});
});
describe('selectRecommendedPackageId', () => {
const packages = [
{ id: 1, price: 100, features: { advanced_analytics: false } },
{ id: 2, price: 150, features: { advanced_analytics: true } },
{ id: 3, price: 200, features: { advanced_analytics: true } },
] as any;
it('returns null when no feature is requested', () => {
expect(selectRecommendedPackageId(packages, null, 100)).toBeNull();
});
it('selects the cheapest upgrade with the feature', () => {
const active = { id: 10, price: 120, max_photos: 100, max_guests: 50, gallery_days: 30, features: {} } as any;
expect(selectRecommendedPackageId(packages, 'advanced_analytics', active)).toBe(2);
});
it('falls back to cheapest feature package if no upgrades exist', () => {
const active = { id: 10, price: 250, max_photos: 999, max_guests: 999, gallery_days: 365, features: { advanced_analytics: true } } as any;
expect(selectRecommendedPackageId(packages, 'advanced_analytics', active)).toBe(2);
});
});
describe('buildPackageComparisonRows', () => {
it('includes limit rows and enabled feature rows', () => {
const rows = buildPackageComparisonRows([
{ features: { advanced_analytics: true, custom_branding: false } },
{ features: { custom_branding: true, watermark_removal: true } },
] as any);
expect(rows.map((row) => row.id)).toEqual([
'limit.max_photos',
'limit.max_guests',
'limit.gallery_days',
'feature.advanced_analytics',
'feature.custom_branding',
'feature.watermark_removal',
]);
});
});
describe('getEnabledPackageFeatures', () => {
it('accepts array payloads', () => {
expect(getEnabledPackageFeatures({ features: ['custom_branding', ''] } as any)).toEqual(['custom_branding']);
});
});

View File

@@ -1,6 +1,6 @@
import React, { Suspense } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { ChevronDown, ChevronLeft, Bell, QrCode } from 'lucide-react';
import { ChevronLeft, Bell, QrCode } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite';
@@ -9,11 +9,10 @@ import { useEventContext } from '../../context/EventContext';
import { BottomNav, NavKey } from './BottomNav';
import { useMobileNav } from '../hooks/useMobileNav';
import { adminPath } from '../../constants';
import { MobileSheet } from './Sheet';
import { MobileCard, PillBadge, CTAButton } from './Primitives';
import { MobileCard, CTAButton } from './Primitives';
import { useNotificationsBadge } from '../hooks/useNotificationsBadge';
import { useOnlineStatus } from '../hooks/useOnlineStatus';
import { formatEventDate, resolveEventDisplayName } from '../../lib/events';
import { resolveEventDisplayName } from '../../lib/events';
import { TenantEvent, getEvents } from '../../api';
import { withAlpha } from './colors';
import { setTabHistory } from '../lib/tabHistory';
@@ -31,11 +30,11 @@ type MobileShellProps = {
};
export function MobileShell({ title, subtitle, children, activeTab, onBack, headerActions }: MobileShellProps) {
const { events, activeEvent, hasMultipleEvents, hasEvents, selectEvent } = useEventContext();
const { events, activeEvent, selectEvent } = useEventContext();
const { go } = useMobileNav(activeEvent?.slug, activeTab);
const navigate = useNavigate();
const location = useLocation();
const { t, i18n } = useTranslation('mobile');
const { t } = useTranslation('mobile');
const { count: notificationCount } = useNotificationsBadge();
const online = useOnlineStatus();
const { background, surface, border, text, muted, warningBg, warningText, primary, danger, shadow } = useAdminTheme();
@@ -45,16 +44,13 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
const textColor = text;
const mutedText = muted;
const headerSurface = withAlpha(surfaceColor, 0.94);
const [pickerOpen, setPickerOpen] = React.useState(false);
const [fallbackEvents, setFallbackEvents] = React.useState<TenantEvent[]>([]);
const [loadingEvents, setLoadingEvents] = React.useState(false);
const [attemptedFetch, setAttemptedFetch] = React.useState(false);
const [queuedPhotoCount, setQueuedPhotoCount] = React.useState(0);
const [isCompactHeader, setIsCompactHeader] = React.useState(false);
const locale = i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE';
const effectiveEvents = events.length ? events : fallbackEvents;
const effectiveHasMultiple = hasMultipleEvents || effectiveEvents.length > 1;
const effectiveHasEvents = hasEvents || effectiveEvents.length > 0;
const effectiveActive = activeEvent ?? (effectiveEvents.length === 1 ? effectiveEvents[0] : null);
React.useEffect(() => {
@@ -74,16 +70,6 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
.finally(() => setLoadingEvents(false));
}, [events.length, loadingEvents, attemptedFetch, activeEvent, selectEvent]);
React.useEffect(() => {
if (!pickerOpen) return;
if (effectiveEvents.length) return;
setLoadingEvents(true);
getEvents({ force: true })
.then((list) => setFallbackEvents(list ?? []))
.catch(() => setFallbackEvents([]))
.finally(() => setLoadingEvents(false));
}, [pickerOpen, effectiveEvents.length]);
React.useEffect(() => {
const path = `${location.pathname}${location.search}${location.hash}`;
@@ -114,17 +100,104 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
};
}, [refreshQueuedActions]);
const eventTitle = title ?? (effectiveActive ? resolveEventDisplayName(effectiveActive) : t('header.appName', 'Event Admin'));
const subtitleText =
subtitle ??
(effectiveActive?.event_date
? formatEventDate(effectiveActive.event_date, locale) ?? ''
: effectiveHasEvents
? t('header.selectEvent', 'Select an event to continue')
: t('header.empty', 'Create your first event to get started'));
React.useEffect(() => {
if (typeof window === 'undefined' || !window.matchMedia) {
return;
}
const query = window.matchMedia('(max-width: 520px)');
const handleChange = (event: MediaQueryListEvent) => {
setIsCompactHeader(event.matches);
};
setIsCompactHeader(query.matches);
query.addEventListener?.('change', handleChange);
return () => {
query.removeEventListener?.('change', handleChange);
};
}, []);
const showEventSwitcher = effectiveHasMultiple;
const pageTitle = title ?? t('header.appName', 'Event Admin');
const eventContext = !isCompactHeader && effectiveActive ? resolveEventDisplayName(effectiveActive) : null;
const subtitleText = subtitle ?? eventContext ?? '';
const showQr = Boolean(effectiveActive?.slug);
const headerBackButton = onBack ? (
<HeaderActionButton onPress={onBack} ariaLabel={t('actions.back', 'Back')}>
<XStack alignItems="center" space="$1.5">
<ChevronLeft size={28} color={primary} strokeWidth={2.5} />
</XStack>
</HeaderActionButton>
) : (
<XStack width={28} />
);
const headerTitle = (
<XStack alignItems="center" space="$1" flex={1} minWidth={0} justifyContent="flex-end">
<YStack alignItems="flex-end" maxWidth="100%">
<Text fontSize="$lg" fontWeight="800" fontFamily="$display" color={textColor} textAlign="right" numberOfLines={1}>
{pageTitle}
</Text>
{subtitleText ? (
<Text fontSize="$xs" color={mutedText} textAlign="right" numberOfLines={1} fontFamily="$body">
{subtitleText}
</Text>
) : null}
</YStack>
</XStack>
);
const headerActionsRow = (
<XStack alignItems="center" space="$2">
<HeaderActionButton
onPress={() => navigate(adminPath('/mobile/notifications'))}
ariaLabel={t('mobile.notifications', 'Notifications')}
>
<XStack
width={34}
height={34}
borderRadius={12}
backgroundColor={surfaceColor}
alignItems="center"
justifyContent="center"
position="relative"
>
<Bell size={16} color={textColor} />
{notificationCount > 0 ? (
<YStack
position="absolute"
top={-4}
right={-4}
minWidth={18}
height={18}
paddingHorizontal={6}
borderRadius={999}
backgroundColor={danger}
alignItems="center"
justifyContent="center"
>
<Text fontSize={10} color="white" fontWeight="700">
{notificationCount > 9 ? '9+' : notificationCount}
</Text>
</YStack>
) : null}
</XStack>
</HeaderActionButton>
{showQr ? (
<HeaderActionButton
onPress={() => navigate(adminPath(`/mobile/events/${effectiveActive?.slug}/qr`))}
ariaLabel={t('header.quickQr', 'Quick QR')}
>
<XStack
width={34}
height={34}
borderRadius={12}
backgroundColor={primary}
alignItems="center"
justifyContent="center"
>
<QrCode size={16} color="white" />
</XStack>
</HeaderActionButton>
) : null}
{headerActions ?? null}
</XStack>
);
return (
<YStack backgroundColor={backgroundColor} minHeight="100vh" alignItems="center">
@@ -150,96 +223,27 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
WebkitBackdropFilter: 'blur(12px)',
}}
>
<XStack alignItems="center" justifyContent="space-between" minHeight={48} space="$3">
{onBack ? (
<HeaderActionButton onPress={onBack} ariaLabel={t('actions.back', 'Back')}>
<XStack alignItems="center" space="$1.5">
<ChevronLeft size={28} color={primary} strokeWidth={2.5} />
{isCompactHeader ? (
<YStack space="$2">
<XStack alignItems="center" justifyContent="space-between" minHeight={48} space="$3">
{headerBackButton}
<XStack flex={1} minWidth={0} justifyContent="flex-end">
{headerTitle}
</XStack>
</HeaderActionButton>
) : (
<XStack width={28} />
)}
<XStack alignItems="center" space="$2.5" flex={1} justifyContent="flex-end">
<XStack alignItems="center" space="$1" maxWidth="55%">
<Pressable
disabled={!showEventSwitcher}
onPress={() => setPickerOpen(true)}
style={{ alignItems: 'flex-end' }}
>
<Text fontSize="$lg" fontWeight="800" fontFamily="$display" color={textColor} textAlign="right" numberOfLines={1}>
{eventTitle}
</Text>
{subtitleText ? (
<Text fontSize="$xs" color={mutedText} textAlign="right" numberOfLines={1} fontFamily="$body">
{subtitleText}
</Text>
) : null}
</Pressable>
{showEventSwitcher ? <ChevronDown size={14} color={textColor} /> : null}
</XStack>
<XStack alignItems="center" space="$2">
<HeaderActionButton
onPress={() => navigate(adminPath('/mobile/notifications'))}
ariaLabel={t('mobile.notifications', 'Notifications')}
>
<XStack
width={34}
height={34}
borderRadius={12}
backgroundColor={surfaceColor}
alignItems="center"
justifyContent="center"
position="relative"
>
<Bell size={16} color={textColor} />
{notificationCount > 0 ? (
<YStack
position="absolute"
top={-4}
right={-4}
minWidth={18}
height={18}
paddingHorizontal={6}
borderRadius={999}
backgroundColor={danger}
alignItems="center"
justifyContent="center"
>
<Text fontSize={10} color="white" fontWeight="700">
{notificationCount > 9 ? '9+' : notificationCount}
</Text>
</YStack>
) : null}
</XStack>
</HeaderActionButton>
{showQr ? (
<HeaderActionButton
onPress={() => navigate(adminPath(`/mobile/events/${effectiveActive?.slug}/qr`))}
ariaLabel={t('header.quickQr', 'Quick QR')}
>
<XStack
height={34}
paddingHorizontal="$3"
borderRadius={12}
backgroundColor={primary}
alignItems="center"
justifyContent="center"
space="$1.5"
>
<QrCode size={16} color="white" />
<Text fontSize="$xs" fontWeight="800" color="white">
{t('header.quickQr', 'Quick QR')}
</Text>
</XStack>
</HeaderActionButton>
) : null}
{headerActions ?? null}
<XStack alignItems="center" justifyContent="flex-end">
{headerActionsRow}
</XStack>
</YStack>
) : (
<XStack alignItems="center" justifyContent="space-between" minHeight={48} space="$3">
{headerBackButton}
<XStack alignItems="center" space="$2.5" flex={1} justifyContent="flex-end" minWidth={0}>
{headerTitle}
{headerActionsRow}
</XStack>
</XStack>
</XStack>
)}
</YStack>
<YStack
@@ -290,75 +294,6 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
<BottomNav active={activeTab} onNavigate={go} />
<MobileSheet
open={pickerOpen}
onClose={() => setPickerOpen(false)}
title={t('header.eventSwitcher', 'Choose an event')}
footer={null}
bottomOffsetPx={110}
>
<YStack space="$2">
{effectiveEvents.length === 0 ? (
<MobileCard alignItems="flex-start" space="$2">
<Text fontSize="$sm" color={textColor} fontWeight="700">
{t('header.noEventsTitle', 'Create your first event')}
</Text>
<Text fontSize="$xs" color={mutedText}>
{t('header.noEventsBody', 'Start an event to access tasks, uploads, QR posters and more.')}
</Text>
<Pressable onPress={() => navigate(adminPath('/mobile/events/new'))}>
<XStack alignItems="center" space="$2">
<Text fontSize="$sm" color={primary} fontWeight="700">
{t('header.createEvent', 'Create event')}
</Text>
</XStack>
</Pressable>
</MobileCard>
) : (
effectiveEvents.map((event) => (
<Pressable
key={event.slug}
onPress={() => {
const targetSlug = event.slug ?? null;
selectEvent(targetSlug);
setPickerOpen(false);
if (targetSlug) {
navigate(adminPath(`/mobile/events/${targetSlug}`));
}
}}
>
<XStack alignItems="center" justifyContent="space-between" paddingVertical="$2">
<YStack space="$0.5">
<Text fontSize="$sm" fontWeight="700" color={textColor}>
{resolveEventDisplayName(event)}
</Text>
<Text fontSize="$xs" color={mutedText}>
{formatEventDate(event.event_date, locale) ?? t('header.noDate', 'Date tbd')}
</Text>
</YStack>
<PillBadge tone={event.slug === activeEvent?.slug ? 'success' : 'muted'}>
{event.slug === activeEvent?.slug
? t('header.active', 'Active')
: (event.status ?? '—')}
</PillBadge>
</XStack>
</Pressable>
))
)}
{activeEvent ? (
<Pressable
onPress={() => {
selectEvent(null);
setPickerOpen(false);
}}
>
<Text fontSize="$xs" color={mutedText} textAlign="center">
{t('header.clearSelection', 'Clear selection')}
</Text>
</Pressable>
) : null}
</YStack>
</MobileSheet>
</YStack>
);
}

View File

@@ -50,7 +50,7 @@ describe('MobileSheet', () => {
const onClose = vi.fn();
render(
<MobileSheet open title="Test Sheet" onClose={onClose}>
<MobileSheet open title="Test Sheet" onClose={onClose} snapPoints={[94]} contentSpacing="$2" padding="$3" paddingBottom="$6">
<div>Body</div>
</MobileSheet>,
);

View File

@@ -12,11 +12,26 @@ type SheetProps = {
onClose: () => void;
children: React.ReactNode;
footer?: React.ReactNode;
snapPoints?: number[];
contentSpacing?: string;
padding?: string;
paddingBottom?: string;
/** Optional bottom offset so content sits above the bottom nav/safe-area. */
bottomOffsetPx?: number;
};
export function MobileSheet({ open, title, onClose, children, footer, bottomOffsetPx = 88 }: SheetProps) {
export function MobileSheet({
open,
title,
onClose,
children,
footer,
snapPoints = [82],
contentSpacing = '$3',
padding = '$4',
paddingBottom = '$7',
bottomOffsetPx = 88,
}: SheetProps) {
const { t } = useTranslation('mobile');
const { surface, textStrong, muted, overlay, shadow, border } = useAdminTheme();
const bottomOffset = `max(env(safe-area-inset-bottom, 0px), ${bottomOffsetPx}px)`;
@@ -33,7 +48,7 @@ export function MobileSheet({ open, title, onClose, children, footer, bottomOffs
onClose();
}
}}
snapPoints={[82]}
snapPoints={snapPoints}
snapPointsMode="percent"
dismissOnOverlayPress
dismissOnSnapToBottom
@@ -48,8 +63,8 @@ export function MobileSheet({ open, title, onClose, children, footer, bottomOffs
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
backgroundColor: surface,
padding: '$4',
paddingBottom: '$7',
padding,
paddingBottom,
shadowColor: shadow,
shadowOpacity: 0.12,
shadowRadius: 18,
@@ -62,7 +77,7 @@ export function MobileSheet({ open, title, onClose, children, footer, bottomOffs
showsVerticalScrollIndicator={false}
{...({ contentContainerStyle: { paddingBottom: 6 } } as any)}
>
<YStack space="$3">
<YStack space={contentSpacing}>
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{title}

View File

@@ -0,0 +1,143 @@
import React from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { act, render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string, fallback?: string) => fallback ?? key, i18n: { language: 'en-GB' } }),
}));
vi.mock('@tamagui/core', () => ({
useTheme: () => ({
background: { val: '#FFF8F5' },
surface: { val: '#ffffff' },
borderColor: { val: '#e5e7eb' },
color: { val: '#1f2937' },
gray: { val: '#6b7280' },
red10: { val: '#b91c1c' },
shadowColor: { val: 'rgba(0,0,0,0.12)' },
primary: { val: '#FF5A5F' },
}),
}));
vi.mock('@tamagui/stacks', () => ({
YStack: ({ children, ...props }: { children: React.ReactNode }) => <div {...props}>{children}</div>,
XStack: ({ children, ...props }: { children: React.ReactNode }) => <div {...props}>{children}</div>,
}));
vi.mock('@tamagui/text', () => ({
SizableText: ({ children, ...props }: { children: React.ReactNode }) => <span {...props}>{children}</span>,
}));
vi.mock('@tamagui/react-native-web-lite', () => ({
Pressable: ({ children, onPress, ...props }: { children: React.ReactNode; onPress?: () => void }) => (
<button type="button" onClick={onPress} {...props}>
{children}
</button>
),
}));
vi.mock('../BottomNav', () => ({
BottomNav: () => <div data-testid="bottom-nav" />,
NavKey: {},
}));
vi.mock('../../../context/EventContext', () => ({
useEventContext: () => ({
events: [],
activeEvent: { slug: 'event-1', name: 'Test Event', event_date: '2024-01-01', status: 'active', settings: {} },
hasMultipleEvents: false,
hasEvents: true,
selectEvent: vi.fn(),
}),
}));
vi.mock('../../hooks/useMobileNav', () => ({
useMobileNav: () => ({ go: vi.fn(), slug: 'event-1' }),
}));
vi.mock('../../hooks/useNotificationsBadge', () => ({
useNotificationsBadge: () => ({ count: 0 }),
}));
vi.mock('../../hooks/useOnlineStatus', () => ({
useOnlineStatus: () => true,
}));
vi.mock('../../../api', () => ({
getEvents: vi.fn().mockResolvedValue([]),
}));
vi.mock('../../lib/tabHistory', () => ({
setTabHistory: vi.fn(),
}));
vi.mock('../../lib/photoModerationQueue', () => ({
loadPhotoQueue: vi.fn(() => []),
}));
vi.mock('../../lib/queueStatus', () => ({
countQueuedPhotoActions: vi.fn(() => 0),
}));
vi.mock('../../theme', () => ({
useAdminTheme: () => ({
background: '#FFF8F5',
surface: '#ffffff',
border: '#e5e7eb',
text: '#1f2937',
muted: '#6b7280',
warningBg: '#fff7ed',
warningText: '#92400e',
primary: '#FF5A5F',
danger: '#b91c1c',
shadow: 'rgba(0,0,0,0.12)',
}),
}));
import { MobileShell } from '../MobileShell';
describe('MobileShell', () => {
beforeEach(() => {
window.matchMedia = vi.fn().mockReturnValue({
matches: false,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
});
it('renders quick QR as icon-only button', async () => {
await act(async () => {
render(
<MemoryRouter>
<MobileShell activeTab="home">
<div>Body</div>
</MobileShell>
</MemoryRouter>
);
});
expect(screen.getByLabelText('Quick QR')).toBeInTheDocument();
expect(screen.queryByText('Quick QR')).not.toBeInTheDocument();
});
it('hides the event context on compact headers', async () => {
window.matchMedia = vi.fn().mockReturnValue({
matches: true,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
await act(async () => {
render(
<MemoryRouter>
<MobileShell activeTab="home">
<div>Body</div>
</MobileShell>
</MemoryRouter>
);
});
expect(screen.queryByText('Test Event')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,59 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import toast from 'react-hot-toast';
import { createTenantPaddleCheckout } from '../../api';
import { adminPath } from '../../constants';
import { getApiErrorMessage } from '../../lib/apiError';
import { storePendingCheckout } from '../lib/billingCheckout';
export function usePackageCheckout(): {
busy: boolean;
startCheckout: (packageId: number) => Promise<void>;
} {
const { t } = useTranslation('management');
const [busy, setBusy] = React.useState(false);
const startCheckout = React.useCallback(
async (packageId: number) => {
if (busy) {
return;
}
setBusy(true);
try {
if (typeof window === 'undefined') {
throw new Error('Checkout is only available in the browser.');
}
const billingUrl = new URL(adminPath('/mobile/billing'), window.location.origin);
const successUrl = new URL(billingUrl);
successUrl.searchParams.set('checkout', 'success');
successUrl.searchParams.set('package_id', String(packageId));
const cancelUrl = new URL(billingUrl);
cancelUrl.searchParams.set('checkout', 'cancel');
cancelUrl.searchParams.set('package_id', String(packageId));
const { checkout_url, checkout_session_id } = await createTenantPaddleCheckout(packageId, {
success_url: successUrl.toString(),
return_url: cancelUrl.toString(),
});
if (checkout_session_id) {
storePendingCheckout({
packageId,
checkoutSessionId: checkout_session_id,
startedAt: Date.now(),
});
}
window.location.href = checkout_url;
} catch (err) {
toast.error(getApiErrorMessage(err, t('shop.errors.checkout', 'Checkout failed')));
setBusy(false);
}
},
[busy, t],
);
return { busy, startCheckout };
}

View File

@@ -0,0 +1,28 @@
export function resolveMaxCount(values: number[]): number {
if (!Array.isArray(values) || values.length === 0) {
return 1;
}
return Math.max(...values, 1);
}
export function resolveTimelineHours(timestamps: string[], fallbackHours = 12): number {
if (!Array.isArray(timestamps) || timestamps.length < 2) {
return fallbackHours;
}
const times = timestamps
.map((value) => new Date(value).getTime())
.filter((value) => Number.isFinite(value));
if (times.length < 2) {
return fallbackHours;
}
const min = Math.min(...times);
const max = Math.max(...times);
const diff = Math.max(0, max - min);
const hours = diff / (1000 * 60 * 60);
return Math.max(1, Math.round(hours));
}

View File

@@ -0,0 +1,82 @@
export type PendingCheckout = {
packageId: number | null;
checkoutSessionId?: string | null;
startedAt: number;
};
export const PENDING_CHECKOUT_TTL_MS = 1000 * 60 * 30;
export const CHECKOUT_STORAGE_KEY = 'admin.billing.checkout.pending.v1';
export function isCheckoutExpired(
pending: PendingCheckout,
now = Date.now(),
ttl = PENDING_CHECKOUT_TTL_MS,
): boolean {
return now - pending.startedAt > ttl;
}
export function loadPendingCheckout(
now = Date.now(),
ttl = PENDING_CHECKOUT_TTL_MS,
): PendingCheckout | null {
if (typeof window === 'undefined') {
return null;
}
try {
const raw = window.sessionStorage.getItem(CHECKOUT_STORAGE_KEY);
if (! raw) {
return null;
}
const parsed = JSON.parse(raw) as PendingCheckout;
if (typeof parsed?.startedAt !== 'number') {
return null;
}
const packageId =
typeof parsed.packageId === 'number' && Number.isFinite(parsed.packageId)
? parsed.packageId
: null;
const checkoutSessionId = typeof parsed.checkoutSessionId === 'string' ? parsed.checkoutSessionId : null;
if (now - parsed.startedAt > ttl) {
return null;
}
return {
packageId,
checkoutSessionId,
startedAt: parsed.startedAt,
};
} catch {
return null;
}
}
export function storePendingCheckout(next: PendingCheckout | null): void {
if (typeof window === 'undefined') {
return;
}
try {
if (! next) {
window.sessionStorage.removeItem(CHECKOUT_STORAGE_KEY);
} else {
window.sessionStorage.setItem(CHECKOUT_STORAGE_KEY, JSON.stringify(next));
}
} catch {
// Ignore storage errors.
}
}
export function shouldClearPendingCheckout(
pending: PendingCheckout,
activePackageId: number | null,
now = Date.now(),
ttl = PENDING_CHECKOUT_TTL_MS,
): boolean {
if (isCheckoutExpired(pending, now, ttl)) {
return true;
}
if (pending.packageId && activePackageId && pending.packageId === activePackageId) {
return true;
}
return false;
}

View File

@@ -0,0 +1,146 @@
import type { Package } from '../../api';
type PackageChange = {
isUpgrade: boolean;
isDowngrade: boolean;
};
export type PackageComparisonRow =
| {
id: string;
type: 'limit';
limitKey: 'max_photos' | 'max_guests' | 'gallery_days';
}
| {
id: string;
type: 'feature';
featureKey: string;
};
function normalizePackageFeatures(pkg: Package | null): string[] {
if (!pkg?.features) {
return [];
}
if (Array.isArray(pkg.features)) {
return pkg.features.filter((feature): feature is string => typeof feature === 'string' && feature.trim().length > 0);
}
if (typeof pkg.features === 'object') {
return Object.entries(pkg.features)
.filter(([, enabled]) => enabled)
.map(([key]) => key);
}
return [];
}
export function getEnabledPackageFeatures(pkg: Package): string[] {
return normalizePackageFeatures(pkg);
}
function collectFeatures(pkg: Package | null): Set<string> {
return new Set(normalizePackageFeatures(pkg));
}
function compareLimit(candidate: number | null, active: number | null): number {
if (active === null) {
return candidate === null ? 0 : -1;
}
if (candidate === null) {
return 1;
}
if (candidate > active) return 1;
if (candidate < active) return -1;
return 0;
}
export function classifyPackageChange(pkg: Package, active: Package | null): PackageChange {
if (!active) {
return { isUpgrade: false, isDowngrade: false };
}
const activeFeatures = collectFeatures(active);
const candidateFeatures = collectFeatures(pkg);
const hasFeatureUpgrade = Array.from(candidateFeatures).some((feature) => !activeFeatures.has(feature));
const hasFeatureDowngrade = Array.from(activeFeatures).some((feature) => !candidateFeatures.has(feature));
const limitKeys: Array<keyof Package> = ['max_photos', 'max_guests', 'gallery_days'];
let hasLimitUpgrade = false;
let hasLimitDowngrade = false;
limitKeys.forEach((key) => {
const candidateLimit = pkg[key] ?? null;
const activeLimit = active[key] ?? null;
const delta = compareLimit(candidateLimit, activeLimit);
if (delta > 0) {
hasLimitUpgrade = true;
} else if (delta < 0) {
hasLimitDowngrade = true;
}
});
const hasUpgrade = hasFeatureUpgrade || hasLimitUpgrade;
const hasDowngrade = hasFeatureDowngrade || hasLimitDowngrade;
if (hasUpgrade && !hasDowngrade) {
return { isUpgrade: true, isDowngrade: false };
}
if (hasDowngrade) {
return { isUpgrade: false, isDowngrade: true };
}
return { isUpgrade: false, isDowngrade: false };
}
export function selectRecommendedPackageId(
packages: Package[],
feature: string | null,
activePackage: Package | null
): number | null {
if (!feature) {
return null;
}
const candidates = packages.filter((pkg) => normalizePackageFeatures(pkg).includes(feature));
if (candidates.length === 0) {
return null;
}
const upgrades = candidates.filter((pkg) => classifyPackageChange(pkg, activePackage).isUpgrade);
const pool = upgrades.length ? upgrades : candidates;
const sorted = [...pool].sort((a, b) => a.price - b.price);
return sorted[0]?.id ?? null;
}
export function buildPackageComparisonRows(packages: Package[]): PackageComparisonRow[] {
const limitRows: PackageComparisonRow[] = [
{ id: 'limit.max_photos', type: 'limit', limitKey: 'max_photos' },
{ id: 'limit.max_guests', type: 'limit', limitKey: 'max_guests' },
{ id: 'limit.gallery_days', type: 'limit', limitKey: 'gallery_days' },
];
const featureKeys = new Set<string>();
packages.forEach((pkg) => {
normalizePackageFeatures(pkg).forEach((key) => {
if (key !== 'photos') {
featureKeys.add(key);
}
});
});
const featureRows = Array.from(featureKeys)
.sort((a, b) => a.localeCompare(b))
.map((featureKey) => ({
id: `feature.${featureKey}`,
type: 'feature' as const,
featureKey,
}));
return [...limitRows, ...featureRows];
}

View File

@@ -15,7 +15,8 @@ const t = (key: string, options?: Record<string, unknown> | string) => {
return template
.replace('{{used}}', String(options?.used ?? '{{used}}'))
.replace('{{limit}}', String(options?.limit ?? '{{limit}}'))
.replace('{{remaining}}', String(options?.remaining ?? '{{remaining}}'));
.replace('{{remaining}}', String(options?.remaining ?? '{{remaining}}'))
.replace('{{count}}', String(options?.count ?? '{{count}}'));
};
describe('packageSummary helpers', () => {
@@ -53,6 +54,12 @@ describe('packageSummary helpers', () => {
expect(result[0].value).toBe('30 of 120 remaining');
});
it('falls back to remaining count when remaining exceeds limit', () => {
const result = getPackageLimitEntries({ max_photos: 120, remaining_photos: 180 }, t);
expect(result[0].value).toBe('Remaining 180');
});
it('formats event usage copy', () => {
const result = formatEventUsage(3, 10, t);

View File

@@ -138,6 +138,12 @@ const formatLimitWithRemaining = (limit: number | null, remaining: number | null
if (remaining !== null && remaining >= 0) {
const normalizedRemaining = Number.isFinite(remaining) ? Math.max(0, Math.round(remaining)) : remaining;
if (normalizedRemaining > limit) {
return t('mobileBilling.usage.remaining', {
count: normalizedRemaining,
defaultValue: 'Remaining {{count}}',
});
}
return t('mobileBilling.usage.remainingOf', {
remaining: normalizedRemaining,
limit,

View File

@@ -12,7 +12,6 @@ export function prefetchMobileRoutes() {
schedule(() => {
void import('./DashboardPage');
void import('./EventsPage');
void import('./EventDetailPage');
void import('./EventPhotosPage');
void import('./EventTasksPage');
void import('./NotificationsPage');

View File

@@ -20,7 +20,6 @@ const AuthCallbackPage = React.lazy(() => import('./mobile/AuthCallbackPage'));
const LoginStartPage = React.lazy(() => import('./mobile/LoginStartPage'));
const LogoutPage = React.lazy(() => import('./mobile/LogoutPage'));
const MobileEventsPage = React.lazy(() => import('./mobile/EventsPage'));
const MobileEventDetailPage = React.lazy(() => import('./mobile/EventDetailPage'));
const MobileEventPhotoboothPage = React.lazy(() => import('./mobile/EventPhotoboothPage'));
const MobileBrandingPage = React.lazy(() => import('./mobile/BrandingPage'));
const MobileEventFormPage = React.lazy(() => import('./mobile/EventFormPage'));
@@ -195,7 +194,7 @@ export const router = createBrowserRouter([
{ path: 'events/:slug/guest-notifications', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}/guest-notifications`} /> },
{ path: 'events/:slug/toolkit', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}`} /> },
{ path: 'mobile/events', element: <MobileEventsPage /> },
{ path: 'mobile/events/:slug', element: <MobileEventDetailPage /> },
{ path: 'mobile/events/:slug', element: <MobileDashboardPage /> },
{ path: 'mobile/events/:slug/branding', element: <RequireAdminAccess><MobileBrandingPage /></RequireAdminAccess> },
{ path: 'mobile/events/new', element: <RequireAdminAccess><MobileEventFormPage /></RequireAdminAccess> },
{ path: 'mobile/events/:slug/edit', element: <RequireAdminAccess><MobileEventFormPage /></RequireAdminAccess> },

View File

@@ -27,7 +27,6 @@ import { SettingsSheet } from './settings-sheet';
import { useTranslation, type TranslateFn } from '../i18n/useTranslation';
import { DEFAULT_EVENT_BRANDING, useOptionalEventBranding } from '../context/EventBrandingContext';
import { useOptionalNotificationCenter, type NotificationCenterValue } from '../context/NotificationCenterContext';
import { useGuestTaskProgress, TASK_BADGE_TARGET } from '../hooks/useGuestTaskProgress';
import { usePushSubscription } from '../hooks/usePushSubscription';
import { getContrastingTextColor, relativeLuminance } from '../lib/color';
import { isTaskModeEnabled } from '../lib/engagement';
@@ -151,7 +150,6 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
const { event, status } = useEventData();
const notificationCenter = useOptionalNotificationCenter();
const [notificationsOpen, setNotificationsOpen] = React.useState(false);
const taskProgress = useGuestTaskProgress(eventToken);
const tasksEnabled = isTaskModeEnabled(event);
const panelRef = React.useRef<HTMLDivElement | null>(null);
const notificationButtonRef = React.useRef<HTMLButtonElement | null>(null);
@@ -258,7 +256,6 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
onToggle={() => setNotificationsOpen((prev) => !prev)}
panelRef={panelRef}
buttonRef={notificationButtonRef}
taskProgress={tasksEnabled && taskProgress?.hydrated ? taskProgress : undefined}
t={t}
/>
)}
@@ -285,18 +282,14 @@ type NotificationButtonProps = {
onToggle: () => void;
panelRef: React.RefObject<HTMLDivElement | null>;
buttonRef: React.RefObject<HTMLButtonElement | null>;
taskProgress?: ReturnType<typeof useGuestTaskProgress>;
t: TranslateFn;
};
type PushState = ReturnType<typeof usePushSubscription>;
function NotificationButton({ center, eventToken, open, onToggle, panelRef, buttonRef, taskProgress, t }: NotificationButtonProps) {
const badgeCount = center.unreadCount + center.pendingCount + center.queueCount;
const progressRatio = taskProgress
? Math.min(1, taskProgress.completedCount / TASK_BADGE_TARGET)
: 0;
const [activeTab, setActiveTab] = React.useState<'unread' | 'all' | 'status'>(center.unreadCount > 0 ? 'unread' : 'all');
function NotificationButton({ center, eventToken, open, onToggle, panelRef, buttonRef, t }: NotificationButtonProps) {
const badgeCount = center.unreadCount;
const [activeTab, setActiveTab] = React.useState<'unread' | 'all' | 'uploads'>(center.unreadCount > 0 ? 'unread' : 'all');
const [scopeFilter, setScopeFilter] = React.useState<'all' | 'tips' | 'general'>('all');
const pushState = usePushSubscription(eventToken);
@@ -321,7 +314,7 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, butt
case 'unread':
base = unreadNotifications;
break;
case 'status':
case 'uploads':
base = uploadNotifications;
break;
default:
@@ -331,7 +324,7 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, butt
}, [activeTab, center.notifications, unreadNotifications, uploadNotifications]);
const scopedNotifications = React.useMemo(() => {
if (scopeFilter === 'all') {
if (activeTab === 'uploads' || scopeFilter === 'all') {
return filteredNotifications;
}
return filteredNotifications.filter((item) => {
@@ -365,10 +358,10 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, butt
>
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-semibold text-slate-900">{t('header.notifications.title', 'Benachrichtigungen')}</p>
<p className="text-sm font-semibold text-slate-900">{t('header.notifications.title', 'Updates')}</p>
<p className="text-xs text-slate-500">
{center.unreadCount > 0
? t('header.notifications.unread', { defaultValue: '{{count}} neu', count: center.unreadCount })
? t('header.notifications.unread', { defaultValue: '{count} neu', count: center.unreadCount })
: t('header.notifications.allRead', 'Alles gelesen')}
</p>
</div>
@@ -384,67 +377,43 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, butt
</div>
<NotificationTabs
tabs={[
{ key: 'unread', label: t('header.notifications.tabUnread', 'Neu'), badge: unreadNotifications.length },
{ key: 'status', label: t('header.notifications.tabStatus', 'Uploads/Status'), badge: uploadNotifications.length },
{ key: 'all', label: t('header.notifications.tabAll', 'Alle'), badge: center.notifications.length },
{ key: 'unread', label: t('header.notifications.tabUnread', 'Nachrichten'), badge: unreadNotifications.length },
{ key: 'uploads', label: t('header.notifications.tabUploads', 'Uploads'), badge: uploadNotifications.length },
{ key: 'all', label: t('header.notifications.tabAll', 'Alle Updates'), badge: center.notifications.length },
]}
activeTab={activeTab}
onTabChange={(next) => setActiveTab(next as typeof activeTab)}
/>
<div className="mt-3">
<div className="flex gap-2 overflow-x-auto text-xs whitespace-nowrap pb-1">
{(
[
{ key: 'all', label: t('header.notifications.scope.all', 'Alle') },
{ key: 'tips', label: t('header.notifications.scope.tips', 'Tipps & Achievements') },
{ key: 'general', label: t('header.notifications.scope.general', 'Allgemein') },
] as const
).map((option) => (
<button
key={option.key}
type="button"
onClick={() => {
setScopeFilter(option.key);
center.setFilters({ scope: option.key });
}}
className={`rounded-full border px-3 py-1 font-semibold transition ${
scopeFilter === option.key
? 'border-pink-200 bg-pink-50 text-pink-700'
: 'border-slate-200 bg-white text-slate-600 hover:border-pink-200 hover:text-pink-700'
}`}
>
{option.label}
</button>
))}
{activeTab !== 'uploads' && (
<div className="mt-3">
<div className="flex gap-2 overflow-x-auto text-xs whitespace-nowrap pb-1">
{(
[
{ key: 'all', label: t('header.notifications.scope.all', 'Alle') },
{ key: 'tips', label: t('header.notifications.scope.tips', 'Tipps & Achievements') },
{ key: 'general', label: t('header.notifications.scope.general', 'Allgemein') },
] as const
).map((option) => (
<button
key={option.key}
type="button"
onClick={() => {
setScopeFilter(option.key);
center.setFilters({ scope: option.key });
}}
className={`rounded-full border px-3 py-1 font-semibold transition ${
scopeFilter === option.key
? 'border-pink-200 bg-pink-50 text-pink-700'
: 'border-slate-200 bg-white text-slate-600 hover:border-pink-200 hover:text-pink-700'
}`}
>
{option.label}
</button>
))}
</div>
</div>
</div>
<div className="mt-3 max-h-80 space-y-2 overflow-y-auto pr-1">
{center.loading ? (
<NotificationSkeleton />
) : scopedNotifications.length === 0 ? (
<NotificationEmptyState
t={t}
message={
activeTab === 'unread'
? t('header.notifications.emptyUnread', 'Du bist auf dem neuesten Stand!')
: activeTab === 'status'
? t('header.notifications.emptyStatus', 'Keine Upload-Hinweise oder Wartungen aktiv.')
: undefined
}
/>
) : (
scopedNotifications.map((item) => (
<NotificationListItem
key={item.id}
item={item}
onMarkRead={() => center.markAsRead(item.id)}
onDismiss={() => center.dismiss(item.id)}
t={t}
/>
))
)}
</div>
{activeTab === 'status' && (
)}
{activeTab === 'uploads' && (center.pendingCount > 0 || center.queueCount > 0) && (
<div className="mt-3 space-y-2">
{center.pendingCount > 0 && (
<div className="flex items-center justify-between rounded-xl bg-amber-50/90 px-3 py-2 text-xs text-amber-900">
@@ -478,30 +447,32 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, butt
)}
</div>
)}
{taskProgress && (
<div className="mt-3 rounded-2xl border border-slate-200 bg-slate-50/90 p-3">
<div className="flex items-center justify-between">
<div>
<p className="text-xs uppercase tracking-[0.3em] text-slate-400">{t('header.notifications.badgeLabel', 'Badge-Fortschritt')}</p>
<p className="text-lg font-semibold text-slate-900">
{taskProgress.completedCount}/{TASK_BADGE_TARGET}
</p>
</div>
<Link
to={`/e/${encodeURIComponent(eventToken)}/tasks`}
className="rounded-full border border-slate-200 px-3 py-1 text-xs font-semibold text-pink-600 transition hover:border-pink-300"
>
{t('header.notifications.tasksCta', 'Weiter')}
</Link>
</div>
<div className="mt-3 h-1.5 w-full rounded-full bg-slate-100">
<div
className="h-full rounded-full bg-pink-500"
style={{ width: `${progressRatio * 100}%` }}
<div className="mt-3 max-h-80 space-y-2 overflow-y-auto pr-1">
{center.loading ? (
<NotificationSkeleton />
) : scopedNotifications.length === 0 ? (
<NotificationEmptyState
t={t}
message={
activeTab === 'unread'
? t('header.notifications.emptyUnread', 'Du bist auf dem neuesten Stand!')
: activeTab === 'uploads'
? t('header.notifications.emptyStatus', 'Keine Upload-Hinweise oder Wartungen aktiv.')
: undefined
}
/>
) : (
scopedNotifications.map((item) => (
<NotificationListItem
key={item.id}
item={item}
onMarkRead={() => center.markAsRead(item.id)}
onDismiss={() => center.dismiss(item.id)}
t={t}
/>
</div>
</div>
)}
))
)}
</div>
<NotificationStatusBar
lastFetchedAt={center.lastFetchedAt}
isOffline={center.isOffline}

View File

@@ -38,7 +38,6 @@ vi.mock('../../context/NotificationCenterContext', () => ({
queueItems: [],
queueCount: 0,
pendingCount: 0,
totalCount: 0,
loading: false,
pendingLoading: false,
refresh: vi.fn(),
@@ -97,10 +96,10 @@ describe('Header notifications toggle', () => {
const bellButton = screen.getByLabelText('Benachrichtigungen anzeigen');
fireEvent.click(bellButton);
expect(screen.getByText('Benachrichtigungen')).toBeInTheDocument();
expect(screen.getByText('Updates')).toBeInTheDocument();
fireEvent.click(bellButton);
expect(screen.queryByText('Benachrichtigungen')).not.toBeInTheDocument();
expect(screen.queryByText('Updates')).not.toBeInTheDocument();
});
});

View File

@@ -16,7 +16,6 @@ export type NotificationCenterValue = {
queueItems: QueueItem[];
queueCount: number;
pendingCount: number;
totalCount: number;
loading: boolean;
pendingLoading: boolean;
refresh: () => Promise<void>;
@@ -264,11 +263,9 @@ export function NotificationCenterProvider({ eventToken, children }: { eventToke
}, [loadNotifications, refreshQueue, loadPendingUploads]);
const loading = loadingNotifications || queueLoading || pendingLoading;
const totalCount = unreadCount + queueCount + pendingCount;
React.useEffect(() => {
void updateAppBadge(totalCount);
}, [totalCount]);
void updateAppBadge(unreadCount);
}, [unreadCount]);
const value: NotificationCenterValue = {
notifications,
@@ -276,7 +273,6 @@ export function NotificationCenterProvider({ eventToken, children }: { eventToke
queueItems: items,
queueCount,
pendingCount,
totalCount,
loading,
pendingLoading,
refresh,

View File

@@ -42,7 +42,13 @@ export const messages: Record<LocaleCode, NestedMessages> = {
},
helpGallery: 'Hilfe zu Galerie & Teilen',
notifications: {
tabStatus: 'Upload-Status',
title: 'Updates',
unread: '{count} neu',
allRead: 'Alles gelesen',
tabUnread: 'Nachrichten',
tabUploads: 'Uploads',
tabAll: 'Alle Updates',
emptyStatus: 'Keine Upload-Hinweise oder Wartungen aktiv.',
},
},
liveShowPlayer: {
@@ -774,7 +780,13 @@ export const messages: Record<LocaleCode, NestedMessages> = {
},
helpGallery: 'Help: Gallery & sharing',
notifications: {
tabStatus: 'Upload status',
title: 'Updates',
unread: '{count} new',
allRead: 'All read',
tabUnread: 'Messages',
tabUploads: 'Uploads',
tabAll: 'All updates',
emptyStatus: 'No upload status or maintenance active.',
},
},
liveShowPlayer: {

View File

@@ -6,6 +6,7 @@ use App\Http\Controllers\Api\LegalController;
use App\Http\Controllers\Api\LiveShowController;
use App\Http\Controllers\Api\Marketing\CouponPreviewController;
use App\Http\Controllers\Api\PackageController;
use App\Http\Controllers\Api\PhotoboothConnectController;
use App\Http\Controllers\Api\SparkboothUploadController;
use App\Http\Controllers\Api\Tenant\AdminPushSubscriptionController;
use App\Http\Controllers\Api\Tenant\DashboardController;
@@ -24,6 +25,7 @@ use App\Http\Controllers\Api\Tenant\LiveShowLinkController;
use App\Http\Controllers\Api\Tenant\LiveShowPhotoController;
use App\Http\Controllers\Api\Tenant\NotificationLogController;
use App\Http\Controllers\Api\Tenant\OnboardingController;
use App\Http\Controllers\Api\Tenant\PhotoboothConnectCodeController;
use App\Http\Controllers\Api\Tenant\PhotoboothController;
use App\Http\Controllers\Api\Tenant\PhotoController;
use App\Http\Controllers\Api\Tenant\ProfileController;
@@ -88,7 +90,7 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
});
});
Route::middleware('throttle:100,1')->group(function () {
Route::middleware('throttle:guest-api')->group(function () {
Route::get('/help', [HelpController::class, 'index'])->name('help.index');
Route::get('/help/{slug}', [HelpController::class, 'show'])->name('help.show');
Route::get('/legal/{slug}', [LegalController::class, 'show'])->name('legal.show');
@@ -153,6 +155,9 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
Route::post('/photobooth/sparkbooth/upload', [SparkboothUploadController::class, 'store'])
->name('photobooth.sparkbooth.upload');
Route::post('/photobooth/connect', [PhotoboothConnectController::class, 'store'])
->middleware('throttle:photobooth-connect')
->name('photobooth.connect');
Route::get('/tenant/events/{event:slug}/photos/{photo}/{variant}/asset', [PhotoController::class, 'asset'])
->whereNumber('photo')
@@ -263,6 +268,8 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
Route::post('/enable', [PhotoboothController::class, 'enable'])->name('tenant.events.photobooth.enable');
Route::post('/rotate', [PhotoboothController::class, 'rotate'])->name('tenant.events.photobooth.rotate');
Route::post('/disable', [PhotoboothController::class, 'disable'])->name('tenant.events.photobooth.disable');
Route::post('/connect-codes', [PhotoboothConnectCodeController::class, 'store'])
->name('tenant.events.photobooth.connect-codes.store');
});
Route::get('members', [EventMemberController::class, 'index'])
@@ -353,6 +360,8 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
Route::post('/complete', [PackageController::class, 'completePurchase'])->name('packages.complete');
Route::post('/free', [PackageController::class, 'assignFree'])->name('packages.free');
Route::post('/paddle-checkout', [PackageController::class, 'createPaddleCheckout'])->name('packages.paddle-checkout');
Route::get('/checkout-session/{session}/status', [PackageController::class, 'checkoutSessionStatus'])
->name('packages.checkout-session.status');
});
Route::get('addons/catalog', [EventAddonCatalogController::class, 'index'])

View File

@@ -11,6 +11,7 @@ use App\Models\TenantPackage;
use App\Services\EventJoinTokenService;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Storage;
use Tests\Feature\Tenant\TenantTestCase;
@@ -77,6 +78,55 @@ class EventControllerTest extends TenantTestCase
->assertJsonPath('error.code', 'event_limit_missing');
}
public function test_superadmin_can_create_event_without_tenant_package(): void
{
$tenant = $this->tenant;
$eventType = EventType::factory()->create();
$package = Package::factory()->create([
'type' => 'endcustomer',
'slug' => 'pro',
'max_photos' => 100,
]);
$superadmin = \App\Models\User::factory()->create([
'tenant_id' => $tenant->id,
'role' => 'superadmin',
'password' => Hash::make('password'),
'email_verified_at' => now(),
]);
$login = $this->postJson('/api/v1/tenant-auth/login', [
'login' => $superadmin->email,
'password' => 'password',
]);
$login->assertOk();
$token = (string) $login->json('token');
$response = $this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/tenant/events', [
'name' => 'Owner Event',
'slug' => 'owner-event',
'event_date' => Carbon::now()->addDays(10)->toDateString(),
'event_type_id' => $eventType->id,
'package_id' => $package->id,
]);
$response->assertStatus(201);
$event = Event::latest()->first();
$this->assertDatabaseHas('events', [
'tenant_id' => $tenant->id,
'slug' => 'owner-event',
]);
$this->assertDatabaseHas('event_packages', [
'event_id' => $event->id,
'package_id' => $package->id,
]);
}
public function test_create_event_requires_waiver_for_endcustomer_package(): void
{
$tenant = $this->tenant;

View File

@@ -0,0 +1,100 @@
<?php
namespace Tests\Feature\Photobooth;
use App\Models\Event;
use App\Models\EventPhotoboothSetting;
use App\Models\PhotoboothConnectCode;
use PHPUnit\Framework\Attributes\Test;
use Tests\Feature\Tenant\TenantTestCase;
class PhotoboothConnectCodeTest extends TenantTestCase
{
#[Test]
public function it_creates_a_connect_code_for_sparkbooth(): void
{
$event = Event::factory()->for($this->tenant)->create([
'slug' => 'connect-code-event',
]);
EventPhotoboothSetting::factory()
->for($event)
->activeSparkbooth()
->create([
'username' => 'pbconnect',
'password' => 'SECRET12',
]);
$response = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/photobooth/connect-codes");
$response->assertOk()
->assertJsonPath('data.code', fn ($value) => is_string($value) && strlen($value) === 6)
->assertJsonPath('data.expires_at', fn ($value) => is_string($value) && $value !== '');
$this->assertDatabaseCount('photobooth_connect_codes', 1);
}
#[Test]
public function it_redeems_a_connect_code_and_returns_upload_credentials(): void
{
$event = Event::factory()->for($this->tenant)->create([
'slug' => 'connect-code-redeem',
]);
EventPhotoboothSetting::factory()
->for($event)
->activeSparkbooth()
->create([
'username' => 'pbconnect',
'password' => 'SECRET12',
]);
$codeResponse = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/photobooth/connect-codes");
$codeResponse->assertOk();
$code = (string) $codeResponse->json('data.code');
$redeem = $this->postJson('/api/v1/photobooth/connect', [
'code' => $code,
]);
$redeem->assertOk()
->assertJsonPath('data.upload_url', fn ($value) => is_string($value) && $value !== '')
->assertJsonPath('data.username', 'pbconnect')
->assertJsonPath('data.password', 'SECRET12');
$this->assertDatabaseHas('photobooth_connect_codes', [
'event_id' => $event->id,
]);
}
#[Test]
public function it_rejects_expired_connect_codes(): void
{
$event = Event::factory()->for($this->tenant)->create([
'slug' => 'connect-code-expired',
]);
EventPhotoboothSetting::factory()
->for($event)
->activeSparkbooth()
->create([
'username' => 'pbconnect',
'password' => 'SECRET12',
]);
$code = '123456';
PhotoboothConnectCode::query()->create([
'event_id' => $event->id,
'code_hash' => hash('sha256', $code),
'expires_at' => now()->subMinute(),
]);
$response = $this->postJson('/api/v1/photobooth/connect', [
'code' => $code,
]);
$response->assertStatus(422);
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace Tests\Feature\Tenant;
use App\Models\Event;
use App\Models\Photo;
class PhotoModerationControllerTest extends TenantTestCase
{
public function test_tenant_admin_can_approve_photo(): void
{
$event = Event::factory()->for($this->tenant)->create([
'slug' => 'moderation-event',
]);
$photo = Photo::factory()->for($event)->create([
'status' => 'pending',
]);
$response = $this->authenticatedRequest('PATCH', "/api/v1/tenant/events/{$event->slug}/photos/{$photo->id}", [
'status' => 'approved',
]);
$response->assertOk();
$this->assertSame('approved', $photo->refresh()->status);
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace Tests\Feature\Tenant;
use App\Models\CheckoutSession;
use App\Models\Package;
use Illuminate\Support\Str;
class TenantCheckoutSessionStatusTest extends TenantTestCase
{
public function test_tenant_can_fetch_checkout_session_status(): void
{
$package = Package::factory()->create([
'price' => 129,
]);
$session = CheckoutSession::create([
'id' => (string) Str::uuid(),
'user_id' => $this->tenantUser->id,
'tenant_id' => $this->tenant->id,
'package_id' => $package->id,
'status' => CheckoutSession::STATUS_FAILED,
'provider' => CheckoutSession::PROVIDER_PADDLE,
'provider_metadata' => [
'paddle_checkout_url' => 'https://checkout.paddle.test/checkout/123',
],
'status_history' => [
[
'status' => CheckoutSession::STATUS_FAILED,
'reason' => 'paddle_failed',
'at' => now()->toIso8601String(),
],
],
]);
$response = $this->authenticatedRequest(
'GET',
"/api/v1/tenant/packages/checkout-session/{$session->id}/status"
);
$response->assertOk()
->assertJsonPath('status', CheckoutSession::STATUS_FAILED)
->assertJsonPath('reason', 'paddle_failed')
->assertJsonPath('checkout_url', 'https://checkout.paddle.test/checkout/123');
}
}

View File

@@ -29,7 +29,10 @@ class TenantPaddleCheckoutTest extends TenantTestCase
return $tenant->is($this->tenant)
&& $payloadPackage->is($package)
&& array_key_exists('success_url', $payload)
&& array_key_exists('return_url', $payload);
&& array_key_exists('return_url', $payload)
&& array_key_exists('metadata', $payload)
&& is_array($payload['metadata'])
&& ! empty($payload['metadata']['checkout_session_id']);
})
->andReturn([
'checkout_url' => 'https://checkout.paddle.test/checkout/123',
@@ -42,7 +45,8 @@ class TenantPaddleCheckoutTest extends TenantTestCase
]);
$response->assertOk()
->assertJsonPath('checkout_url', 'https://checkout.paddle.test/checkout/123');
->assertJsonPath('checkout_url', 'https://checkout.paddle.test/checkout/123')
->assertJsonStructure(['checkout_session_id']);
}
public function test_paddle_checkout_requires_paddle_price_id(): void

View File

@@ -0,0 +1,67 @@
<?php
namespace Tests\Unit;
use App\Models\GuestPolicySetting;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Tests\TestCase;
class RateLimitConfigTest extends TestCase
{
use RefreshDatabase;
public function test_tenant_api_rate_limiter_allows_higher_throughput(): void
{
$request = Request::create('/api/v1/tenant/events', 'GET', [], [], [], [
'REMOTE_ADDR' => '10.0.0.1',
]);
$request->attributes->set('tenant_id', 42);
$limiter = RateLimiter::limiter('tenant-api');
$this->assertNotNull($limiter);
$limit = $limiter($request);
$this->assertInstanceOf(Limit::class, $limit);
$this->assertSame(600, $limit->maxAttempts);
}
public function test_guest_api_rate_limiter_allows_higher_throughput(): void
{
$request = Request::create('/api/v1/events/sample', 'GET', [], [], [], [
'REMOTE_ADDR' => '10.0.0.2',
]);
$limiter = RateLimiter::limiter('guest-api');
$this->assertNotNull($limiter);
$limit = $limiter($request);
$this->assertInstanceOf(Limit::class, $limit);
$this->assertSame(300, $limit->maxAttempts);
}
public function test_guest_policy_defaults_follow_join_token_limits(): void
{
$accessLimit = 300;
$downloadLimit = 120;
config([
'join_tokens.access_limit' => $accessLimit,
'join_tokens.download_limit' => $downloadLimit,
]);
GuestPolicySetting::query()->delete();
GuestPolicySetting::flushCache();
$settings = GuestPolicySetting::current();
$this->assertSame($accessLimit, $settings->join_token_access_limit);
$this->assertSame($downloadLimit, $settings->join_token_download_limit);
}
}

View File

@@ -0,0 +1,144 @@
<?php
namespace Tests\Unit;
use App\Enums\GuestNotificationType;
use App\Events\GuestPhotoUploaded;
use App\Listeners\GuestNotifications\SendPhotoUploadedNotification;
use App\Models\Event;
use App\Models\GuestNotification;
use App\Models\GuestNotificationReceipt;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Tests\TestCase;
class SendPhotoUploadedNotificationTest extends TestCase
{
use RefreshDatabase;
public function test_it_dedupes_recent_photo_activity_notifications(): void
{
Carbon::setTestNow('2026-01-12 13:48:01');
$event = Event::factory()->create();
$listener = $this->app->make(SendPhotoUploadedNotification::class);
GuestNotification::factory()->create([
'tenant_id' => $event->tenant_id,
'event_id' => $event->id,
'type' => GuestNotificationType::PHOTO_ACTIVITY,
'title' => 'Fotospiel-Test hat gerade ein Foto gemacht 🎉',
'payload' => [
'photo_id' => 123,
'photo_ids' => [123],
'count' => 1,
],
'created_at' => now()->subSeconds(5),
'updated_at' => now()->subSeconds(5),
]);
$listener->handle(new GuestPhotoUploaded(
$event,
123,
'device-123',
'Fotospiel-Test'
));
$notification = GuestNotification::query()
->where('event_id', $event->id)
->where('type', GuestNotificationType::PHOTO_ACTIVITY)
->first();
$this->assertSame(1, GuestNotification::query()
->where('event_id', $event->id)
->where('type', GuestNotificationType::PHOTO_ACTIVITY)
->count());
$this->assertSame(1, (int) ($notification?->payload['count'] ?? 0));
}
public function test_it_groups_recent_photo_activity_notifications(): void
{
Carbon::setTestNow('2026-01-12 13:48:01');
$event = Event::factory()->create();
$listener = $this->app->make(SendPhotoUploadedNotification::class);
GuestNotification::factory()->create([
'tenant_id' => $event->tenant_id,
'event_id' => $event->id,
'type' => GuestNotificationType::PHOTO_ACTIVITY,
'title' => 'Fotospiel-Test hat gerade ein Foto gemacht 🎉',
'payload' => [
'photo_id' => 122,
'photo_ids' => [122],
'count' => 1,
],
'created_at' => now()->subMinutes(5),
'updated_at' => now()->subMinutes(5),
]);
$listener->handle(new GuestPhotoUploaded(
$event,
123,
'device-123',
'Fotospiel-Test'
));
$this->assertSame(1, GuestNotification::query()
->where('event_id', $event->id)
->where('type', GuestNotificationType::PHOTO_ACTIVITY)
->count());
$notification = GuestNotification::query()
->where('event_id', $event->id)
->where('type', GuestNotificationType::PHOTO_ACTIVITY)
->first();
$this->assertSame('Es gibt 2 neue Fotos!', $notification?->title);
$this->assertSame(2, (int) ($notification?->payload['count'] ?? 0));
$this->assertSame(1, GuestNotificationReceipt::query()
->where('guest_identifier', 'device-123')
->where('status', 'read')
->count());
}
public function test_it_creates_notification_outside_group_window(): void
{
Carbon::setTestNow('2026-01-12 13:48:01');
$event = Event::factory()->create();
$listener = $this->app->make(SendPhotoUploadedNotification::class);
GuestNotification::factory()->create([
'tenant_id' => $event->tenant_id,
'event_id' => $event->id,
'type' => GuestNotificationType::PHOTO_ACTIVITY,
'title' => 'Fotospiel-Test hat gerade ein Foto gemacht 🎉',
'payload' => [
'photo_id' => 122,
'photo_ids' => [122],
'count' => 1,
],
'created_at' => now()->subMinutes(20),
'updated_at' => now()->subMinutes(20),
]);
$listener->handle(new GuestPhotoUploaded(
$event,
123,
'device-123',
'Fotospiel-Test'
));
$this->assertSame(2, GuestNotification::query()
->where('event_id', $event->id)
->where('type', GuestNotificationType::PHOTO_ACTIVITY)
->count());
$this->assertSame(1, GuestNotificationReceipt::query()
->where('guest_identifier', 'device-123')
->where('status', 'read')
->count());
}
}