Compare commits

...

71 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
Codex Agent
cff014ede5 fix(i18n): restore missing translations and enable Suspense loading
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-07 20:50:09 +01:00
Codex Agent
8c5d3b93d5 feat: improve mobile navigation with tap-to-reset and history filtering
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-07 15:14:31 +01:00
Codex Agent
22cb7ed7ce fix: resolve typescript and build errors across admin and guest apps
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-07 13:25:30 +01:00
Codex Agent
1ec4987b38 i18n: add translations for analytics and 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-06 21:22:24 +01:00
Codex Agent
6542ac66f1 chore: update beads for adaptive shop
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-06 20:57:48 +01:00
Codex Agent
9bf4e8894f feat: make mobile package shop adaptive and inventory-aware
This commit includes:
- Updating navigation to pass ?feature=advanced_analytics context.
- Merging catalog with user inventory in MobilePackageShopPage.
- Implementing smart sorting (recommended first, then price).
- Adding highlighting and badges for recommended and active packages.
- Displaying remaining event counts on package cards.
2026-01-06 20:57:10 +01:00
Codex Agent
704683421f chore: extract more translations for adaptive shop 2026-01-06 20:56:29 +01:00
Codex Agent
9e9e04b97e fix: add missing activeTab prop to MobileShell in PackageShopPage
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-06 19:02:08 +01:00
Codex Agent
59c463dbd3 chore: commit scanner changes
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-06 18:18:18 +01:00
Codex Agent
8af2db2976 chore: update beads 2026-01-06 18:15:48 +01:00
Codex Agent
a22bff1879 chore: extract new translations for package shop 2026-01-06 18:13:45 +01:00
Codex Agent
5009697f7b feat: implement dedicated package shop page with legal confirmation
This commit:
- Creates MobilePackageShopPage.tsx for listing packages and handling checkout.
- Implements a confirmation screen with mandatory AGB and Withdrawal checkboxes.
- Registers the new route /mobile/billing/shop.
- Updates EventAnalyticsPage to link to the new shop page.
- Reverts previous inline upgrade logic in BillingPage.tsx.
2026-01-06 18:04:03 +01:00
Codex Agent
a8b9c3623a fix: remove duplicate createTenantPaddleCheckout definition in api.ts
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-06 16:40:56 +01:00
Codex Agent
d5d53b563c chore: update beads
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-06 16:36:44 +01:00
Codex Agent
c4fa0fc06e feat: implement upgrade to premium flow
This commit adds the ability for tenants to upgrade their package directly from the mobile billing page. It includes:
- New API function createTenantPaddleCheckout in api.ts
- Upgrade handler and UI in BillingPage.tsx
- Updated navigation in EventAnalyticsPage.tsx to link to the packages section of the billing page
2026-01-06 16:35:10 +01:00
Codex Agent
ee3e9737c4 feat: implement advanced analytics for mobile admin dashboard
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
This commit includes:
- Backend EventAnalyticsService and Controller
- API endpoint for event analytics
- Frontend EventAnalyticsPage with custom bar charts and top contributor lists
- Analytics shortcut on the dashboard
- Feature-lock upsell UI for non-premium users
2026-01-06 16:17:23 +01:00
Codex Agent
322cafa3c2 new package limits and features 2026-01-06 15:49:57 +01:00
Codex Agent
232302eb6f Improve package usage visibility
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-06 14:17:27 +01:00
Codex Agent
ef1773d966 Fix package limits in tenant overview
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-06 13:36:50 +01:00
Codex Agent
e3deec9741 Tweak zero remaining events copy
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-06 13:23:20 +01:00
Codex Agent
10fbee4e6e Expand package limit and feature details
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-06 13:20:19 +01:00
Codex Agent
4fe589f0e2 Add export progress hint
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-06 13:03:05 +01:00
Codex Agent
a3538f6470 Fix data exports UI and scope format
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-06 12:59:38 +01:00
Codex Agent
e82a10cb8b Adjust event defaults and nav width
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-06 12:22:12 +01:00
Codex Agent
cc89cc667a Add package summary banner
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-06 12:01:12 +01:00
Codex Agent
a796973861 Admin package summary sheet 2026-01-06 11:57:30 +01:00
Codex Agent
eba212a056 Login Page redesign 2026-01-06 11:56:54 +01:00
158 changed files with 7206 additions and 31335 deletions

View File

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

View File

@@ -1 +1 @@
fotospiel-app-2yn fotospiel-app-29r

12
.gitignore vendored
View File

@@ -13,6 +13,8 @@ fotospiel-tenant-app
/storage/*.key /storage/*.key
/storage/pail /storage/pail
/vendor /vendor
/clients/photobooth-uploader/**/bin
/clients/photobooth-uploader/**/obj
.env .env
.env.backup .env.backup
.env.production .env.production
@@ -23,11 +25,9 @@ Homestead.yaml
npm-debug.log npm-debug.log
yarn-error.log yarn-error.log
/auth.json /auth.json
/.fleet
/.idea
/.nova
/.vscode /.vscode
/.zed
tools/git-askpass.ps1
podman-compose.dev.yml
test-results test-results
GEMINI.md
.beads/.sync.lock
.beads/daemon-error
.beads/sync_base.jsonl

File diff suppressed because it is too large Load Diff

1
GEMINI.md Symbolic link
View File

@@ -0,0 +1 @@
/mnt/c/wwwroot/fotospiel-app/AGENTS.md

View File

@@ -203,9 +203,20 @@ class SeedDemoSwitcherTenants extends Command
$this->upsertAdmin($tenant, 'starter-wedding@demo.fotospiel'); $this->upsertAdmin($tenant, 'starter-wedding@demo.fotospiel');
TenantPackage::updateOrCreate(
['tenant_id' => $tenant->id, 'package_id' => $packages['standard']->id],
[
'price' => $packages['standard']->price,
'purchased_at' => Carbon::now()->subDays(1),
'expires_at' => Carbon::now()->addMonths(12),
'used_events' => 0,
'active' => true,
]
);
$event = $this->upsertEvent( $event = $this->upsertEvent(
tenant: $tenant, tenant: $tenant,
package: $packages['starter'], package: $packages['standard'],
eventType: $eventTypes['wedding'] ?? null, eventType: $eventTypes['wedding'] ?? null,
attributes: [ attributes: [
'name' => ['de' => 'Hochzeit Mia & Jonas', 'en' => 'Wedding Mia & Jonas'], 'name' => ['de' => 'Hochzeit Mia & Jonas', 'en' => 'Wedding Mia & Jonas'],

View File

@@ -2,6 +2,7 @@
namespace App\Filament\Resources\DataExportResource\Tables; namespace App\Filament\Resources\DataExportResource\Tables;
use App\Enums\DataExportScope;
use App\Jobs\GenerateDataExport; use App\Jobs\GenerateDataExport;
use App\Models\DataExport; use App\Models\DataExport;
use App\Services\Audit\SuperAdminAuditLogger; use App\Services\Audit\SuperAdminAuditLogger;
@@ -14,6 +15,15 @@ use Illuminate\Support\Number;
class DataExportTable class DataExportTable
{ {
public static function formatScope(DataExportScope|string|null $state): string
{
if ($state instanceof DataExportScope) {
$state = $state->value;
}
return $state ? __('admin.data_exports.scope.'.$state) : '—';
}
public static function configure(Table $table): Table public static function configure(Table $table): Table
{ {
return $table return $table
@@ -31,7 +41,7 @@ class DataExportTable
TextColumn::make('scope') TextColumn::make('scope')
->label(__('admin.data_exports.fields.scope')) ->label(__('admin.data_exports.fields.scope'))
->badge() ->badge()
->formatStateUsing(fn (?string $state) => $state ? __('admin.data_exports.scope.'.$state) : '—'), ->formatStateUsing(fn (DataExportScope|string|null $state): string => self::formatScope($state)),
TextColumn::make('status') TextColumn::make('status')
->label(__('admin.data_exports.fields.status')) ->label(__('admin.data_exports.fields.status'))
->badge() ->badge()

View File

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

View File

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

View File

@@ -3,9 +3,12 @@
namespace App\Http\Controllers\Api; namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Checkout\CheckoutSessionStatusRequest;
use App\Models\CheckoutSession;
use App\Models\Package; use App\Models\Package;
use App\Models\PackagePurchase; use App\Models\PackagePurchase;
use App\Models\TenantPackage; use App\Models\TenantPackage;
use App\Services\Checkout\CheckoutSessionService;
use App\Services\Paddle\PaddleCheckoutService; use App\Services\Paddle\PaddleCheckoutService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -14,7 +17,10 @@ use Illuminate\Validation\ValidationException;
class PackageController extends Controller class PackageController extends Controller
{ {
public function __construct(private readonly PaddleCheckoutService $paddleCheckout) {} public function __construct(
private readonly PaddleCheckoutService $paddleCheckout,
private readonly CheckoutSessionService $sessions,
) {}
public function index(Request $request): JsonResponse public function index(Request $request): JsonResponse
{ {
@@ -165,23 +171,82 @@ class PackageController extends Controller
$package = Package::findOrFail($request->integer('package_id')); $package = Package::findOrFail($request->integer('package_id'));
$tenant = $request->attributes->get('tenant'); $tenant = $request->attributes->get('tenant');
$user = $request->user();
if (! $tenant) { if (! $tenant) {
throw ValidationException::withMessages(['tenant' => 'Tenant context missing.']); throw ValidationException::withMessages(['tenant' => 'Tenant context missing.']);
} }
if (! $user) {
throw ValidationException::withMessages(['user' => 'User context missing.']);
}
if (! $package->paddle_price_id) { if (! $package->paddle_price_id) {
throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Paddle price.']); throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Paddle price.']);
} }
$session = $this->sessions->createOrResume($user, $package, [
'tenant' => $tenant,
]);
$this->sessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE);
$now = now();
$session->forceFill([
'accepted_terms_at' => $now,
'accepted_privacy_at' => $now,
'accepted_withdrawal_notice_at' => $now,
'digital_content_waiver_at' => null,
'legal_version' => config('app.legal_version', $now->toDateString()),
])->save();
$payload = [ $payload = [
'success_url' => $request->input('success_url'), 'success_url' => $request->input('success_url'),
'return_url' => $request->input('return_url'), 'return_url' => $request->input('return_url'),
'metadata' => [
'checkout_session_id' => $session->id,
'legal_version' => $session->legal_version,
'accepted_terms' => true,
],
]; ];
$checkout = $this->paddleCheckout->createCheckout($tenant, $package, $payload); $checkout = $this->paddleCheckout->createCheckout($tenant, $package, $payload);
return response()->json($checkout); $session->forceFill([
'paddle_checkout_id' => $checkout['id'] ?? $session->paddle_checkout_id,
'provider_metadata' => array_merge($session->provider_metadata ?? [], array_filter([
'paddle_checkout_id' => $checkout['id'] ?? null,
'paddle_checkout_url' => $checkout['checkout_url'] ?? null,
'paddle_expires_at' => $checkout['expires_at'] ?? null,
])),
])->save();
return response()->json(array_merge($checkout, [
'checkout_session_id' => $session->id,
]));
}
public function checkoutSessionStatus(CheckoutSessionStatusRequest $request, CheckoutSession $session): JsonResponse
{
$history = $session->status_history ?? [];
$reason = null;
foreach (array_reverse($history) as $entry) {
if (($entry['status'] ?? null) === $session->status) {
$reason = $entry['reason'] ?? null;
break;
}
}
$checkoutUrl = data_get($session->provider_metadata ?? [], 'paddle_checkout_url');
return response()->json([
'status' => $session->status,
'completed_at' => optional($session->completed_at)->toIso8601String(),
'reason' => $reason,
'checkout_url' => is_string($checkoutUrl) ? $checkoutUrl : null,
]);
} }
private function handleFreePurchase(Request $request, Package $package, $tenant): JsonResponse private function handleFreePurchase(Request $request, Package $package, $tenant): JsonResponse

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

@@ -0,0 +1,46 @@
<?php
namespace App\Http\Controllers\Api\Tenant;
use App\Http\Controllers\Controller;
use App\Models\Event;
use App\Services\Analytics\EventAnalyticsService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
class EventAnalyticsController extends Controller
{
public function __construct(
private readonly EventAnalyticsService $analyticsService
) {}
public function show(Request $request, Event $event): JsonResponse
{
// Check if package has advanced_analytics feature
$packageFeatures = $event->eventPackage?->package?->features ?? [];
// Handle array or JSON string features
if (is_string($packageFeatures)) {
$packageFeatures = json_decode($packageFeatures, true) ?? [];
}
$hasAccess = in_array('advanced_analytics', $packageFeatures, true);
if (!$hasAccess) {
return response()->json([
'message' => 'This feature is only available in the Premium package.',
'code' => 'feature_locked'
], 403);
}
$timeline = $this->analyticsService->getTimeline($event);
$contributors = $this->analyticsService->getTopContributors($event);
$tasks = $this->analyticsService->getTaskStats($event);
return response()->json([
'timeline' => $timeline,
'contributors' => $contributors,
'tasks' => $tasks,
]);
}
}

View File

@@ -16,6 +16,7 @@ use App\Models\Package;
use App\Models\PackagePurchase; use App\Models\PackagePurchase;
use App\Models\Photo; use App\Models\Photo;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User;
use App\Services\EventJoinTokenService; use App\Services\EventJoinTokenService;
use App\Support\ApiError; use App\Support\ApiError;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@@ -88,12 +89,15 @@ class EventController extends Controller
$tenant = Tenant::findOrFail($tenantId); $tenant = Tenant::findOrFail($tenantId);
} }
$actor = $request->user();
$isSuperAdmin = $actor instanceof User && $actor->isSuperAdmin();
// Package check is now handled by middleware // Package check is now handled by middleware
$validated = $request->validated(); $validated = $request->validated();
$tenantId = $tenant->id; $tenantId = $tenant->id;
$requestedPackageId = $validated['package_id'] ?? null; $requestedPackageId = $isSuperAdmin ? $request->integer('package_id') : null;
unset($validated['package_id']); unset($validated['package_id']);
$tenantPackage = $tenant->tenantPackages() $tenantPackage = $tenant->tenantPackages()
@@ -108,6 +112,10 @@ class EventController extends Controller
$package = Package::query()->find($requestedPackageId); $package = Package::query()->find($requestedPackageId);
} }
if (! $package && $isSuperAdmin) {
$package = $this->resolveOwnerPackage();
}
if (! $package && $tenantPackage) { if (! $package && $tenantPackage) {
$package = $tenantPackage->package ?? Package::query()->find($tenantPackage->package_id); $package = $tenantPackage->package ?? Package::query()->find($tenantPackage->package_id);
} }
@@ -121,7 +129,7 @@ class EventController extends Controller
$requiresWaiver = $package->isEndcustomer(); $requiresWaiver = $package->isEndcustomer();
$latestPurchase = $requiresWaiver ? $this->resolveLatestPackagePurchase($tenant, $package) : null; $latestPurchase = $requiresWaiver ? $this->resolveLatestPackagePurchase($tenant, $package) : null;
$existingWaiver = $latestPurchase ? data_get($latestPurchase->metadata, 'consents.digital_content_waiver_at') : null; $existingWaiver = $latestPurchase ? data_get($latestPurchase->metadata, 'consents.digital_content_waiver_at') : null;
$needsWaiver = $requiresWaiver && ! $existingWaiver; $needsWaiver = ! $isSuperAdmin && $requiresWaiver && ! $existingWaiver;
if ($needsWaiver && ! $request->boolean('accepted_waiver')) { if ($needsWaiver && ! $request->boolean('accepted_waiver')) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
@@ -182,7 +190,7 @@ class EventController extends Controller
$eventData = Arr::only($eventData, $allowed); $eventData = Arr::only($eventData, $allowed);
$event = DB::transaction(function () use ($tenant, $eventData, $package) { $event = DB::transaction(function () use ($tenant, $eventData, $package, $isSuperAdmin) {
$event = Event::create($eventData); $event = Event::create($eventData);
EventPackage::create([ EventPackage::create([
@@ -193,7 +201,7 @@ class EventController extends Controller
'gallery_expires_at' => $package->gallery_days ? now()->addDays($package->gallery_days) : null, 'gallery_expires_at' => $package->gallery_days ? now()->addDays($package->gallery_days) : null,
]); ]);
if ($package->isReseller()) { if ($package->isReseller() && ! $isSuperAdmin) {
$note = sprintf('Event #%d created (%s)', $event->id, $event->name); $note = sprintf('Event #%d created (%s)', $event->id, $event->name);
if (! $tenant->consumeEventAllowance(1, 'event.create', $note)) { if (! $tenant->consumeEventAllowance(1, 'event.create', $note)) {
@@ -229,6 +237,15 @@ class EventController extends Controller
->first(); ->first();
} }
private function resolveOwnerPackage(): ?Package
{
$ownerPackage = Package::query()
->where('slug', 'pro')
->first();
return $ownerPackage ?? Package::query()->find(3);
}
private function recordEventStartWaiver(Tenant $tenant, Package $package, ?PackagePurchase $purchase): void private function recordEventStartWaiver(Tenant $tenant, Package $package, ?PackagePurchase $purchase): void
{ {
$timestamp = now(); $timestamp = now();

View File

@@ -135,7 +135,7 @@ class EventMemberController extends Controller
$user->password = Hash::make(Str::random(32)); $user->password = Hash::make(Str::random(32));
} }
if ($user->tenant_id && (int) $user->tenant_id !== (int) $tenant->id && $user->role !== 'super_admin') { if ($user->tenant_id && (int) $user->tenant_id !== (int) $tenant->id && ! $user->isSuperAdmin()) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'email' => __('Dieser Benutzer ist einem anderen Mandanten zugeordnet.'), 'email' => __('Dieser Benutzer ist einem anderen Mandanten zugeordnet.'),
]); ]);
@@ -143,9 +143,9 @@ class EventMemberController extends Controller
$user->tenant_id = $tenant->id; $user->tenant_id = $tenant->id;
if ($role === 'tenant_admin' && $user->role !== 'super_admin') { if ($role === 'tenant_admin' && ! $user->isSuperAdmin()) {
$user->role = 'tenant_admin'; $user->role = 'tenant_admin';
} elseif (! in_array($user->role, ['tenant_admin', 'super_admin'], true)) { } elseif (! in_array($user->role, ['tenant_admin', 'super_admin', 'superadmin'], true)) {
$user->role = 'member'; $user->role = 'member';
} }

View File

@@ -34,6 +34,8 @@ class OnboardingController extends Controller
'admin_app_opened_at' => Arr::get($settings, 'onboarding.admin_app_opened_at'), 'admin_app_opened_at' => Arr::get($settings, 'onboarding.admin_app_opened_at'),
'primary_event_id' => Arr::get($settings, 'onboarding.primary_event_id'), 'primary_event_id' => Arr::get($settings, 'onboarding.primary_event_id'),
'selected_packages' => Arr::get($settings, 'onboarding.selected_packages'), 'selected_packages' => Arr::get($settings, 'onboarding.selected_packages'),
'summary_seen_package_id' => Arr::get($settings, 'onboarding.summary_seen_package_id'),
'summary_seen_at' => Arr::get($settings, 'onboarding.summary_seen_at'),
'dismissed_at' => Arr::get($settings, 'onboarding.dismissed_at'), 'dismissed_at' => Arr::get($settings, 'onboarding.dismissed_at'),
'completed_at' => Arr::get($settings, 'onboarding.completed_at'), 'completed_at' => Arr::get($settings, 'onboarding.completed_at'),
'branding_completed' => (bool) ($status['palette'] ?? false), 'branding_completed' => (bool) ($status['palette'] ?? false),
@@ -86,6 +88,11 @@ class OnboardingController extends Controller
Arr::set($settings, 'onboarding.invite_created_at', Carbon::now()->toIso8601String()); Arr::set($settings, 'onboarding.invite_created_at', Carbon::now()->toIso8601String());
break; break;
case 'summary_seen':
Arr::set($settings, 'onboarding.summary_seen_package_id', Arr::get($meta, 'package_id'));
Arr::set($settings, 'onboarding.summary_seen_at', Carbon::now()->toIso8601String());
break;
case 'dismissed': case 'dismissed':
Arr::set($settings, 'onboarding.dismissed_at', Carbon::now()->toIso8601String()); Arr::set($settings, 'onboarding.dismissed_at', Carbon::now()->toIso8601String());
break; break;

View File

@@ -525,13 +525,13 @@ class PhotoController extends Controller
]); ]);
// Only tenant admins can moderate // Only tenant admins can moderate
if (isset($validated['status']) && ! $this->tokenHasScope($request, 'tenant:write')) { if (isset($validated['status']) && ! $this->tokenHasScope($request, 'tenant-admin')) {
return ApiError::response( return ApiError::response(
'insufficient_scope', 'insufficient_scope',
'Insufficient Scopes', 'Insufficient Scopes',
'You are not allowed to moderate photos for this event.', 'You are not allowed to moderate photos for this event.',
Response::HTTP_FORBIDDEN, Response::HTTP_FORBIDDEN,
['required_scope' => 'tenant:write'] ['required_scope' => 'tenant-admin']
); );
} }
@@ -823,6 +823,11 @@ class PhotoController extends Controller
private function tokenHasScope(Request $request, string $scope): bool private function tokenHasScope(Request $request, string $scope): bool
{ {
$accessToken = $request->user()?->currentAccessToken();
if ($accessToken && $accessToken->can($scope)) {
return true;
}
$scopes = $request->user()->scopes ?? ($request->attributes->get('decoded_token')['scopes'] ?? []); $scopes = $request->user()->scopes ?? ($request->attributes->get('decoded_token')['scopes'] ?? []);
if (! is_array($scopes)) { if (! is_array($scopes)) {

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; $abilities[] = 'tenant:'.$user->tenant_id;
} }
if (in_array($user->role, ['tenant_admin', 'admin', 'super_admin'], true)) { if (in_array($user->role, ['tenant_admin', 'admin', 'super_admin', 'superadmin'], true)) {
$abilities[] = 'tenant-admin'; $abilities[] = 'tenant-admin';
} }
if ($user->role === 'super_admin') { if ($user->isSuperAdmin()) {
$abilities[] = 'super-admin'; $abilities[] = 'super-admin';
} }
@@ -219,7 +219,7 @@ class TenantAdminTokenController extends Controller
private function ensureUserCanAccessPanel(User $user): void private function ensureUserCanAccessPanel(User $user): void
{ {
if (in_array($user->role, ['tenant_admin', 'admin', 'super_admin'], true)) { if (in_array($user->role, ['tenant_admin', 'admin', 'super_admin', 'superadmin'], true)) {
return; return;
} }

View File

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

View File

@@ -80,7 +80,7 @@ class TenantAdminPasswordResetController extends Controller
private function canAccessEventAdmin(User $user): bool private function canAccessEventAdmin(User $user): bool
{ {
if (in_array($user->role, ['tenant_admin', 'admin', 'super_admin'], true)) { if (in_array($user->role, ['tenant_admin', 'admin', 'super_admin', 'superadmin'], true)) {
return true; return true;
} }

View File

@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Api; namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\EventPackage;
use App\Models\TenantPackage; use App\Models\TenantPackage;
use App\Support\ApiError; use App\Support\ApiError;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@@ -29,23 +30,108 @@ class TenantPackageController extends Controller
->orderBy('created_at', 'desc') ->orderBy('created_at', 'desc')
->get(); ->get();
$packages->each(function ($package) { $usageEventPackage = $this->resolveUsageEventPackage($tenant->id);
$pkg = $package->package;
$package->remaining_events = $pkg->max_events_per_year - $package->used_events; $packages->each(function (TenantPackage $package) use ($usageEventPackage): void {
$package->package_limits = array_merge( $eventPackage = $package->active ? $usageEventPackage : null;
$pkg->limits, $this->hydratePackageSnapshot($package, $eventPackage);
[
'branding_allowed' => $pkg->branding_allowed,
'watermark_allowed' => $pkg->watermark_allowed,
'features' => $pkg->features,
]
);
}); });
$activePackage = $tenant->activeResellerPackage?->load('package');
if ($activePackage instanceof TenantPackage) {
$this->hydratePackageSnapshot($activePackage, $usageEventPackage);
}
return response()->json([ return response()->json([
'data' => $packages, 'data' => $packages,
'active_package' => $tenant->activeResellerPackage ? $tenant->activeResellerPackage->load('package') : null, 'active_package' => $activePackage,
'message' => 'Tenant packages loaded successfully.', 'message' => 'Tenant packages loaded successfully.',
]); ]);
} }
private function hydratePackageSnapshot(TenantPackage $package, ?EventPackage $eventPackage = null): void
{
$pkg = $package->package;
$maxEvents = $pkg?->max_events_per_year;
$package->remaining_events = $maxEvents === null ? null : max($maxEvents - $package->used_events, 0);
$package->package_limits = array_merge(
$pkg?->limits ?? [],
$this->buildUsageSnapshot($eventPackage),
[
'branding_allowed' => $pkg?->branding_allowed,
'watermark_allowed' => $pkg?->watermark_allowed,
'features' => $pkg?->features ?? [],
]
);
}
/**
* @return Collection<int, EventPackage>
*/
private function resolveUsageEventPackage(int $tenantId): ?EventPackage
{
$baseQuery = EventPackage::query()
->whereHas('event', fn ($query) => $query->where('tenant_id', $tenantId))
->with('package')
->orderByDesc('purchased_at')
->orderByDesc('created_at');
$activeEventPackage = (clone $baseQuery)
->whereNotNull('gallery_expires_at')
->where('gallery_expires_at', '>=', now())
->first();
return $activeEventPackage ?? $baseQuery->first();
}
private function buildUsageSnapshot(?EventPackage $eventPackage): array
{
if (! $eventPackage) {
return [];
}
$limits = $eventPackage->effectiveLimits();
$maxPhotos = $this->normalizeLimit($limits['max_photos'] ?? null);
$maxGuests = $this->normalizeLimit($limits['max_guests'] ?? null);
$galleryDays = $this->normalizeLimit($limits['gallery_days'] ?? null);
$usedPhotos = (int) $eventPackage->used_photos;
$usedGuests = (int) $eventPackage->used_guests;
$remainingPhotos = $maxPhotos === null ? null : max(0, $maxPhotos - $usedPhotos);
$remainingGuests = $maxGuests === null ? null : max(0, $maxGuests - $usedGuests);
$remainingGalleryDays = null;
$usedGalleryDays = null;
if ($galleryDays !== null && $eventPackage->gallery_expires_at) {
$remainingGalleryDays = max(0, now()->diffInDays($eventPackage->gallery_expires_at, false));
$usedGalleryDays = max(0, $galleryDays - $remainingGalleryDays);
}
return array_filter([
'used_photos' => $maxPhotos === null ? null : $usedPhotos,
'remaining_photos' => $remainingPhotos,
'used_guests' => $maxGuests === null ? null : $usedGuests,
'remaining_guests' => $remainingGuests,
'used_gallery_days' => $usedGalleryDays,
'remaining_gallery_days' => $remainingGalleryDays,
], static fn ($value) => $value !== null);
}
private function normalizeLimit(?int $value): ?int
{
if ($value === null) {
return null;
}
$value = (int) $value;
if ($value <= 0) {
return null;
}
return $value;
}
} }

View File

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

View File

@@ -12,7 +12,7 @@ class TenantAdminAuthController extends Controller
$user = Auth::user(); $user = Auth::user();
// Allow only tenant_admin and super_admin // Allow only tenant_admin and super_admin
if ($user && in_array($user->role, ['tenant_admin', 'super_admin'])) { if ($user && in_array($user->role, ['tenant_admin', 'super_admin', 'superadmin'], true)) {
return view('admin'); return view('admin');
} }

View File

@@ -46,7 +46,7 @@ class TenantAdminGoogleController extends Controller
/** @var User|null $user */ /** @var User|null $user */
$user = User::query()->where('email', $email)->first(); $user = User::query()->where('email', $email)->first();
if (! $user || ! in_array($user->role, ['tenant_admin', 'super_admin'], true)) { if (! $user || ! in_array($user->role, ['tenant_admin', 'super_admin', 'superadmin'], true)) {
return $this->sendBackWithError($request, 'google_no_match', 'No tenant admin account is linked to this Google address.'); return $this->sendBackWithError($request, 'google_no_match', 'No tenant admin account is linked to this Google address.');
} }

View File

@@ -3,6 +3,7 @@
namespace App\Http\Middleware; namespace App\Http\Middleware;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User;
use App\Services\Packages\PackageLimitEvaluator; use App\Services\Packages\PackageLimitEvaluator;
use App\Support\ApiError; use App\Support\ApiError;
use Closure; use Closure;
@@ -26,7 +27,7 @@ class CreditCheckMiddleware
]); ]);
} }
if ($this->requiresCredits($request)) { if ($this->requiresCredits($request) && ! $this->shouldBypassCreditCheck($request, $tenant)) {
$violation = $this->limitEvaluator->assessEventCreation($tenant); $violation = $this->limitEvaluator->assessEventCreation($tenant);
if ($violation !== null) { if ($violation !== null) {
@@ -43,6 +44,24 @@ class CreditCheckMiddleware
return $next($request); return $next($request);
} }
private function shouldBypassCreditCheck(Request $request, Tenant $tenant): bool
{
$user = $request->user();
if (! $user instanceof User) {
return false;
}
if (! $user->isSuperAdmin()) {
return false;
}
if (! $user->tenant_id) {
return false;
}
return (int) $user->tenant_id === (int) $tenant->id;
}
private function requiresCredits(Request $request): bool private function requiresCredits(Request $request): bool
{ {
return $request->isMethod('post') return $request->isMethod('post')

View File

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

View File

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

View File

@@ -3,6 +3,7 @@
namespace App\Http\Middleware; namespace App\Http\Middleware;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User;
use App\Services\Packages\PackageLimitEvaluator; use App\Services\Packages\PackageLimitEvaluator;
use App\Support\ApiError; use App\Support\ApiError;
use Closure; use Closure;
@@ -26,7 +27,7 @@ class PackageMiddleware
]); ]);
} }
if ($this->requiresPackageCheck($request)) { if ($this->requiresPackageCheck($request) && ! $this->shouldBypassPackageCheck($request, $tenant)) {
$violation = $this->detectViolation($request, $tenant); $violation = $this->detectViolation($request, $tenant);
if ($violation !== null) { if ($violation !== null) {
@@ -43,6 +44,24 @@ class PackageMiddleware
return $next($request); return $next($request);
} }
private function shouldBypassPackageCheck(Request $request, Tenant $tenant): bool
{
$user = $request->user();
if (! $user instanceof User) {
return false;
}
if (! $user->isSuperAdmin()) {
return false;
}
if (! $user->tenant_id) {
return false;
}
return (int) $user->tenant_id === (int) $tenant->id;
}
private function requiresPackageCheck(Request $request): bool private function requiresPackageCheck(Request $request): bool
{ {
return $request->isMethod('post') && ( return $request->isMethod('post') && (

View File

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

View File

@@ -4,9 +4,9 @@ namespace App\Http\Middleware;
use Closure; use Closure;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;
class SuperAdminAuth class SuperAdminAuth
{ {
@@ -28,7 +28,7 @@ class SuperAdminAuth
$user = Auth::user(); $user = Auth::user();
Log::info('SuperAdminAuth: User ID '.$user->id.', role: '.$user->role); Log::info('SuperAdminAuth: User ID '.$user->id.', role: '.$user->role);
if ($user->role !== 'super_admin') { if (! $user->isSuperAdmin()) {
abort(403, 'Zugriff nur für SuperAdmin. User ID: '.$user->id.', Role: '.$user->role); abort(403, 'Zugriff nur für SuperAdmin. User ID: '.$user->id.', Role: '.$user->role);
} }

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'], 'event_date' => ['required', 'date', 'after_or_equal:today'],
'location' => ['nullable', 'string', 'max:255'], 'location' => ['nullable', 'string', 'max:255'],
'event_type_id' => ['required', 'exists:event_types,id'], 'event_type_id' => ['required', 'exists:event_types,id'],
'package_id' => ['nullable', 'integer', 'exists:packages,id'],
'max_participants' => ['nullable', 'integer', 'min:1', 'max:10000'], 'max_participants' => ['nullable', 'integer', 'min:1', 'max:10000'],
'public_url' => ['nullable', 'url', 'max:500'], 'public_url' => ['nullable', 'url', 'max:500'],
'custom_domain' => ['nullable', 'string', 'max:255'], 'custom_domain' => ['nullable', 'string', 'max:255'],

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\GuestNotificationAudience;
use App\Enums\GuestNotificationType; use App\Enums\GuestNotificationType;
use App\Events\GuestPhotoUploaded; use App\Events\GuestPhotoUploaded;
use App\Models\GuestNotification;
use App\Models\Photo; use App\Models\Photo;
use App\Services\GuestNotificationService; use App\Services\GuestNotificationService;
use Illuminate\Support\Carbon;
class SendPhotoUploadedNotification class SendPhotoUploadedNotification
{ {
private const DEDUPE_WINDOW_SECONDS = 30;
private const GROUP_WINDOW_MINUTES = 10;
private const MAX_GROUP_PHOTOS = 6;
/** /**
* @param int[] $milestones * @param int[] $milestones
*/ */
@@ -25,7 +33,20 @@ class SendPhotoUploadedNotification
? sprintf('%s hat gerade ein Foto gemacht 🎉', $guestLabel) ? sprintf('%s hat gerade ein Foto gemacht 🎉', $guestLabel)
: 'Es gibt neue Fotos!'; : 'Es gibt neue Fotos!';
$this->notifications->createNotification( $recent = $this->findRecentPhotoNotification($event->event->id);
if ($recent) {
if ($this->shouldSkipDuplicate($recent, $event->photoId, $title)) {
return;
}
$notification = $this->updateGroupedNotification($recent, $event->photoId);
$this->markUploaderRead($notification, $event->guestIdentifier);
$this->maybeCreateMilestoneNotification($event, $guestLabel);
return;
}
$notification = $this->notifications->createNotification(
$event->event, $event->event,
GuestNotificationType::PHOTO_ACTIVITY, GuestNotificationType::PHOTO_ACTIVITY,
$title, $title,
@@ -34,11 +55,15 @@ class SendPhotoUploadedNotification
'audience_scope' => GuestNotificationAudience::ALL, 'audience_scope' => GuestNotificationAudience::ALL,
'payload' => [ 'payload' => [
'photo_id' => $event->photoId, 'photo_id' => $event->photoId,
'photo_ids' => [$event->photoId],
'count' => 1,
], ],
'expires_at' => now()->addHours(3), 'expires_at' => now()->addHours(3),
] ]
); );
$this->markUploaderRead($notification, $event->guestIdentifier);
$this->maybeCreateMilestoneNotification($event, $guestLabel); $this->maybeCreateMilestoneNotification($event, $guestLabel);
} }
@@ -87,4 +112,94 @@ class SendPhotoUploadedNotification
return $guestIdentifier; return $guestIdentifier;
} }
private function findRecentPhotoNotification(int $eventId): ?GuestNotification
{
$cutoff = Carbon::now()->subMinutes(self::GROUP_WINDOW_MINUTES);
return GuestNotification::query()
->where('event_id', $eventId)
->where('type', GuestNotificationType::PHOTO_ACTIVITY)
->active()
->notExpired()
->where('created_at', '>=', $cutoff)
->orderByDesc('id')
->first();
}
private function shouldSkipDuplicate(GuestNotification $notification, int $photoId, string $title): bool
{
$payload = $notification->payload;
if (is_array($payload)) {
$payloadIds = array_filter(
array_map(
fn ($value) => is_numeric($value) ? (int) $value : null,
(array) ($payload['photo_ids'] ?? [])
),
fn ($value) => $value !== null && $value > 0
);
if (in_array($photoId, $payloadIds, true)) {
return true;
}
if (is_numeric($payload['photo_id'] ?? null) && (int) $payload['photo_id'] === $photoId) {
return true;
}
}
$cutoff = Carbon::now()->subSeconds(self::DEDUPE_WINDOW_SECONDS);
if ($notification->created_at instanceof Carbon && $notification->created_at->greaterThanOrEqualTo($cutoff)) {
return $notification->title === $title;
}
return false;
}
private function updateGroupedNotification(GuestNotification $notification, int $photoId): GuestNotification
{
$payload = is_array($notification->payload) ? $notification->payload : [];
$photoIds = array_filter(
array_map(
fn ($value) => is_numeric($value) ? (int) $value : null,
(array) ($payload['photo_ids'] ?? [])
),
fn ($value) => $value !== null && $value > 0
);
$photoIds[] = $photoId;
$photoIds = array_values(array_unique($photoIds));
$photoIds = array_slice($photoIds, 0, self::MAX_GROUP_PHOTOS);
$existingCount = is_numeric($payload['count'] ?? null)
? max(1, (int) $payload['count'])
: max(1, count($photoIds) - 1);
$newCount = $existingCount + 1;
$notification->forceFill([
'title' => $this->buildGroupedTitle($newCount),
'payload' => [
'count' => $newCount,
'photo_ids' => $photoIds,
],
])->save();
return $notification;
}
private function buildGroupedTitle(int $count): string
{
if ($count <= 1) {
return 'Es gibt neue Fotos!';
}
return sprintf('Es gibt %d neue Fotos!', $count);
}
private function markUploaderRead(GuestNotification $notification, string $guestIdentifier): void
{
$guestIdentifier = trim($guestIdentifier);
if ($guestIdentifier === '' || $guestIdentifier === 'anonymous') {
return;
}
$this->notifications->markAsRead($notification, $guestIdentifier);
}
} }

View File

@@ -41,9 +41,9 @@ class GuestPolicySetting extends Model
'per_device_upload_limit' => 50, 'per_device_upload_limit' => 50,
'join_token_failure_limit' => (int) config('join_tokens.failure_limit', 10), 'join_token_failure_limit' => (int) config('join_tokens.failure_limit', 10),
'join_token_failure_decay_minutes' => (int) config('join_tokens.failure_decay_minutes', 5), 'join_token_failure_decay_minutes' => (int) config('join_tokens.failure_decay_minutes', 5),
'join_token_access_limit' => (int) config('join_tokens.access_limit', 120), 'join_token_access_limit' => (int) config('join_tokens.access_limit', 300),
'join_token_access_decay_minutes' => (int) config('join_tokens.access_decay_minutes', 1), 'join_token_access_decay_minutes' => (int) config('join_tokens.access_decay_minutes', 1),
'join_token_download_limit' => (int) config('join_tokens.download_limit', 60), 'join_token_download_limit' => (int) config('join_tokens.download_limit', 120),
'join_token_download_decay_minutes' => (int) config('join_tokens.download_decay_minutes', 1), 'join_token_download_decay_minutes' => (int) config('join_tokens.download_decay_minutes', 1),
'join_token_ttl_hours' => 168, 'join_token_ttl_hours' => 168,
'share_link_ttl_hours' => (int) config('share-links.ttl_hours', 48), 'share_link_ttl_hours' => (int) config('share-links.ttl_hours', 48),

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

View File

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

View File

@@ -15,7 +15,7 @@ class TenantPolicy
*/ */
public function viewAny(User $user): bool public function viewAny(User $user): bool
{ {
return $user->role === 'super_admin'; return $user->isSuperAdmin();
} }
/** /**
@@ -35,7 +35,7 @@ class TenantPolicy
*/ */
public function create(User $user): bool public function create(User $user): bool
{ {
return $user->role === 'super_admin'; return $user->isSuperAdmin();
} }
/** /**
@@ -43,7 +43,7 @@ class TenantPolicy
*/ */
public function update(User $user, Tenant $tenant): bool public function update(User $user, Tenant $tenant): bool
{ {
return $user->role === 'super_admin'; return $user->isSuperAdmin();
} }
/** /**
@@ -51,7 +51,7 @@ class TenantPolicy
*/ */
public function delete(User $user, Tenant $tenant): bool public function delete(User $user, Tenant $tenant): bool
{ {
return $user->role === 'super_admin'; return $user->isSuperAdmin();
} }
/** /**
@@ -59,6 +59,6 @@ class TenantPolicy
*/ */
public function suspend(User $user, Tenant $tenant): bool public function suspend(User $user, Tenant $tenant): bool
{ {
return $user->role === 'super_admin'; return $user->isSuperAdmin();
} }
} }

View File

@@ -155,7 +155,15 @@ class AppServiceProvider extends ServiceProvider
$key = $tenantId ? 'tenant:'.$tenantId : ('ip:'.($request->ip() ?? 'unknown')); $key = $tenantId ? 'tenant:'.$tenantId : ('ip:'.($request->ip() ?? 'unknown'));
return Limit::perMinute(100)->by($key); return Limit::perMinute(600)->by($key);
});
RateLimiter::for('guest-api', function (Request $request) {
return Limit::perMinute(300)->by('guest-api:'.($request->ip() ?? 'unknown'));
});
RateLimiter::for('photobooth-connect', function (Request $request) {
return Limit::perMinute(30)->by('photobooth-connect:'.($request->ip() ?? 'unknown'));
}); });
RateLimiter::for('tenant-auth', function (Request $request) { RateLimiter::for('tenant-auth', function (Request $request) {

View File

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

View File

@@ -0,0 +1,73 @@
<?php
namespace App\Services\Analytics;
use App\Models\Event;
use App\Models\Photo;
use Illuminate\Support\Facades\DB;
class EventAnalyticsService
{
/**
* Get the activity timeline (uploads per hour).
*/
public function getTimeline(Event $event): array
{
// Group by hour of created_at
// Adjust for timezone if necessary, but for now we'll use UTC or server time
// Ideally we should use the event's timezone if stored, or client's.
// We'll return data in ISO format buckets.
$stats = $event->photos()
->selectRaw('DATE_FORMAT(created_at, "%Y-%m-%d %H:00:00") as hour, count(*) as count')
->groupBy('hour')
->orderBy('hour')
->get();
return $stats->map(fn ($item) => [
'timestamp' => $item->hour,
'count' => (int) $item->count,
])->toArray();
}
/**
* Get top contributors (users with most uploads).
*/
public function getTopContributors(Event $event, int $limit = 5): array
{
$stats = $event->photos()
->select('guest_name', DB::raw('count(*) as count'), DB::raw('sum(likes_count) as likes'))
->whereNotNull('guest_name')
->groupBy('guest_name')
->orderByDesc('count')
->limit($limit)
->get();
return $stats->map(fn ($item) => [
'name' => $item->guest_name,
'count' => (int) $item->count,
'likes' => (int) $item->likes,
])->toArray();
}
/**
* Get task completion stats.
*/
public function getTaskStats(Event $event, int $limit = 5): array
{
$stats = $event->photos()
->whereNotNull('task_id')
->select('task_id', DB::raw('count(*) as count'))
->groupBy('task_id')
->with('task:id,name,name_translations') // Eager load task name
->orderByDesc('count')
->limit($limit)
->get();
return $stats->map(fn ($item) => [
'task_id' => $item->task_id,
'task_name' => $item->task ? $item->task->getNameForLocale() : 'Unknown Task', // Assuming getNameForLocale exists or similar
'count' => (int) $item->count,
])->toArray();
}
}

View File

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

View File

@@ -126,6 +126,36 @@ class GuestNotificationService
return null; return null;
} }
$photoId = Arr::get($payload, 'photo_id');
if (is_numeric($photoId)) {
$photoId = max(1, (int) $photoId);
} else {
$photoId = null;
}
$photoIds = Arr::get($payload, 'photo_ids');
if (is_array($photoIds)) {
$photoIds = array_values(array_unique(array_filter(array_map(function ($value) {
if (! is_numeric($value)) {
return null;
}
$int = (int) $value;
return $int > 0 ? $int : null;
}, $photoIds))));
$photoIds = array_slice($photoIds, 0, 10);
} else {
$photoIds = [];
}
$count = Arr::get($payload, 'count');
if (is_numeric($count)) {
$count = max(1, min(9999, (int) $count));
} else {
$count = null;
}
$cta = Arr::get($payload, 'cta'); $cta = Arr::get($payload, 'cta');
if (is_array($cta)) { if (is_array($cta)) {
$cta = [ $cta = [
@@ -142,6 +172,9 @@ class GuestNotificationService
$clean = array_filter([ $clean = array_filter([
'cta' => $cta, 'cta' => $cta,
'photo_id' => $photoId,
'photo_ids' => $photoIds,
'count' => $count,
]); ]);
return $clean === [] ? null : $clean; return $clean === [] ? null : $clean;

View File

@@ -17,6 +17,11 @@ class PaddleDiscountService
*/ */
public function createDiscount(Coupon $coupon): array public function createDiscount(Coupon $coupon): array
{ {
$existing = $this->findExistingDiscount($coupon->code);
if ($existing !== null) {
return $existing;
}
$payload = $this->buildDiscountPayload($coupon); $payload = $this->buildDiscountPayload($coupon);
$response = $this->client->post('/discounts', $payload); $response = $this->client->post('/discounts', $payload);
@@ -82,6 +87,35 @@ class PaddleDiscountService
return Arr::get($response, 'data', $response); return Arr::get($response, 'data', $response);
} }
/**
* @return array<string, mixed>|null
*/
protected function findExistingDiscount(?string $code): ?array
{
$normalized = Str::upper(trim((string) $code));
if ($normalized === '') {
return null;
}
$response = $this->client->get('/discounts', [
'code' => $normalized,
'per_page' => 1,
]);
$items = Arr::get($response, 'data', []);
if (! is_array($items) || $items === []) {
return null;
}
$match = Collection::make($items)->first(static function ($item) use ($normalized) {
$codeValue = Str::upper((string) Arr::get($item, 'code', ''));
return $codeValue === $normalized ? $item : null;
});
return is_array($match) ? $match : null;
}
/** /**
* @return array<string, mixed> * @return array<string, mixed>
*/ */

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(); $user = $request->user();
if ($user && in_array($user->role, ['tenant_admin', 'admin', 'super_admin', 'member'], true)) { if ($user && in_array($user->role, ['tenant_admin', 'admin', 'super_admin', 'superadmin', 'member'], true)) {
if ($user->role !== 'super_admin' || (int) $user->tenant_id === (int) $tenantId) { if (! $user->isSuperAdmin() || (int) $user->tenant_id === (int) $tenantId) {
return $user; return $user;
} }
} }
$user = User::query() $user = User::query()
->where('tenant_id', $tenantId) ->where('tenant_id', $tenantId)
->whereIn('role', ['tenant_admin', 'admin', 'member']) ->whereIn('role', ['tenant_admin', 'admin', 'super_admin', 'superadmin', 'member'])
->orderByDesc('email_verified_at') ->orderByDesc('email_verified_at')
->orderBy('id') ->orderBy('id')
->first(); ->first();

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_limit' => (int) env('JOIN_TOKEN_FAILURE_LIMIT', 10),
'failure_decay_minutes' => (int) env('JOIN_TOKEN_FAILURE_DECAY', 5), 'failure_decay_minutes' => (int) env('JOIN_TOKEN_FAILURE_DECAY', 5),
'access_limit' => (int) env('JOIN_TOKEN_ACCESS_LIMIT', 120), 'access_limit' => (int) env('JOIN_TOKEN_ACCESS_LIMIT', 300),
'access_decay_minutes' => (int) env('JOIN_TOKEN_ACCESS_DECAY', 1), 'access_decay_minutes' => (int) env('JOIN_TOKEN_ACCESS_DECAY', 1),
'download_limit' => (int) env('JOIN_TOKEN_DOWNLOAD_LIMIT', 60), 'download_limit' => (int) env('JOIN_TOKEN_DOWNLOAD_LIMIT', 120),
'download_decay_minutes' => (int) env('JOIN_TOKEN_DOWNLOAD_DECAY', 1), 'download_decay_minutes' => (int) env('JOIN_TOKEN_DOWNLOAD_DECAY', 1),
]; ];

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)), 'rate_limit_per_minute' => (int) env('SPARKBOOTH_RATE_LIMIT_PER_MINUTE', env('PHOTOBOOTH_RATE_LIMIT_PER_MINUTE', 20)),
'response_format' => env('SPARKBOOTH_RESPONSE_FORMAT', 'json'), 'response_format' => env('SPARKBOOTH_RESPONSE_FORMAT', 'json'),
], ],
'connect_code' => [
'length' => (int) env('PHOTOBOOTH_CONNECT_CODE_LENGTH', 6),
'expires_minutes' => (int) env('PHOTOBOOTH_CONNECT_CODE_EXPIRES_MINUTES', 10),
],
]; ];

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

@@ -24,13 +24,13 @@ class PackageSeeder extends Seeder
], ],
'type' => PackageType::ENDCUSTOMER, 'type' => PackageType::ENDCUSTOMER,
'price' => 29.00, 'price' => 29.00,
'max_photos' => 300, 'max_photos' => 600,
'max_guests' => 50, 'max_guests' => 100,
'gallery_days' => 14, 'gallery_days' => 180,
'max_tasks' => 30, 'max_tasks' => 30,
'watermark_allowed' => true, 'watermark_allowed' => true,
'branding_allowed' => false, 'branding_allowed' => false,
'features' => ['basic_uploads', 'custom_tasks'], 'features' => ['basic_uploads', 'limited_sharing', 'custom_tasks'],
'paddle_product_id' => 'pro_01k8jcxx2g1vj9snqbga4283ej', 'paddle_product_id' => 'pro_01k8jcxx2g1vj9snqbga4283ej',
'paddle_price_id' => 'pri_01k8jcxx8qktxvqzzv0nkjjj27', 'paddle_price_id' => 'pri_01k8jcxx8qktxvqzzv0nkjjj27',
'description' => <<<TEXT 'description' => <<<TEXT
@@ -57,13 +57,13 @@ TEXT,
], ],
'type' => PackageType::ENDCUSTOMER, 'type' => PackageType::ENDCUSTOMER,
'price' => 59.00, 'price' => 59.00,
'max_photos' => 1000, 'max_photos' => 4000,
'max_guests' => 150, 'max_guests' => 250,
'gallery_days' => 30, 'gallery_days' => 365,
'max_tasks' => 100, 'max_tasks' => 100,
'watermark_allowed' => false, 'watermark_allowed' => false,
'branding_allowed' => true, 'branding_allowed' => true,
'features' => ['basic_uploads', 'custom_branding', 'custom_tasks'], 'features' => ['basic_uploads', 'unlimited_sharing', 'custom_branding', 'custom_tasks', 'live_slideshow'],
'paddle_product_id' => 'pro_01k8jcxwjv4ne8vf9pvd9bye3j', 'paddle_product_id' => 'pro_01k8jcxwjv4ne8vf9pvd9bye3j',
'paddle_price_id' => 'pri_01k8jcxws51pze5xc3vj2ea0yc', 'paddle_price_id' => 'pri_01k8jcxws51pze5xc3vj2ea0yc',
'description' => <<<TEXT 'description' => <<<TEXT
@@ -78,7 +78,7 @@ TEXT,
['title' => 'Gäste', 'value' => '{{max_guests}}'], ['title' => 'Gäste', 'value' => '{{max_guests}}'],
['title' => 'Aufgaben', 'value' => '{{max_tasks}} Fotoaufgaben'], ['title' => 'Aufgaben', 'value' => '{{max_tasks}} Fotoaufgaben'],
['title' => 'Galerie', 'value' => '{{gallery_duration}}'], ['title' => 'Galerie', 'value' => '{{gallery_duration}}'],
['title' => 'Branding', 'value' => 'Eigenes Logo & Wasserzeichen'], ['title' => 'Branding', 'value' => 'Eigenes Logo & Live-Slideshow'],
], ],
], ],
[ [
@@ -89,53 +89,31 @@ TEXT,
'en' => 'Premium', 'en' => 'Premium',
], ],
'type' => PackageType::ENDCUSTOMER, 'type' => PackageType::ENDCUSTOMER,
'price' => 129.00, 'price' => 119.00,
'max_photos' => 3000, 'max_photos' => 10000,
'max_guests' => 500, 'max_guests' => null,
'gallery_days' => 180, 'gallery_days' => 730,
'max_tasks' => 200, 'max_tasks' => 200,
'watermark_allowed' => false, 'watermark_allowed' => false,
'branding_allowed' => true, 'branding_allowed' => true,
'features' => ['basic_uploads', 'custom_branding', 'advanced_analytics', 'live_slideshow', 'priority_support'], 'features' => ['basic_uploads', 'unlimited_sharing', 'custom_branding', 'custom_tasks', 'live_slideshow', 'advanced_analytics', 'priority_support'],
'paddle_product_id' => 'pro_01k8jcxvwp38gay6jj2akjg76s', 'paddle_product_id' => 'pro_01k8jcxvwp38gay6jj2akjg76s',
'paddle_price_id' => 'pri_01k8jcxw5sap4r306wcvc0ephy', 'paddle_price_id' => 'pri_01k8jcxw5sap4r306wcvc0ephy',
'description' => <<<TEXT 'description' => <<<TEXT
Das volle Erlebnis für alle, die keine Kompromisse machen wollen. {{max_photos}} Bilder, {{max_guests}} Gäste, {{gallery_duration}} Galerie-Zugang und {{max_tasks}} Aufgaben dazu kein Wasserzeichen, Live-Slideshow und Premium-Support. Das volle Erlebnis für alle, die keine Kompromisse machen wollen. {{max_photos}} Bilder, unbegrenzt viele Gäste, {{gallery_duration}} Galerie-Zugang und {{max_tasks}} Aufgaben dazu kein Wasserzeichen, Live-Slideshow und Premium-Support.
TEXT, TEXT,
'description_translations' => [ 'description_translations' => [
'de' => 'Das volle Erlebnis für alle, die keine Kompromisse machen wollen. {{max_photos}} Bilder, {{max_guests}} Gäste, {{gallery_duration}} Galerie-Zugang und {{max_tasks}} Aufgaben dazu kein Wasserzeichen, Live-Slideshow und Premium-Support.', 'de' => 'Das volle Erlebnis für alle, die keine Kompromisse machen wollen. {{max_photos}} Bilder, unbegrenzt viele Gäste, {{gallery_duration}} Galerie-Zugang und {{max_tasks}} Aufgaben dazu kein Wasserzeichen, Live-Slideshow und Premium-Support.',
'en' => 'The full experience for anyone who refuses to compromise. {{max_photos}} photos, {{max_guests}} guests, {{gallery_duration}} of gallery access and {{max_tasks}} challenges—no watermark, live slideshow and premium support included.', 'en' => 'The full experience for anyone who refuses to compromise. {{max_photos}} photos, unlimited guests, {{gallery_duration}} of gallery access and {{max_tasks}} challenges—no watermark, live slideshow and premium support included.',
], ],
'description_table' => [ 'description_table' => [
['title' => 'Fotos', 'value' => '{{max_photos}}'], ['title' => 'Fotos', 'value' => '{{max_photos}}'],
['title' => 'Gäste', 'value' => '{{max_guests}}'], ['title' => 'Gäste', 'value' => 'Unbegrenzt'],
['title' => 'Aufgaben', 'value' => '{{max_tasks}} Fotoaufgaben'], ['title' => 'Aufgaben', 'value' => '{{max_tasks}} Fotoaufgaben'],
['title' => 'Galerie', 'value' => '{{gallery_duration}}'], ['title' => 'Galerie', 'value' => '{{gallery_duration}}'],
['title' => 'Extras', 'value' => 'Live-Slideshow & Premium-Support'], ['title' => 'Extras', 'value' => 'Live-Slideshow & Premium-Support'],
], ],
], ],
[
'slug' => 'premium',
'name' => 'Premium',
'name_translations' => [
'de' => 'Premium',
'en' => 'Premium',
],
'type' => PackageType::ENDCUSTOMER,
'price' => 149.00,
'max_photos' => 5000,
'max_guests' => 1000,
'gallery_days' => 180,
'max_tasks' => null,
'watermark_allowed' => true,
'branding_allowed' => false,
'features' => ['basic_uploads', 'unlimited_sharing', 'custom_branding', 'custom_tasks'],
'paddle_product_id' => 'pro_01k8jcxsmxrs45v1qvtpsepn13',
'paddle_price_id' => 'pri_01k8jcxswh74jv8vy5q7a4h70p',
'description' => null,
'description_translations' => null,
'description_table' => [],
],
[ [
'slug' => 's-small-reseller', 'slug' => 's-small-reseller',
'name' => 'Reseller S', 'name' => 'Reseller S',

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; namespace Database\Seeders;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use App\Models\User; use Illuminate\Support\Str;
class SuperAdminSeeder extends Seeder class SuperAdminSeeder extends Seeder
{ {
@@ -12,12 +14,49 @@ class SuperAdminSeeder extends Seeder
{ {
$email = env('ADMIN_EMAIL', 'admin@example.com'); $email = env('ADMIN_EMAIL', 'admin@example.com');
$password = env('ADMIN_PASSWORD', 'ChangeMe123!'); $password = env('ADMIN_PASSWORD', 'ChangeMe123!');
User::updateOrCreate(['email'=>$email], [ $user = User::updateOrCreate(['email' => $email], [
'first_name' => 'Super', 'first_name' => 'Super',
'last_name' => 'Admin', 'last_name' => 'Admin',
'password' => Hash::make($password), 'password' => Hash::make($password),
'role' => 'super_admin', 'role' => 'super_admin',
]); ]);
}
$tenantSlug = env('OWNER_TENANT_SLUG', 'owner-tenant');
$tenantName = env('OWNER_TENANT_NAME', 'Owner Tenant');
$tenant = Tenant::query()->firstOrCreate(
['slug' => $tenantSlug],
[
'name' => $tenantName,
'email' => $email,
'contact_email' => $email,
'user_id' => $user->id,
'is_active' => true,
'is_suspended' => false,
'settings' => [
'contact_email' => $email,
],
],
);
if (! $tenant->slug) {
$tenant->forceFill(['slug' => Str::slug($tenantName)])->save();
} }
if (! $tenant->user_id) {
$tenant->forceFill(['user_id' => $user->id])->save();
}
if (! $tenant->email) {
$tenant->forceFill(['email' => $email])->save();
}
if (! $tenant->contact_email) {
$tenant->forceFill(['contact_email' => $email])->save();
}
if ($user->tenant_id !== $tenant->id) {
$user->forceFill(['tenant_id' => $tenant->id])->save();
}
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{}

View File

@@ -0,0 +1 @@
{}

View File

@@ -0,0 +1 @@
{}

View File

@@ -0,0 +1 @@
{}

View File

@@ -2,6 +2,7 @@
import { authorizedFetch } from './auth/tokens'; import { authorizedFetch } from './auth/tokens';
import { ApiError, emitApiErrorEvent } from './lib/apiError'; import { ApiError, emitApiErrorEvent } from './lib/apiError';
import type { EventLimitSummary } from './lib/limitWarnings'; import type { EventLimitSummary } from './lib/limitWarnings';
export type { EventLimitSummary };
import i18n from './i18n'; import i18n from './i18n';
type JsonValue = Record<string, unknown>; type JsonValue = Record<string, unknown>;
@@ -330,6 +331,8 @@ export type TenantOnboardingStatus = {
admin_app_opened_at?: string | null; admin_app_opened_at?: string | null;
primary_event_id?: number | string | null; primary_event_id?: number | string | null;
selected_packages?: unknown; selected_packages?: unknown;
summary_seen_package_id?: number | null;
summary_seen_at?: string | null;
dismissed_at?: string | null; dismissed_at?: string | null;
completed_at?: string | null; completed_at?: string | null;
branding_completed?: boolean; branding_completed?: boolean;
@@ -2452,6 +2455,35 @@ 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; checkout_session_id?: string }> {
const response = await authorizedFetch('/api/v1/tenant/packages/paddle-checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
package_id: packageId,
success_url: urls?.success_url,
return_url: urls?.return_url,
}),
});
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 }> { export async function createTenantBillingPortalSession(): Promise<{ url: string }> {
const response = await authorizedFetch('/api/v1/tenant/billing/portal', { const response = await authorizedFetch('/api/v1/tenant/billing/portal', {
method: 'POST', method: 'POST',
@@ -2543,22 +2575,7 @@ export async function assignFreeTenantPackage(packageId: number): Promise<void>
await jsonOrThrow(response, 'Failed to assign free package'); await jsonOrThrow(response, 'Failed to assign free package');
} }
export async function createTenantPaddleCheckout(packageId: number): Promise<{ checkout_url: string }> {
const response = await authorizedFetch('/api/v1/tenant/packages/paddle-checkout', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ package_id: packageId }),
});
const data = await jsonOrThrow<{ checkout_url: string }>(response, 'Failed to create Paddle checkout');
if (!data.checkout_url) {
throw new Error('Missing Paddle checkout URL');
}
return { checkout_url: data.checkout_url };
}
export async function recordCreditPurchase(payload: { export async function recordCreditPurchase(payload: {
package_id: string; package_id: string;
@@ -2854,6 +2871,35 @@ export async function removeEventMember(eventIdentifier: number | string, member
throw new Error('Failed to remove member'); throw new Error('Failed to remove member');
} }
} }
export type AnalyticsTimelinePoint = {
timestamp: string;
count: number;
};
export type AnalyticsContributor = {
name: string;
count: number;
likes: number;
};
export type AnalyticsTaskStat = {
task_id: number;
task_name: string;
count: number;
};
export type EventAnalytics = {
timeline: AnalyticsTimelinePoint[];
contributors: AnalyticsContributor[];
tasks: AnalyticsTaskStat[];
};
export async function getEventAnalytics(slug: string): Promise<EventAnalytics> {
const response = await authorizedFetch(`${eventEndpoint(slug)}/analytics`);
const data = await jsonOrThrow<EventAnalytics>(response, 'Failed to load analytics');
return data;
}
type CacheEntry<T> = { type CacheEntry<T> = {
value?: T; value?: T;
expiresAt: number; expiresAt: number;

View File

@@ -15,6 +15,7 @@ export const ADMIN_SETTINGS_PATH = adminPath('/mobile/settings');
export const ADMIN_PROFILE_PATH = adminPath('/mobile/profile'); export const ADMIN_PROFILE_PATH = adminPath('/mobile/profile');
export const ADMIN_FAQ_PATH = adminPath('/mobile/help'); export const ADMIN_FAQ_PATH = adminPath('/mobile/help');
export const ADMIN_BILLING_PATH = adminPath('/mobile/billing'); export const ADMIN_BILLING_PATH = adminPath('/mobile/billing');
export const ADMIN_PACKAGE_SHOP_PATH = adminPath('/mobile/billing/shop');
export const ADMIN_DATA_EXPORTS_PATH = adminPath('/mobile/exports'); export const ADMIN_DATA_EXPORTS_PATH = adminPath('/mobile/exports');
export const ADMIN_PHOTOS_PATH = adminPath('/mobile/uploads'); export const ADMIN_PHOTOS_PATH = adminPath('/mobile/uploads');
export const ADMIN_LIVE_PATH = adminPath('/mobile/dashboard'); export const ADMIN_LIVE_PATH = adminPath('/mobile/dashboard');

View File

@@ -34,6 +34,27 @@
"more": "Weitere Einträge konnten nicht geladen werden.", "more": "Weitere Einträge konnten nicht geladen werden.",
"portal": "Paddle-Portal konnte nicht geöffnet werden." "portal": "Paddle-Portal konnte nicht geöffnet werden."
}, },
"checkoutSuccess": "Checkout abgeschlossen. Dein Paket wird in Kürze aktiviert.",
"checkoutCancelled": "Checkout wurde abgebrochen.",
"checkoutActivated": "Dein Paket ist jetzt aktiv.",
"checkoutPendingTitle": "Paket wird aktiviert",
"checkoutPendingBody": "Das kann ein paar Minuten dauern. Wir aktualisieren den Status, sobald das Paket aktiv ist.",
"checkoutPendingBadge": "Ausstehend",
"checkoutPendingRefresh": "Aktualisieren",
"checkoutPendingDismiss": "Ausblenden",
"checkoutFailedTitle": "Checkout fehlgeschlagen",
"checkoutFailedBody": "Die Zahlung wurde nicht abgeschlossen. Du kannst es erneut versuchen oder den Support kontaktieren.",
"checkoutFailedBadge": "Fehlgeschlagen",
"checkoutFailedRetry": "Erneut versuchen",
"checkoutFailedDismiss": "Ausblenden",
"checkoutActionTitle": "Aktion erforderlich",
"checkoutActionBody": "Schließe die Zahlung ab, um das Paket zu aktivieren.",
"checkoutActionBadge": "Aktion nötig",
"checkoutActionButton": "Checkout fortsetzen",
"checkoutFailureReasons": {
"paddle_failed": "Die Zahlung wurde abgelehnt.",
"paddle_cancelled": "Der Checkout wurde abgebrochen."
},
"sections": { "sections": {
"invoices": { "invoices": {
"title": "Rechnungen & Zahlungen", "title": "Rechnungen & Zahlungen",
@@ -176,6 +197,8 @@
}, },
"common": { "common": {
"all": "Alle", "all": "Alle",
"anonymous": "Anonym",
"error": "Etwas ist schiefgelaufen",
"loadMore": "Mehr laden", "loadMore": "Mehr laden",
"processing": "Verarbeite …", "processing": "Verarbeite …",
"select": "Auswählen", "select": "Auswählen",
@@ -1704,7 +1727,7 @@
"fields": { "fields": {
"name": { "name": {
"label": "Eventname", "label": "Eventname",
"placeholder": "z. B. Sommerfest 2025", "placeholder": "z. B. Hochzeit von Franziska & Florian",
"help": "Die Kennung und Event-URL werden automatisch aus dem Namen generiert.", "help": "Die Kennung und Event-URL werden automatisch aus dem Namen generiert.",
"fallback": "Event" "fallback": "Event"
}, },
@@ -2061,6 +2084,47 @@
"storageUnprotected": "Nicht geschützt", "storageUnprotected": "Nicht geschützt",
"storageAction": "Jetzt schützen" "storageAction": "Jetzt schützen"
}, },
"packageSummary": {
"title": "Deine Paketübersicht",
"fallbackTitle": "Paketübersicht",
"subtitle": "Hier ist dein aktuelles Paket auf einen Blick.",
"limitPhotos": "Fotos",
"limitGuests": "Gäste",
"limitDays": "Galerietage",
"limitsTitle": "Limits",
"remaining": "Verbleibende Events",
"purchased": "Gekauft",
"expires": "Läuft ab",
"unlimited": "Unbegrenzt",
"unknown": "Unbekannt",
"featuresTitle": "Enthaltene Features",
"feature": {
"priority_support": "Priority Support",
"reseller_dashboard": "Reseller-Dashboard",
"custom_domain": "Eigene Domain",
"custom_branding": "Benutzerdefiniertes Branding",
"custom_tasks": "Individuelle Aufgaben",
"unlimited_sharing": "Unbegrenztes Sharing",
"analytics": "Analytics",
"advanced_reporting": "Erweitertes Reporting",
"live_slideshow": "Live-Slideshow",
"basic_uploads": "Gäste-Uploads",
"team_management": "Team-Management",
"moderation_tools": "Moderations-Tools",
"prints": "Print-Uploads",
"photo_likes_enabled": "Foto-Likes",
"event_checklist": "Event-Checkliste",
"advanced_analytics": "Erweiterte Analytics",
"branding_allowed": "Branding",
"watermark_allowed": "Wasserzeichen"
},
"hint": "Im Billing kannst du dein Paket jederzeit prüfen oder upgraden.",
"continue": "Weiter zum Event-Setup",
"dismiss": "Schließen",
"bannerTitle": "Deine Paketübersicht",
"bannerSubtitle": "{{name}} ist aktiv. Prüfe Limits & Features.",
"bannerCta": "Ansehen"
},
"pickEvent": "Event auswählen", "pickEvent": "Event auswählen",
"status": { "status": {
"published": "Live", "published": "Live",
@@ -2664,14 +2728,27 @@
"mobileBilling": { "mobileBilling": {
"packageFallback": "Paket", "packageFallback": "Paket",
"remainingEvents": "{{count}} Events", "remainingEvents": "{{count}} Events",
"remainingEventsOf": "{{remaining}} von {{limit}} Events übrig",
"remainingEventsZero": "Keine Events mehr verfügbar",
"eventsCreated": "{{used}} von {{limit}} Events angelegt",
"openEvent": "Event öffnen", "openEvent": "Event öffnen",
"details": {
"limitsTitle": "Limits",
"featuresTitle": "Features"
},
"usage": { "usage": {
"events": "Events", "events": "Events",
"guests": "Gäste", "guests": "Gäste",
"photos": "Fotos", "photos": "Fotos",
"gallery": "Galerietage",
"value": "{{used}} / {{limit}}", "value": "{{used}} / {{limit}}",
"limit": "Limit {{limit}}", "limit": "Limit {{limit}}",
"remaining": "Verbleibend {{count}}" "remaining": "Verbleibend {{count}}",
"remainingOf": "{{remaining}} von {{limit}} übrig",
"statusWarning": "Knapp",
"statusDanger": "Limit erreicht",
"ctaWarning": "Mehr Kapazität sichern",
"ctaDanger": "Paket upgraden"
}, },
"status": { "status": {
"completed": "Abgeschlossen", "completed": "Abgeschlossen",
@@ -2684,6 +2761,13 @@
"days": "+{{count}} Tage" "days": "+{{count}} Tage"
} }
}, },
"packageLimits": {
"max_photos": "Fotos",
"max_guests": "Gäste",
"max_tasks": "Aufgaben",
"gallery_days": "Galerietage",
"max_events_per_year": "Events pro Jahr"
},
"mobileEvents": { "mobileEvents": {
"edit": "Event bearbeiten" "edit": "Event bearbeiten"
}, },
@@ -2767,7 +2851,8 @@
"title": "Datenexporte", "title": "Datenexporte",
"request": { "request": {
"title": "Exportanfrage", "title": "Exportanfrage",
"hint": "Exportiere Mandantendaten oder ein einzelnes Event-Archiv." "hint": "Exportiere Mandantendaten oder ein einzelnes Event-Archiv.",
"progress": "Export läuft. Die Liste wird automatisch aktualisiert."
}, },
"fields": { "fields": {
"scope": "Umfang", "scope": "Umfang",
@@ -2809,5 +2894,104 @@
"failed": "Export fehlgeschlagen.", "failed": "Export fehlgeschlagen.",
"download": "Download fehlgeschlagen." "download": "Download fehlgeschlagen."
} }
},
"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."
},
"shop": {
"title": "Paket upgraden",
"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": {
"photos": "{{count}} Fotos",
"photos_one": "{{count}} Foto",
"photos_other": "{{count}} Fotos",
"unlimitedPhotos": "Unbegrenzte Fotos",
"days": "{{count}} Tage Galerie",
"days_one": "{{count}} Tag Galerie",
"days_other": "{{count}} Tage Galerie"
},
"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": {
"active": "Aktives Paket",
"owned": "Gekauft",
"remaining": "Noch {{count}} Events",
"remaining_one": "Noch {{count}} Event",
"remaining_other": "Noch {{count}} Events"
},
"badges": {
"recommended": "Empfohlen",
"active": "Aktiv",
"upgrade": "Upgrade",
"downgrade": "Downgrade"
},
"confirmTitle": "Kauf bestätigen",
"confirmSubtitle": "Du upgradest auf:",
"legalTitle": "Rechtliches",
"legal": {
"agb": "Ich stimme den AGB und Datenschutzbestimmungen zu.",
"withdrawal": "Ich stimme zu, dass die Ausführung des Vertrags sofort beginnt und ich mein Widerrufsrecht verliere."
},
"processing": "Verarbeitung...",
"payNow": "Jetzt zahlen",
"errors": {
"checkout": "Checkout fehlgeschlagen"
},
"selectDisabled": "Nicht verfügbar"
} }
} }

View File

@@ -15,7 +15,8 @@
"description": "Mit nur wenigen Schritten führst du deine Gäste durch ein magisches Fotoabenteuer inklusive Storytelling, Aufgaben und moderierter Galerie.", "description": "Mit nur wenigen Schritten führst du deine Gäste durch ein magisches Fotoabenteuer inklusive Storytelling, Aufgaben und moderierter Galerie.",
"primary": { "primary": {
"label": "Pakete entdecken", "label": "Pakete entdecken",
"button": "Pakete entdecken" "button": "Pakete entdecken",
"billing": "Zum Billing"
}, },
"secondary": { "secondary": {
"label": "Events anzeigen", "label": "Events anzeigen",
@@ -246,7 +247,8 @@
"cta": { "cta": {
"heading": "Bereit für dein erstes Event?", "heading": "Bereit für dein erstes Event?",
"description": "Du wechselst jetzt in den Event-Manager. Dort kannst du Tasks zuweisen, Mitglieder einladen und die Gästegalerie testen. Keine Sorge: Du kannst jederzeit zur Welcome Journey zurückkehren.", "description": "Du wechselst jetzt in den Event-Manager. Dort kannst du Tasks zuweisen, Mitglieder einladen und die Gästegalerie testen. Keine Sorge: Du kannst jederzeit zur Welcome Journey zurückkehren.",
"button": "Event erstellen" "button": "Event erstellen",
"billing": "Zum Billing"
}, },
"actions": { "actions": {
"back": { "back": {

View File

@@ -34,6 +34,27 @@
"more": "Unable to load more entries.", "more": "Unable to load more entries.",
"portal": "Unable to open the Paddle portal." "portal": "Unable to open the Paddle portal."
}, },
"checkoutSuccess": "Checkout completed. Your package will activate shortly.",
"checkoutCancelled": "Checkout was cancelled.",
"checkoutActivated": "Your package is now active.",
"checkoutPendingTitle": "Activating your package",
"checkoutPendingBody": "This can take a few minutes. We will update this screen once the package is active.",
"checkoutPendingBadge": "Pending",
"checkoutPendingRefresh": "Refresh",
"checkoutPendingDismiss": "Dismiss",
"checkoutFailedTitle": "Checkout failed",
"checkoutFailedBody": "The payment did not complete. You can try again or contact support.",
"checkoutFailedBadge": "Failed",
"checkoutFailedRetry": "Try again",
"checkoutFailedDismiss": "Dismiss",
"checkoutActionTitle": "Action required",
"checkoutActionBody": "Complete your payment to activate the package.",
"checkoutActionBadge": "Action needed",
"checkoutActionButton": "Continue checkout",
"checkoutFailureReasons": {
"paddle_failed": "The payment was declined.",
"paddle_cancelled": "The checkout was cancelled."
},
"sections": { "sections": {
"invoices": { "invoices": {
"title": "Invoices & payments", "title": "Invoices & payments",
@@ -172,6 +193,8 @@
}, },
"common": { "common": {
"all": "All", "all": "All",
"anonymous": "Anonymous",
"error": "Something went wrong",
"loadMore": "Load more", "loadMore": "Load more",
"processing": "Processing…", "processing": "Processing…",
"select": "Select", "select": "Select",
@@ -2065,6 +2088,47 @@
"storageUnprotected": "Not protected", "storageUnprotected": "Not protected",
"storageAction": "Protect now" "storageAction": "Protect now"
}, },
"packageSummary": {
"title": "Your package summary",
"fallbackTitle": "Package summary",
"subtitle": "Here is a quick overview of your active package.",
"limitPhotos": "Photos",
"limitGuests": "Guests",
"limitDays": "Gallery days",
"limitsTitle": "Limits",
"remaining": "Remaining events",
"purchased": "Purchased",
"expires": "Expires",
"unlimited": "Unlimited",
"unknown": "Unknown",
"featuresTitle": "Included features",
"feature": {
"priority_support": "Priority support",
"reseller_dashboard": "Reseller dashboard",
"custom_domain": "Custom domain",
"custom_branding": "Custom branding",
"custom_tasks": "Custom tasks",
"unlimited_sharing": "Unlimited sharing",
"analytics": "Analytics",
"advanced_reporting": "Advanced reporting",
"live_slideshow": "Live slideshow",
"basic_uploads": "Guest uploads",
"team_management": "Team management",
"moderation_tools": "Moderation tools",
"prints": "Print uploads",
"photo_likes_enabled": "Photo likes",
"event_checklist": "Event checklist",
"advanced_analytics": "Advanced analytics",
"branding_allowed": "Branding",
"watermark_allowed": "Watermarks"
},
"hint": "You can revisit billing any time to review or upgrade your package.",
"continue": "Continue to event setup",
"dismiss": "Close",
"bannerTitle": "Your package summary",
"bannerSubtitle": "{{name}} is active. Review limits & features.",
"bannerCta": "View"
},
"pickEvent": "Select an event", "pickEvent": "Select an event",
"status": { "status": {
"published": "Live", "published": "Live",
@@ -2668,14 +2732,27 @@
"mobileBilling": { "mobileBilling": {
"packageFallback": "Package", "packageFallback": "Package",
"remainingEvents": "{{count}} events", "remainingEvents": "{{count}} events",
"remainingEventsOf": "{{remaining}} of {{limit}} events remaining",
"remainingEventsZero": "No events remaining",
"eventsCreated": "{{used}} of {{limit}} events created",
"openEvent": "Open event", "openEvent": "Open event",
"details": {
"limitsTitle": "Limits",
"featuresTitle": "Features"
},
"usage": { "usage": {
"events": "Events", "events": "Events",
"guests": "Guests", "guests": "Guests",
"photos": "Photos", "photos": "Photos",
"gallery": "Gallery days",
"value": "{{used}} / {{limit}}", "value": "{{used}} / {{limit}}",
"limit": "Limit {{limit}}", "limit": "Limit {{limit}}",
"remaining": "Remaining {{count}}" "remaining": "Remaining {{count}}",
"remainingOf": "{{remaining}} of {{limit}} remaining",
"statusWarning": "Low",
"statusDanger": "Limit reached",
"ctaWarning": "Secure more capacity",
"ctaDanger": "Upgrade package"
}, },
"status": { "status": {
"completed": "Completed", "completed": "Completed",
@@ -2688,6 +2765,13 @@
"days": "+{{count}} days" "days": "+{{count}} days"
} }
}, },
"packageLimits": {
"max_photos": "Photos",
"max_guests": "Guests",
"max_tasks": "Tasks",
"gallery_days": "Gallery days",
"max_events_per_year": "Events per year"
},
"mobileEvents": { "mobileEvents": {
"edit": "Edit event" "edit": "Edit event"
}, },
@@ -2771,7 +2855,8 @@
"title": "Data exports", "title": "Data exports",
"request": { "request": {
"title": "Export request", "title": "Export request",
"hint": "Export tenant data or a specific event archive." "hint": "Export tenant data or a specific event archive.",
"progress": "Export is running. This list refreshes automatically."
}, },
"fields": { "fields": {
"scope": "Scope", "scope": "Scope",
@@ -2813,5 +2898,104 @@
"failed": "Export failed.", "failed": "Export failed.",
"download": "Download failed." "download": "Download failed."
} }
},
"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."
},
"shop": {
"title": "Upgrade Package",
"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": {
"photos": "{{count}} Photos",
"photos_one": "{{count}} Photo",
"photos_other": "{{count}} Photos",
"unlimitedPhotos": "Unlimited Photos",
"days": "{{count}} Days Gallery",
"days_one": "{{count}} Day Gallery",
"days_other": "{{count}} Days Gallery"
},
"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": {
"active": "Active Plan",
"owned": "Purchased",
"remaining": "{{count}} Events left",
"remaining_one": "{{count}} Event left",
"remaining_other": "{{count}} Events left"
},
"badges": {
"recommended": "Recommended",
"active": "Active",
"upgrade": "Upgrade",
"downgrade": "Downgrade"
},
"confirmTitle": "Confirm Purchase",
"confirmSubtitle": "You are upgrading to:",
"legalTitle": "Terms & Conditions",
"legal": {
"agb": "I agree to the Terms and Conditions and Privacy Policy.",
"withdrawal": "I agree that the contract execution begins immediately and I lose my right of withdrawal."
},
"processing": "Processing...",
"payNow": "Pay Now",
"errors": {
"checkout": "Checkout failed"
},
"selectDisabled": "Not available"
} }
} }

View File

@@ -15,7 +15,8 @@
"description": "In just a few steps you guide guests through a magical photo journey complete with storytelling, tasks, and a moderated gallery.", "description": "In just a few steps you guide guests through a magical photo journey complete with storytelling, tasks, and a moderated gallery.",
"primary": { "primary": {
"label": "Explore packages", "label": "Explore packages",
"button": "Explore packages" "button": "Explore packages",
"billing": "Open billing"
}, },
"secondary": { "secondary": {
"label": "View events", "label": "View events",
@@ -246,7 +247,8 @@
"cta": { "cta": {
"heading": "Ready for your first event?", "heading": "Ready for your first event?",
"description": "You're switching to the event manager. Assign tasks, invite members, and test the gallery. You can always return to the welcome journey.", "description": "You're switching to the event manager. Assign tasks, invite members, and test the gallery. You can always return to the welcome journey.",
"button": "Create event" "button": "Create event",
"billing": "Open billing"
}, },
"actions": { "actions": {
"back": { "back": {

View File

@@ -14,7 +14,7 @@ export type EventTabCounts = Partial<{
tasks: number; tasks: number;
}>; }>;
type Translator = (key: string, fallback: string) => string; type Translator = any;
export function buildEventTabs(event: TenantEvent, translate: Translator, counts: EventTabCounts = {}) { export function buildEventTabs(event: TenantEvent, translate: Translator, counts: EventTabCounts = {}) {
if (!event.slug) { if (!event.slug) {

View File

@@ -12,15 +12,30 @@ import {
createTenantBillingPortalSession, createTenantBillingPortalSession,
getTenantPackagesOverview, getTenantPackagesOverview,
getTenantPaddleTransactions, getTenantPaddleTransactions,
getTenantPackageCheckoutStatus,
TenantPackageSummary, TenantPackageSummary,
PaddleTransactionSummary, PaddleTransactionSummary,
} from '../api'; } from '../api';
import { TenantAddonHistoryEntry, getTenantAddonHistory } from '../api'; import { TenantAddonHistoryEntry, getTenantAddonHistory } from '../api';
import { getApiErrorMessage } from '../lib/apiError'; import { getApiErrorMessage } from '../lib/apiError';
import { ADMIN_EVENT_VIEW_PATH, adminPath } from '../constants'; import { ADMIN_EVENT_VIEW_PATH, adminPath } from '../constants';
import { buildPackageUsageMetrics, PackageUsageMetric, usagePercent } from './billingUsage'; import { buildPackageUsageMetrics, getUsageState, PackageUsageMetric, usagePercent } from './billingUsage';
import { useBackNavigation } from './hooks/useBackNavigation'; import { useBackNavigation } from './hooks/useBackNavigation';
import { useAdminTheme } from './theme'; import { useAdminTheme } from './theme';
import {
collectPackageFeatures,
formatEventUsage,
getPackageFeatureLabel,
getPackageLimitEntries,
} from './lib/packageSummary';
import {
PendingCheckout,
loadPendingCheckout,
shouldClearPendingCheckout,
storePendingCheckout,
} from './lib/billingCheckout';
const CHECKOUT_POLL_INTERVAL_MS = 10000;
export default function MobileBillingPage() { export default function MobileBillingPage() {
const { t } = useTranslation('management'); const { t } = useTranslation('management');
@@ -34,6 +49,11 @@ export default function MobileBillingPage() {
const [loading, setLoading] = React.useState(true); const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null); const [error, setError] = React.useState<string | null>(null);
const [portalBusy, setPortalBusy] = React.useState(false); const [portalBusy, setPortalBusy] = React.useState(false);
const [pendingCheckout, setPendingCheckout] = React.useState<PendingCheckout | null>(() => loadPendingCheckout());
const [checkoutStatus, setCheckoutStatus] = React.useState<string | null>(null);
const [checkoutStatusReason, setCheckoutStatusReason] = React.useState<string | null>(null);
const [checkoutActionUrl, setCheckoutActionUrl] = React.useState<string | null>(null);
const lastCheckoutStatusRef = React.useRef<string | null>(null);
const packagesRef = React.useRef<HTMLDivElement | null>(null); const packagesRef = React.useRef<HTMLDivElement | null>(null);
const invoicesRef = React.useRef<HTMLDivElement | null>(null); const invoicesRef = React.useRef<HTMLDivElement | null>(null);
const supportEmail = 'support@fotospiel.de'; const supportEmail = 'support@fotospiel.de';
@@ -89,6 +109,11 @@ export default function MobileBillingPage() {
} }
}, [portalBusy, t]); }, [portalBusy, t]);
const persistPendingCheckout = React.useCallback((next: PendingCheckout | null) => {
setPendingCheckout(next);
storePendingCheckout(next);
}, []);
React.useEffect(() => { React.useEffect(() => {
void load(); void load();
}, [load]); }, [load]);
@@ -102,6 +127,115 @@ export default function MobileBillingPage() {
} }
}, [location.hash, loading]); }, [location.hash, loading]);
React.useEffect(() => {
if (!location.search) {
return;
}
const params = new URLSearchParams(location.search);
const checkout = params.get('checkout');
const packageId = params.get('package_id');
if (!checkout) {
return;
}
if (checkout === 'success') {
const packageIdNumber = packageId ? Number(packageId) : null;
const existingSessionId = pendingCheckout?.checkoutSessionId ?? null;
const pendingEntry = {
packageId: Number.isFinite(packageIdNumber) ? packageIdNumber : null,
checkoutSessionId: existingSessionId,
startedAt: Date.now(),
};
persistPendingCheckout(pendingEntry);
toast.success(t('billing.checkoutSuccess', 'Checkout completed. Your package will activate shortly.'));
} else if (checkout === 'cancel') {
persistPendingCheckout(null);
toast(t('billing.checkoutCancelled', 'Checkout was cancelled.'));
}
params.delete('checkout');
params.delete('package_id');
navigate(
{
pathname: location.pathname,
search: params.toString(),
hash: location.hash,
},
{ replace: true },
);
}, [location.hash, location.pathname, location.search, navigate, pendingCheckout?.checkoutSessionId, persistPendingCheckout, t]);
React.useEffect(() => {
if (!pendingCheckout) {
return;
}
if (shouldClearPendingCheckout(pendingCheckout, activePackage?.package_id ?? null)) {
persistPendingCheckout(null);
}
}, [activePackage?.package_id, pendingCheckout, persistPendingCheckout]);
React.useEffect(() => {
if (!pendingCheckout?.checkoutSessionId) {
setCheckoutStatus(null);
setCheckoutStatusReason(null);
setCheckoutActionUrl(null);
lastCheckoutStatusRef.current = null;
return;
}
let active = true;
let intervalId: ReturnType<typeof setInterval> | null = null;
const poll = async () => {
try {
const result = await getTenantPackageCheckoutStatus(pendingCheckout.checkoutSessionId as string);
if (!active) {
return;
}
setCheckoutStatus(result.status);
setCheckoutStatusReason(result.reason ?? null);
setCheckoutActionUrl(typeof result.checkout_url === 'string' ? result.checkout_url : null);
const lastStatus = lastCheckoutStatusRef.current;
lastCheckoutStatusRef.current = result.status;
if (result.status === 'completed') {
persistPendingCheckout(null);
if (lastStatus !== 'completed') {
toast.success(t('billing.checkoutActivated', 'Your package is now active.'));
}
await load();
if (intervalId) {
clearInterval(intervalId);
}
return;
}
if (result.status === 'failed' || result.status === 'cancelled') {
if (intervalId) {
clearInterval(intervalId);
}
}
} catch {
if (!active) {
return;
}
}
};
void poll();
intervalId = setInterval(poll, CHECKOUT_POLL_INTERVAL_MS);
return () => {
active = false;
if (intervalId) {
clearInterval(intervalId);
}
};
}, [load, pendingCheckout?.checkoutSessionId, persistPendingCheckout, t]);
return ( return (
<MobileShell <MobileShell
activeTab="profile" activeTab="profile"
@@ -121,6 +255,109 @@ export default function MobileBillingPage() {
<CTAButton label={t('billing.actions.refresh', 'Refresh')} tone="ghost" onPress={load} /> <CTAButton label={t('billing.actions.refresh', 'Refresh')} tone="ghost" onPress={load} />
</MobileCard> </MobileCard>
) : null} ) : null}
{pendingCheckout && (checkoutStatus === 'failed' || checkoutStatus === 'cancelled') ? (
<MobileCard borderColor={danger} backgroundColor="$red1" space="$2">
<XStack alignItems="center" justifyContent="space-between">
<YStack space="$0.5" flex={1}>
<Text fontSize="$sm" fontWeight="800" color={danger}>
{t('billing.checkoutFailedTitle', 'Checkout failed')}
</Text>
<Text fontSize="$xs" color={muted}>
{t(
'billing.checkoutFailedBody',
'The payment did not complete. You can try again or contact support.'
)}
</Text>
{checkoutStatusReason ? (
<Text fontSize="$xs" color={muted}>
{t(`billing.checkoutFailureReasons.${checkoutStatusReason}`, checkoutStatusReason)}
</Text>
) : null}
</YStack>
<PillBadge tone="danger">
{t('billing.checkoutFailedBadge', 'Failed')}
</PillBadge>
</XStack>
<XStack space="$2">
<CTAButton
label={t('billing.checkoutFailedRetry', 'Try again')}
onPress={() => navigate(adminPath('/mobile/billing/shop'))}
fullWidth={false}
/>
<CTAButton
label={t('billing.checkoutFailedDismiss', 'Dismiss')}
tone="ghost"
onPress={() => persistPendingCheckout(null)}
fullWidth={false}
/>
</XStack>
</MobileCard>
) : null}
{pendingCheckout && checkoutStatus === 'requires_customer_action' ? (
<MobileCard borderColor={accentSoft} backgroundColor={accentSoft} space="$2">
<XStack alignItems="center" justifyContent="space-between">
<YStack space="$0.5" flex={1}>
<Text fontSize="$sm" fontWeight="800" color={textStrong}>
{t('billing.checkoutActionTitle', 'Action required')}
</Text>
<Text fontSize="$xs" color={muted}>
{t('billing.checkoutActionBody', 'Complete your payment to activate the package.')}
</Text>
</YStack>
<PillBadge tone="warning">
{t('billing.checkoutActionBadge', 'Action needed')}
</PillBadge>
</XStack>
<XStack space="$2">
<CTAButton
label={t('billing.checkoutActionButton', 'Continue checkout')}
onPress={() => {
if (checkoutActionUrl && typeof window !== 'undefined') {
window.open(checkoutActionUrl, '_blank', 'noopener');
return;
}
navigate(adminPath('/mobile/billing/shop'));
}}
fullWidth={false}
/>
<CTAButton
label={t('billing.checkoutFailedDismiss', 'Dismiss')}
tone="ghost"
onPress={() => persistPendingCheckout(null)}
fullWidth={false}
/>
</XStack>
</MobileCard>
) : null}
{pendingCheckout && checkoutStatus !== 'failed' && checkoutStatus !== 'cancelled' && checkoutStatus !== 'requires_customer_action' ? (
<MobileCard borderColor={accentSoft} backgroundColor={accentSoft} space="$2">
<XStack alignItems="center" justifyContent="space-between">
<YStack space="$0.5" flex={1}>
<Text fontSize="$sm" fontWeight="800" color={textStrong}>
{t('billing.checkoutPendingTitle', 'Activating your package')}
</Text>
<Text fontSize="$xs" color={muted}>
{t(
'billing.checkoutPendingBody',
'This can take a few minutes. We will update this screen once the package is active.'
)}
</Text>
</YStack>
<PillBadge tone="warning">
{t('billing.checkoutPendingBadge', 'Pending')}
</PillBadge>
</XStack>
<XStack space="$2">
<CTAButton label={t('billing.checkoutPendingRefresh', 'Refresh')} onPress={load} fullWidth={false} />
<CTAButton
label={t('billing.checkoutPendingDismiss', 'Dismiss')}
tone="ghost"
onPress={() => persistPendingCheckout(null)}
fullWidth={false}
/>
</XStack>
</MobileCard>
) : null}
<MobileCard space="$2" ref={packagesRef as any}> <MobileCard space="$2" ref={packagesRef as any}>
<XStack alignItems="center" space="$2"> <XStack alignItems="center" space="$2">
@@ -148,6 +385,8 @@ export default function MobileBillingPage() {
pkg={activePackage} pkg={activePackage}
label={t('billing.sections.packages.card.statusActive', 'Aktiv')} label={t('billing.sections.packages.card.statusActive', 'Aktiv')}
isActive isActive
onOpenPortal={openPortal}
portalBusy={portalBusy}
/> />
) : null} ) : null}
{packages {packages
@@ -217,7 +456,6 @@ export default function MobileBillingPage() {
))} ))}
</YStack> </YStack>
)} )}
{null}
</MobileCard> </MobileCard>
<MobileCard space="$2"> <MobileCard space="$2">
@@ -245,18 +483,53 @@ export default function MobileBillingPage() {
))} ))}
</YStack> </YStack>
)} )}
{null}
</MobileCard> </MobileCard>
</MobileShell> </MobileShell>
); );
} }
function PackageCard({ pkg, label, isActive = false }: { pkg: TenantPackageSummary; label?: string; isActive?: boolean }) { function PackageCard({
pkg,
label,
isActive = false,
onOpenPortal,
portalBusy,
}: {
pkg: TenantPackageSummary;
label?: string;
isActive?: boolean;
onOpenPortal?: () => void;
portalBusy?: boolean;
}) {
const { t } = useTranslation('management'); const { t } = useTranslation('management');
const { border, primary, accentSoft, textStrong, muted } = useAdminTheme(); const { border, primary, accentSoft, textStrong, muted } = useAdminTheme();
const remaining = pkg.remaining_events ?? (pkg.package_limits?.max_events_per_year as number | undefined) ?? 0; const limits = (pkg.package_limits ?? null) as Record<string, unknown> | null;
const limitMaxEvents = typeof limits?.max_events_per_year === 'number' ? (limits?.max_events_per_year as number) : null;
const remaining = pkg.remaining_events ?? limitMaxEvents ?? 0;
const remainingText =
remaining === 0
? t('mobileBilling.remainingEventsZero', 'No events remaining')
: limitMaxEvents
? t('mobileBilling.remainingEventsOf', '{{remaining}} of {{limit}} events remaining', {
remaining,
limit: limitMaxEvents,
})
: t('mobileBilling.remainingEvents', '{{count}} events', { count: remaining });
const expires = pkg.expires_at ? formatDate(pkg.expires_at) : null; const expires = pkg.expires_at ? formatDate(pkg.expires_at) : null;
const usageMetrics = buildPackageUsageMetrics(pkg); const usageMetrics = buildPackageUsageMetrics(pkg);
const usageStates = usageMetrics.map((metric) => getUsageState(metric));
const hasUsageWarning = usageStates.some((state) => state === 'warning' || state === 'danger');
const isDanger = usageStates.includes('danger');
const limitEntries = getPackageLimitEntries(limits, t, {
remainingEvents: pkg.remaining_events ?? null,
usedEvents: typeof pkg.used_events === 'number' ? pkg.used_events : null,
});
const featureKeys = collectPackageFeatures(pkg);
const eventUsageText = formatEventUsage(
typeof pkg.used_events === 'number' ? pkg.used_events : null,
limitMaxEvents,
t
);
return ( return (
<MobileCard <MobileCard
borderColor={isActive ? primary : border} borderColor={isActive ? primary : border}
@@ -276,15 +549,50 @@ function PackageCard({ pkg, label, isActive = false }: { pkg: TenantPackageSumma
</Text> </Text>
) : null} ) : null}
<XStack space="$2" marginTop="$2" flexWrap="wrap"> <XStack space="$2" marginTop="$2" flexWrap="wrap">
<PillBadge tone="muted"> <PillBadge tone="muted">{remainingText}</PillBadge>
{t('mobileBilling.remainingEvents', '{{count}} events', { count: remaining })}
</PillBadge>
{pkg.price !== null && pkg.price !== undefined ? ( {pkg.price !== null && pkg.price !== undefined ? (
<PillBadge tone="muted">{formatAmount(pkg.price, pkg.currency ?? 'EUR')}</PillBadge> <PillBadge tone="muted">{formatAmount(pkg.price, pkg.currency ?? 'EUR')}</PillBadge>
) : null} ) : null}
{renderFeatureBadge(pkg, t, 'branding_allowed', t('billing.features.branding', 'Branding'))} {renderFeatureBadge(pkg, t, 'branding_allowed', t('billing.features.branding', 'Branding'))}
{renderFeatureBadge(pkg, t, 'watermark_allowed', t('billing.features.watermark', 'Watermark'))} {renderFeatureBadge(pkg, t, 'watermark_allowed', t('billing.features.watermark', 'Watermark'))}
</XStack> </XStack>
{eventUsageText ? (
<Text fontSize="$xs" color={muted}>
{eventUsageText}
</Text>
) : null}
{limitEntries.length ? (
<YStack space="$1.5" marginTop="$2">
<Text fontSize="$xs" color={muted}>
{t('mobileBilling.details.limitsTitle', 'Limits')}
</Text>
{limitEntries.map((entry) => (
<XStack key={entry.key} alignItems="center" justifyContent="space-between">
<Text fontSize="$xs" color={muted}>
{entry.label}
</Text>
<Text fontSize="$xs" color={textStrong} fontWeight="700">
{entry.value}
</Text>
</XStack>
))}
</YStack>
) : null}
{featureKeys.length ? (
<YStack space="$1.5" marginTop="$2">
<Text fontSize="$xs" color={muted}>
{t('mobileBilling.details.featuresTitle', 'Features')}
</Text>
{featureKeys.map((feature) => (
<XStack key={feature} alignItems="center" space="$2">
<Sparkles size={14} color={primary} />
<Text fontSize="$xs" color={textStrong}>
{getPackageFeatureLabel(feature, t)}
</Text>
</XStack>
))}
</YStack>
) : null}
{usageMetrics.length ? ( {usageMetrics.length ? (
<YStack space="$2" marginTop="$2"> <YStack space="$2" marginTop="$2">
{usageMetrics.map((metric) => ( {usageMetrics.map((metric) => (
@@ -292,6 +600,18 @@ function PackageCard({ pkg, label, isActive = false }: { pkg: TenantPackageSumma
))} ))}
</YStack> </YStack>
) : null} ) : null}
{isActive && hasUsageWarning && onOpenPortal ? (
<CTAButton
label={
isDanger
? t('mobileBilling.usage.ctaDanger', 'Upgrade package')
: t('mobileBilling.usage.ctaWarning', 'Secure more capacity')
}
onPress={onOpenPortal}
disabled={portalBusy}
tone={isDanger ? 'danger' : 'primary'}
/>
) : null}
</MobileCard> </MobileCard>
); );
} }
@@ -305,25 +625,38 @@ function renderFeatureBadge(pkg: TenantPackageSummary, t: any, key: string, labe
function UsageBar({ metric }: { metric: PackageUsageMetric }) { function UsageBar({ metric }: { metric: PackageUsageMetric }) {
const { t } = useTranslation('management'); const { t } = useTranslation('management');
const { muted, textStrong, border, primary, subtle } = useAdminTheme(); const { muted, textStrong, border, primary, subtle, warningText, danger } = useAdminTheme();
const labelMap: Record<PackageUsageMetric['key'], string> = { const labelMap: Record<PackageUsageMetric['key'], string> = {
events: t('mobileBilling.usage.events', 'Events'), events: t('mobileBilling.usage.events', 'Events'),
guests: t('mobileBilling.usage.guests', 'Guests'), guests: t('mobileBilling.usage.guests', 'Guests'),
photos: t('mobileBilling.usage.photos', 'Photos'), photos: t('mobileBilling.usage.photos', 'Photos'),
gallery: t('mobileBilling.usage.gallery', 'Gallery days'),
}; };
if (!metric.limit) { if (!metric.limit) {
return null; return null;
} }
const status = getUsageState(metric);
const hasUsage = metric.used !== null; const hasUsage = metric.used !== null;
const valueText = hasUsage const valueText = hasUsage
? t('mobileBilling.usage.value', { used: metric.used, limit: metric.limit }) ? t('mobileBilling.usage.value', { used: metric.used, limit: metric.limit })
: t('mobileBilling.usage.limit', { limit: metric.limit }); : t('mobileBilling.usage.limit', { limit: metric.limit });
const remainingText = metric.remaining !== null const remainingText = metric.remaining !== null
? t('mobileBilling.usage.remaining', { count: metric.remaining }) ? t('mobileBilling.usage.remainingOf', {
remaining: metric.remaining,
limit: metric.limit,
defaultValue: 'Remaining {{remaining}} of {{limit}}',
})
: null; : null;
const fill = usagePercent(metric); const fill = usagePercent(metric);
const statusLabel =
status === 'danger'
? t('mobileBilling.usage.statusDanger', 'Limit reached')
: status === 'warning'
? t('mobileBilling.usage.statusWarning', 'Low')
: null;
const fillColor = status === 'danger' ? danger : status === 'warning' ? warningText : primary;
return ( return (
<YStack space="$1.5"> <YStack space="$1.5">
@@ -331,12 +664,15 @@ function UsageBar({ metric }: { metric: PackageUsageMetric }) {
<Text fontSize="$xs" color={muted}> <Text fontSize="$xs" color={muted}>
{labelMap[metric.key]} {labelMap[metric.key]}
</Text> </Text>
<XStack alignItems="center" space="$1.5">
{statusLabel ? <PillBadge tone={status === 'danger' ? 'danger' : 'warning'}>{statusLabel}</PillBadge> : null}
<Text fontSize="$xs" color={textStrong} fontWeight="700"> <Text fontSize="$xs" color={textStrong} fontWeight="700">
{valueText} {valueText}
</Text> </Text>
</XStack> </XStack>
</XStack>
<YStack height={6} borderRadius={999} backgroundColor={border} overflow="hidden"> <YStack height={6} borderRadius={999} backgroundColor={border} overflow="hidden">
<YStack height="100%" width={`${fill}%`} backgroundColor={hasUsage ? primary : subtle} /> <YStack height="100%" width={`${fill}%`} backgroundColor={hasUsage ? fillColor : subtle} />
</YStack> </YStack>
{remainingText ? ( {remainingText ? (
<Text fontSize="$xs" color={muted}> <Text fontSize="$xs" color={muted}>

View File

@@ -1,25 +1,27 @@
import React from 'react'; import React from 'react';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Bell, CheckCircle2, Download, Image as ImageIcon, ListTodo, MessageCircle, QrCode, Settings, ShieldCheck, Smartphone, Users, Sparkles } from 'lucide-react'; import { Bell, CalendarDays, Camera, CheckCircle2, ChevronDown, Download, Image as ImageIcon, Layout, ListTodo, MapPin, Megaphone, MessageCircle, Pencil, QrCode, Settings, ShieldCheck, Smartphone, Sparkles, TrendingUp, Tv, Users } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks'; import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text'; import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite'; import { Pressable } from '@tamagui/react-native-web-lite';
import { MobileShell, renderEventLocation } from './components/MobileShell'; import { MobileShell } from './components/MobileShell';
import { MobileCard, CTAButton, KpiTile, ActionTile, PillBadge, SkeletonCard } from './components/Primitives'; import { MobileCard, CTAButton, KpiTile, ActionTile, PillBadge, SkeletonCard } from './components/Primitives';
import { MobileSheet } from './components/Sheet'; import { MobileSheet } from './components/Sheet';
import { adminPath, ADMIN_WELCOME_BASE_PATH } from '../constants'; import { adminPath, ADMIN_WELCOME_BASE_PATH } from '../constants';
import { useEventContext } from '../context/EventContext'; import { useEventContext } from '../context/EventContext';
import { getEventStats, EventStats, TenantEvent, getEvents } from '../api'; import { fetchOnboardingStatus, getEventStats, EventStats, TenantEvent, getEvents, getTenantPackagesOverview } from '../api';
import { formatEventDate, isBrandingAllowed, resolveEngagementMode, resolveEventDisplayName } from '../lib/events'; import { formatEventDate, isBrandingAllowed, resolveEngagementMode, resolveEventDisplayName } from '../lib/events';
import { useAdminPushSubscription } from './hooks/useAdminPushSubscription'; import { useAdminPushSubscription } from './hooks/useAdminPushSubscription';
import { useDevicePermissions } from './hooks/useDevicePermissions'; import { useDevicePermissions } from './hooks/useDevicePermissions';
import { useInstallPrompt } from './hooks/useInstallPrompt'; import { useInstallPrompt } from './hooks/useInstallPrompt';
import { getTourSeen, resolveTourStepKeys, setTourSeen, type TourStepKey } from './lib/mobileTour'; import { getTourSeen, resolveTourStepKeys, setTourSeen, type TourStepKey } from './lib/mobileTour';
import { collectPackageFeatures, formatPackageLimit, getPackageFeatureLabel, getPackageLimitEntries } from './lib/packageSummary';
import { trackOnboarding } from '../api'; import { trackOnboarding } from '../api';
import { useAuth } from '../auth/context'; import { useAuth } from '../auth/context';
import { ADMIN_ACTION_COLORS, ADMIN_MOTION, useAdminTheme } from './theme'; import { ADMIN_ACTION_COLORS, ADMIN_MOTION, useAdminTheme } from './theme';
import { isPastEvent } from './eventDate';
type DeviceSetupProps = { type DeviceSetupProps = {
installPrompt: ReturnType<typeof useInstallPrompt>; installPrompt: ReturnType<typeof useInstallPrompt>;
@@ -31,6 +33,7 @@ type DeviceSetupProps = {
export default function MobileDashboardPage() { export default function MobileDashboardPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const { slug: slugParam } = useParams<{ slug?: string }>();
const { t, i18n } = useTranslation('management'); const { t, i18n } = useTranslation('management');
const { events, activeEvent, hasEvents, hasMultipleEvents, isLoading, selectEvent } = useEventContext(); const { events, activeEvent, hasEvents, hasMultipleEvents, isLoading, selectEvent } = useEventContext();
const { status } = useAuth(); const { status } = useAuth();
@@ -39,11 +42,14 @@ export default function MobileDashboardPage() {
const [fallbackAttempted, setFallbackAttempted] = React.useState(false); const [fallbackAttempted, setFallbackAttempted] = React.useState(false);
const [tourOpen, setTourOpen] = React.useState(false); const [tourOpen, setTourOpen] = React.useState(false);
const [tourStep, setTourStep] = React.useState(0); 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 onboardingTrackedRef = React.useRef(false);
const installPrompt = useInstallPrompt(); const installPrompt = useInstallPrompt();
const pushState = useAdminPushSubscription(); const pushState = useAdminPushSubscription();
const devicePermissions = useDevicePermissions(); const devicePermissions = useDevicePermissions();
const { textStrong, muted, border, surface, accentSoft, primary } = useAdminTheme(); const { textStrong, muted, accentSoft, primary } = useAdminTheme();
const text = textStrong; const text = textStrong;
const accentText = primary; const accentText = primary;
@@ -64,6 +70,16 @@ export default function MobileDashboardPage() {
queryFn: () => getEvents({ force: true }), queryFn: () => getEvents({ force: true }),
staleTime: 60_000, staleTime: 60_000,
}); });
const { data: onboardingStatus, isLoading: onboardingLoading } = useQuery({
queryKey: ['mobile', 'onboarding', 'status'],
queryFn: fetchOnboardingStatus,
staleTime: 60_000,
});
const { data: packagesOverview, isLoading: packagesLoading, isError: packagesError } = useQuery({
queryKey: ['mobile', 'onboarding', 'packages-overview'],
queryFn: () => getTenantPackagesOverview({ force: true }),
staleTime: 60_000,
});
const effectiveEvents = events.length ? events : dashboardEvents?.length ? dashboardEvents : fallbackEvents; const effectiveEvents = events.length ? events : dashboardEvents?.length ? dashboardEvents : fallbackEvents;
const effectiveHasEvents = hasEvents || Boolean(dashboardEvents?.length) || fallbackEvents.length > 0; const effectiveHasEvents = hasEvents || Boolean(dashboardEvents?.length) || fallbackEvents.length > 0;
const effectiveMultiple = const effectiveMultiple =
@@ -71,6 +87,14 @@ export default function MobileDashboardPage() {
const tourTargetSlug = activeEvent?.slug ?? effectiveEvents[0]?.slug ?? null; const tourTargetSlug = activeEvent?.slug ?? effectiveEvents[0]?.slug ?? null;
const tourStepKeys = React.useMemo(() => resolveTourStepKeys(effectiveHasEvents), [effectiveHasEvents]); const tourStepKeys = React.useMemo(() => resolveTourStepKeys(effectiveHasEvents), [effectiveHasEvents]);
React.useEffect(() => {
if (!slugParam || slugParam === activeEvent?.slug) {
return;
}
selectEvent(slugParam);
}, [activeEvent?.slug, selectEvent, slugParam]);
React.useEffect(() => { React.useEffect(() => {
if (status !== 'authenticated' || onboardingTrackedRef.current) { if (status !== 'authenticated' || onboardingTrackedRef.current) {
return; return;
@@ -115,6 +139,41 @@ export default function MobileDashboardPage() {
setTourOpen(true); setTourOpen(true);
}, [forceTour, location.pathname, navigate]); }, [forceTour, location.pathname, navigate]);
const activePackage =
packagesOverview?.activePackage ?? packagesOverview?.packages?.find((pkg) => pkg.active) ?? null;
const remainingEvents = activePackage?.remaining_events ?? null;
const summarySeenPackageId =
summarySeenOverride ?? onboardingStatus?.steps?.summary_seen_package_id ?? null;
const hasSummaryPackage =
Boolean(activePackage?.id && activePackage.id !== summarySeenPackageId);
const shouldRedirectToBilling =
!packagesLoading &&
!packagesError &&
!effectiveHasEvents &&
(activePackage === null || (remainingEvents !== null && remainingEvents <= 0));
React.useEffect(() => {
if (packagesLoading || !shouldRedirectToBilling) {
return;
}
navigate(adminPath('/mobile/billing#packages'), { replace: true });
}, [navigate, packagesLoading, shouldRedirectToBilling]);
React.useEffect(() => {
if (packagesLoading || packagesError || onboardingLoading) {
return;
}
if (shouldRedirectToBilling) {
return;
}
if (hasSummaryPackage) {
setSummaryOpen(true);
}
}, [hasSummaryPackage, onboardingLoading, packagesLoading, shouldRedirectToBilling]);
const markTourSeen = React.useCallback(() => { const markTourSeen = React.useCallback(() => {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return; return;
@@ -278,6 +337,37 @@ export default function MobileDashboardPage() {
</MobileSheet> </MobileSheet>
) : null; ) : null;
const handleSummaryClose = React.useCallback(() => {
setSummaryOpen(false);
if (activePackage?.id) {
setSummarySeenOverride(activePackage.id);
void trackOnboarding('summary_seen', { package_id: activePackage.id });
}
}, [activePackage?.id]);
const packageSummarySheet = activePackage ? (
<PackageSummarySheet
open={summaryOpen}
onClose={handleSummaryClose}
onContinue={() => {
handleSummaryClose();
navigate(adminPath('/mobile/events/new'));
}}
packageName={activePackage.package_name ?? t('mobileDashboard.packageSummary.fallbackTitle', 'Package summary')}
remainingEvents={remainingEvents}
purchasedAt={activePackage.purchased_at}
expiresAt={activePackage.expires_at}
limits={activePackage.package_limits ?? null}
features={activePackage.features ?? null}
locale={locale}
/>
) : null;
const showPackageSummaryBanner =
Boolean(activePackage && summarySeenPackageId === activePackage.id) &&
!summaryOpen &&
!packagesLoading &&
!packagesError;
React.useEffect(() => { React.useEffect(() => {
if (events.length || isLoading || fallbackLoading || fallbackAttempted) { if (events.length || isLoading || fallbackLoading || fallbackAttempted) {
return; return;
@@ -306,6 +396,7 @@ export default function MobileDashboardPage() {
))} ))}
</YStack> </YStack>
{tourSheet} {tourSheet}
{packageSummarySheet}
</MobileShell> </MobileShell>
); );
} }
@@ -313,6 +404,12 @@ export default function MobileDashboardPage() {
if (!effectiveHasEvents) { if (!effectiveHasEvents) {
return ( return (
<MobileShell activeTab="home" title={t('mobileDashboard.title', 'Dashboard')}> <MobileShell activeTab="home" title={t('mobileDashboard.title', 'Dashboard')}>
{showPackageSummaryBanner ? (
<PackageSummaryBanner
packageName={activePackage?.package_name}
onOpen={() => setSummaryOpen(true)}
/>
) : null}
<OnboardingEmptyState <OnboardingEmptyState
installPrompt={installPrompt} installPrompt={installPrompt}
pushState={pushState} pushState={pushState}
@@ -320,6 +417,7 @@ export default function MobileDashboardPage() {
onOpenSettings={() => navigate(adminPath('/mobile/settings'))} onOpenSettings={() => navigate(adminPath('/mobile/settings'))}
/> />
{tourSheet} {tourSheet}
{packageSummarySheet}
</MobileShell> </MobileShell>
); );
} }
@@ -331,8 +429,15 @@ export default function MobileDashboardPage() {
title={t('mobileDashboard.title', 'Dashboard')} title={t('mobileDashboard.title', 'Dashboard')}
subtitle={t('mobileDashboard.selectEvent', 'Select an event to continue')} subtitle={t('mobileDashboard.selectEvent', 'Select an event to continue')}
> >
<EventPickerList events={effectiveEvents} locale={locale} text={text} muted={muted} border={border} /> {showPackageSummaryBanner ? (
<PackageSummaryBanner
packageName={activePackage?.package_name}
onOpen={() => setSummaryOpen(true)}
/>
) : null}
<EventPickerList events={effectiveEvents} locale={locale} navigateOnSelect={false} />
{tourSheet} {tourSheet}
{packageSummarySheet}
</MobileShell> </MobileShell>
); );
} }
@@ -340,30 +445,26 @@ export default function MobileDashboardPage() {
return ( return (
<MobileShell <MobileShell
activeTab="home" activeTab="home"
title={resolveEventDisplayName(activeEvent ?? undefined)} title={t('mobileDashboard.title', 'Dashboard')}
subtitle={formatEventDate(activeEvent?.event_date, locale) ?? undefined}
> >
<DeviceSetupCard {showPackageSummaryBanner ? (
installPrompt={installPrompt} <PackageSummaryBanner
pushState={pushState} packageName={activePackage?.package_name}
devicePermissions={devicePermissions} onOpen={() => setSummaryOpen(true)}
onOpenSettings={() => navigate(adminPath('/mobile/settings'))}
/> />
<FeaturedActions ) : null}
tasksEnabled={tasksEnabled} <EventHeaderCard
onReviewPhotos={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/photos`))}
onManageTasks={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/tasks`))}
onShowQr={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/qr`))}
/>
<SecondaryGrid
event={activeEvent} event={activeEvent}
onGuests={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/members`))} locale={locale}
onPrint={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/qr`))} canSwitch={effectiveMultiple}
onInvites={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/members`))} onSwitch={() => setEventSwitcherOpen(true)}
onSettings={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}`))} onEdit={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/edit`))}
/>
<EventManagementGrid
event={activeEvent}
tasksEnabled={tasksEnabled}
onNavigate={(path) => navigate(path)}
/> />
<KpiStrip <KpiStrip
event={activeEvent} event={activeEvent}
stats={stats} stats={stats}
@@ -373,11 +474,195 @@ export default function MobileDashboardPage() {
/> />
<AlertsAndHints event={activeEvent} stats={stats} tasksEnabled={tasksEnabled} /> <AlertsAndHints event={activeEvent} stats={stats} tasksEnabled={tasksEnabled} />
<DeviceSetupCard
installPrompt={installPrompt}
pushState={pushState}
devicePermissions={devicePermissions}
onOpenSettings={() => navigate(adminPath('/mobile/settings'))}
/>
{tourSheet} {tourSheet}
{packageSummarySheet}
<EventSwitcherSheet
open={eventSwitcherOpen}
onClose={() => setEventSwitcherOpen(false)}
events={effectiveEvents}
locale={locale}
/>
</MobileShell> </MobileShell>
); );
} }
function PackageSummarySheet({
open,
onClose,
onContinue,
packageName,
remainingEvents,
purchasedAt,
expiresAt,
limits,
features,
locale,
}: {
open: boolean;
onClose: () => void;
onContinue: () => void;
packageName: string;
remainingEvents: number | null | undefined;
purchasedAt: string | null | undefined;
expiresAt: string | null | undefined;
limits: Record<string, unknown> | null;
features: string[] | null;
locale: string;
}) {
const { t } = useTranslation('management');
const { textStrong, muted, border, surface, accentSoft, primary } = useAdminTheme();
const text = textStrong;
const resolvedFeatures = collectPackageFeatures({
features,
package_limits: limits,
branding_allowed: (limits as any)?.branding_allowed ?? null,
watermark_allowed: (limits as any)?.watermark_allowed ?? null,
} as any);
const limitEntries = getPackageLimitEntries(limits, t, { remainingEvents });
const hasFeatures = resolvedFeatures.length > 0;
const formatDate = (value?: string | null) => formatEventDate(value, locale) ?? t('mobileDashboard.packageSummary.unknown', 'Unknown');
return (
<MobileSheet open={open} title={t('mobileDashboard.packageSummary.title', 'Your package summary')} onClose={onClose}>
<YStack space="$3">
<MobileCard space="$2.5" borderColor={border} backgroundColor={surface}>
<YStack space="$1.5">
<Text fontSize="$sm" fontWeight="800" color={text}>
{packageName}
</Text>
<Text fontSize="$xs" color={muted}>
{t('mobileDashboard.packageSummary.subtitle', 'Here is a quick overview of your active package.')}
</Text>
</YStack>
<YStack space="$2" marginTop="$2">
<SummaryRow
label={t('mobileDashboard.packageSummary.remaining', 'Remaining events')}
value={formatPackageLimit(remainingEvents, t)}
/>
<SummaryRow label={t('mobileDashboard.packageSummary.purchased', 'Purchased')} value={formatDate(purchasedAt)} />
{expiresAt ? (
<SummaryRow label={t('mobileDashboard.packageSummary.expires', 'Expires')} value={formatDate(expiresAt)} />
) : null}
</YStack>
</MobileCard>
{limitEntries.length ? (
<MobileCard space="$2" borderColor={border} backgroundColor={surface}>
<Text fontSize="$sm" fontWeight="800" color={text}>
{t('mobileDashboard.packageSummary.limitsTitle', 'Limits')}
</Text>
<YStack space="$1.5" marginTop="$2">
{limitEntries.map((entry) => (
<SummaryRow key={entry.key} label={entry.label} value={entry.value} />
))}
</YStack>
</MobileCard>
) : null}
{hasFeatures ? (
<MobileCard space="$2" borderColor={border} backgroundColor={surface}>
<Text fontSize="$sm" fontWeight="800" color={text}>
{t('mobileDashboard.packageSummary.featuresTitle', 'Included features')}
</Text>
<YStack space="$1.5" marginTop="$2">
{resolvedFeatures.map((feature) => (
<XStack key={feature} alignItems="center" space="$2">
<XStack width={24} height={24} borderRadius={8} backgroundColor={accentSoft} alignItems="center" justifyContent="center">
<Sparkles size={14} color={primary} />
</XStack>
<Text fontSize="$xs" color={text}>
{getPackageFeatureLabel(feature, t)}
</Text>
</XStack>
))}
</YStack>
</MobileCard>
) : null}
<MobileCard space="$2" borderColor={border} backgroundColor={surface}>
<Text fontSize="$xs" color={muted}>
{t('mobileDashboard.packageSummary.hint', 'You can revisit billing any time to review or upgrade your package.')}
</Text>
</MobileCard>
<XStack space="$2">
<CTAButton label={t('mobileDashboard.packageSummary.continue', 'Continue to event setup')} onPress={onContinue} fullWidth={false} />
<CTAButton label={t('mobileDashboard.packageSummary.dismiss', 'Close')} tone="ghost" onPress={onClose} fullWidth={false} />
</XStack>
</YStack>
</MobileSheet>
);
}
function SummaryRow({ label, value }: { label: string; value: string }) {
const { textStrong, muted } = useAdminTheme();
return (
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$xs" color={textStrong}>
{label}
</Text>
<Text fontSize="$xs" color={muted}>
{value}
</Text>
</XStack>
);
}
function PackageSummaryBanner({
packageName,
onOpen,
}: {
packageName?: string | null;
onOpen: () => void;
}) {
const { t } = useTranslation('management');
const { textStrong, muted, border, surface, accentSoft, primary } = useAdminTheme();
const text = textStrong;
return (
<MobileCard space="$2" borderColor={border} backgroundColor={surface}>
<XStack alignItems="center" justifyContent="space-between" gap="$2">
<XStack alignItems="center" space="$2" flex={1}>
<XStack
width={36}
height={36}
borderRadius={12}
backgroundColor={accentSoft}
alignItems="center"
justifyContent="center"
>
<Sparkles size={16} color={primary} />
</XStack>
<YStack space="$0.5" flex={1}>
<Text fontSize="$sm" fontWeight="800" color={text}>
{t('mobileDashboard.packageSummary.bannerTitle', 'Your package summary')}
</Text>
<Text fontSize="$xs" color={muted}>
{t('mobileDashboard.packageSummary.bannerSubtitle', {
name: packageName ?? t('mobileDashboard.packageSummary.fallbackTitle', 'Package summary'),
defaultValue: '{{name}} is active. Review limits & features.',
})}
</Text>
</YStack>
</XStack>
<CTAButton
label={t('mobileDashboard.packageSummary.bannerCta', 'View')}
tone="ghost"
fullWidth={false}
onPress={onOpen}
/>
</XStack>
</MobileCard>
);
}
function DeviceSetupCard({ installPrompt, pushState, devicePermissions, onOpenSettings }: DeviceSetupProps) { function DeviceSetupCard({ installPrompt, pushState, devicePermissions, onOpenSettings }: DeviceSetupProps) {
const { t } = useTranslation('management'); const { t } = useTranslation('management');
const { textStrong, muted, border, primary, accentSoft } = useAdminTheme(); const { textStrong, muted, border, primary, accentSoft } = useAdminTheme();
@@ -703,8 +988,20 @@ function OnboardingEmptyState({ installPrompt, pushState, devicePermissions, onO
); );
} }
function EventPickerList({ events, locale, text, muted, border }: { events: TenantEvent[]; locale: string; text: string; muted: string; border: string }) { function EventPickerList({
events,
locale,
onPick,
navigateOnSelect = true,
}: {
events: TenantEvent[];
locale: string;
onPick?: (event: TenantEvent) => void;
navigateOnSelect?: boolean;
}) {
const { t } = useTranslation('management'); const { t } = useTranslation('management');
const { textStrong, muted, border } = useAdminTheme();
const text = textStrong;
const { selectEvent } = useEventContext(); const { selectEvent } = useEventContext();
const navigate = useNavigate(); const navigate = useNavigate();
const [localEvents, setLocalEvents] = React.useState<TenantEvent[]>(events); const [localEvents, setLocalEvents] = React.useState<TenantEvent[]>(events);
@@ -735,7 +1032,8 @@ function EventPickerList({ events, locale, text, muted, border }: { events: Tena
key={event.slug} key={event.slug}
onPress={() => { onPress={() => {
selectEvent(event.slug ?? null); selectEvent(event.slug ?? null);
if (event.slug) { onPick?.(event);
if (navigateOnSelect && event.slug) {
navigate(adminPath(`/mobile/events/${event.slug}`)); navigate(adminPath(`/mobile/events/${event.slug}`));
} }
}} }}
@@ -763,132 +1061,232 @@ function EventPickerList({ events, locale, text, muted, border }: { events: Tena
); );
} }
function FeaturedActions({ function EventSwitcherSheet({
tasksEnabled, open,
onReviewPhotos, onClose,
onManageTasks, events,
onShowQr, locale,
}: { }: {
tasksEnabled: boolean; open: boolean;
onReviewPhotos: () => void; onClose: () => void;
onManageTasks: () => void; events: TenantEvent[];
onShowQr: () => void; locale: string;
}) { }) {
const { t } = useTranslation('management'); const { t } = useTranslation('management');
const { textStrong, muted, subtle } = useAdminTheme();
const text = textStrong;
const cards = [
{
key: 'photos',
label: t('mobileDashboard.photosLabel', 'Review photos'),
desc: t('mobileDashboard.photosDesc', 'Moderate uploads and highlights'),
icon: ImageIcon,
color: ADMIN_ACTION_COLORS.images,
action: onReviewPhotos,
},
{
key: 'tasks',
label: t('mobileDashboard.tasksLabel', 'Manage tasks & challenges'),
desc: tasksEnabled
? t('mobileDashboard.tasksDesc', 'Assign and track progress')
: t('mobileDashboard.tasksDisabledDesc', 'Guests do not see tasks (task mode off)'),
icon: ListTodo,
color: ADMIN_ACTION_COLORS.tasks,
action: onManageTasks,
},
{
key: 'qr',
label: t('mobileDashboard.qrLabel', 'Show / share QR code'),
desc: t('mobileDashboard.qrDesc', 'Posters, cards, and links'),
icon: QrCode,
color: ADMIN_ACTION_COLORS.qr,
action: onShowQr,
},
];
return ( return (
<YStack space="$2"> <MobileSheet open={open} title={t('mobileDashboard.pickEvent', 'Select an event')} onClose={onClose}>
{cards.map((card) => ( <EventPickerList events={events} locale={locale} navigateOnSelect={false} onPick={onClose} />
<Pressable key={card.key} onPress={card.action}> </MobileSheet>
<MobileCard borderColor={`${card.color}44`} backgroundColor={`${card.color}0f`} space="$2.5">
<XStack alignItems="center" space="$3">
<XStack width={44} height={44} borderRadius={14} backgroundColor={card.color} alignItems="center" justifyContent="center">
<card.icon size={20} color="white" />
</XStack>
<YStack space="$1" flex={1}>
<Text fontSize="$md" fontWeight="800" color={text}>
{card.label}
</Text>
<Text fontSize="$xs" color={muted}>
{card.desc}
</Text>
</YStack>
<Text fontSize="$xl" color={subtle}>
˃
</Text>
</XStack>
</MobileCard>
</Pressable>
))}
</YStack>
); );
} }
function SecondaryGrid({ function resolveLocation(event: TenantEvent | null, t: (key: string, fallback: string) => string): string {
if (!event) return t('events.detail.locationPlaceholder', 'Location');
const settings = (event.settings ?? {}) as Record<string, unknown>;
const candidate =
(settings.location as string | undefined) ??
(settings.address as string | undefined) ??
(settings.city as string | undefined);
if (candidate && candidate.trim()) {
return candidate;
}
return t('events.detail.locationPlaceholder', 'Location');
}
function EventHeaderCard({
event, event,
onGuests, locale,
onPrint, canSwitch,
onInvites, onSwitch,
onSettings, onEdit,
}: { }: {
event: TenantEvent | null; event: TenantEvent | null;
onGuests: () => void; locale: string;
onPrint: () => void; canSwitch: boolean;
onInvites: () => void; onSwitch: () => void;
onSettings: () => void; onEdit: () => void;
}) { }) {
const { t } = useTranslation('management'); const { t } = useTranslation('management');
const { textStrong, muted, border, surface, accentSoft, primary } = useAdminTheme(); const { textStrong, muted, border, surface, accentSoft, primary } = useAdminTheme();
const text = textStrong;
if (!event) {
return null;
}
const dateLabel = formatEventDate(event.event_date, locale) ?? t('events.detail.dateTbd', 'Date tbd');
const locationLabel = resolveLocation(event, t);
return (
<MobileCard space="$3" borderColor={border} backgroundColor={surface} position="relative">
<XStack alignItems="center" justifyContent="space-between" space="$2">
{canSwitch ? (
<Pressable onPress={onSwitch} aria-label={t('mobileDashboard.pickEvent', 'Select an event')}>
<XStack alignItems="center" space="$2">
<Text fontSize="$lg" fontWeight="800" color={textStrong}>
{resolveEventDisplayName(event)}
</Text>
<ChevronDown size={16} color={muted} />
</XStack>
</Pressable>
) : (
<Text fontSize="$lg" fontWeight="800" color={textStrong}>
{resolveEventDisplayName(event)}
</Text>
)}
<PillBadge tone={event.status === 'published' ? 'success' : 'warning'}>
{event.status === 'published'
? t('events.status.published', 'Live')
: t('events.status.draft', 'Draft')}
</PillBadge>
</XStack>
<XStack alignItems="center" space="$2">
<CalendarDays size={16} color={muted} />
<Text fontSize="$sm" color={muted}>
{dateLabel}
</Text>
<MapPin size={16} color={muted} />
<Text fontSize="$sm" color={muted}>
{locationLabel}
</Text>
</XStack>
<Pressable
aria-label={t('mobileEvents.edit', 'Edit event')}
onPress={onEdit}
style={{
position: 'absolute',
right: 16,
top: 16,
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: accentSoft,
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 6px 16px rgba(0,0,0,0.12)',
}}
>
<Pencil size={18} color={primary} />
</Pressable>
</MobileCard>
);
}
function EventManagementGrid({
event,
tasksEnabled,
onNavigate,
}: {
event: TenantEvent | null;
tasksEnabled: boolean;
onNavigate: (path: string) => void;
}) {
const { t } = useTranslation('management');
const { textStrong } = useAdminTheme();
const slug = event?.slug ?? null;
const brandingAllowed = isBrandingAllowed(event ?? null); const brandingAllowed = isBrandingAllowed(event ?? null);
if (!event) {
return null;
}
const tiles = [ const tiles = [
{ {
icon: Users, icon: Pencil,
label: t('mobileDashboard.shortcutGuests', 'Guest management'), label: t('mobileDashboard.shortcutSettings', 'Event settings'),
color: ADMIN_ACTION_COLORS.guests, color: ADMIN_ACTION_COLORS.settings,
action: onGuests, 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, icon: QrCode,
label: t('mobileDashboard.shortcutPrints', 'Print & poster downloads'), label: t('events.quick.qr', 'QR Code Layouts'),
color: ADMIN_ACTION_COLORS.qr, color: ADMIN_ACTION_COLORS.qr,
action: onPrint, onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/qr`)) : undefined,
disabled: !slug,
}, },
{ {
icon: Sparkles, icon: ImageIcon,
label: t('mobileDashboard.shortcutInvites', 'Team / helper invites'), label: t('events.quick.images', 'Image Management'),
color: ADMIN_ACTION_COLORS.invites, color: ADMIN_ACTION_COLORS.images,
action: onInvites, 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, icon: Settings,
label: t('mobileDashboard.shortcutSettings', 'Event settings'), label: t('events.quick.liveShowSettings', 'Live Show settings'),
color: ADMIN_ACTION_COLORS.success, color: ADMIN_ACTION_COLORS.images,
action: onSettings, onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/live-show/settings`)) : undefined,
disabled: !slug,
}, },
{ {
icon: Sparkles, icon: Users,
label: t('mobileDashboard.shortcutBranding', 'Branding & moderation'), label: t('events.quick.guests', 'Guest Management'),
color: ADMIN_ACTION_COLORS.guests,
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, color: ADMIN_ACTION_COLORS.branding,
action: brandingAllowed ? onSettings : undefined, onPress: slug && brandingAllowed ? () => onNavigate(adminPath(`/mobile/events/${slug}/branding`)) : undefined,
disabled: !brandingAllowed, 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,
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 ( return (
<YStack space="$2" marginTop="$2"> <YStack space="$2">
<Text fontSize="$sm" fontWeight="800" color={text}> <Text fontSize="$sm" fontWeight="800" color={textStrong}>
{t('mobileDashboard.shortcutsTitle', 'Shortcuts')} {t('events.detail.managementTitle', 'Event management')}
</Text> </Text>
<XStack flexWrap="wrap" space="$2"> <XStack flexWrap="wrap" space="$2">
{tiles.map((tile, index) => ( {tiles.map((tile, index) => (
@@ -897,22 +1295,12 @@ function SecondaryGrid({
icon={tile.icon} icon={tile.icon}
label={tile.label} label={tile.label}
color={tile.color} color={tile.color}
onPress={tile.action} onPress={tile.onPress}
disabled={tile.disabled} disabled={tile.disabled}
delayMs={index * ADMIN_MOTION.tileStaggerMs} delayMs={index * ADMIN_MOTION.tileStaggerMs}
/> />
))} ))}
</XStack> </XStack>
{event ? (
<MobileCard backgroundColor={surface} borderColor={border} space="$1.5">
<Text fontSize="$sm" fontWeight="700" color={text}>
{resolveEventDisplayName(event)}
</Text>
<Text fontSize="$xs" color={muted}>
{renderEventLocation(event)}
</Text>
</MobileCard>
) : null}
</YStack> </YStack>
); );
} }

View File

@@ -23,6 +23,7 @@ import { formatRelativeTime } from './lib/relativeTime';
import { useAdminTheme } from './theme'; import { useAdminTheme } from './theme';
import i18n from '../i18n'; import i18n from '../i18n';
import { triggerDownloadFromBlob } from './invite-layout/export-utils'; import { triggerDownloadFromBlob } from './invite-layout/export-utils';
import { hasInProgressExports } from './lib/dataExports';
const statusTone: Record<DataExportSummary['status'], 'success' | 'warning' | 'muted'> = { const statusTone: Record<DataExportSummary['status'], 'success' | 'warning' | 'muted'> = {
pending: 'warning', pending: 'warning',
@@ -89,6 +90,22 @@ export default function MobileDataExportsPage() {
void load(); void load();
}, [load]); }, [load]);
const hasInProgress = hasInProgressExports(exports);
React.useEffect(() => {
if (!hasInProgress) {
return;
}
const interval = window.setInterval(() => {
void load();
}, 5000);
return () => {
window.clearInterval(interval);
};
}, [hasInProgress, load]);
const handleRequest = async () => { const handleRequest = async () => {
if (requesting) { if (requesting) {
return; return;
@@ -152,7 +169,7 @@ export default function MobileDataExportsPage() {
value={scope} value={scope}
onChange={(event) => setScope(event.target.value as 'tenant' | 'event')} onChange={(event) => setScope(event.target.value as 'tenant' | 'event')}
compact compact
style={{ minWidth: 160 }} style={{ minWidth: 140, maxWidth: 180 }}
> >
<option value="tenant">{t('dataExports.scopes.tenant', 'Tenant')}</option> <option value="tenant">{t('dataExports.scopes.tenant', 'Tenant')}</option>
<option value="event">{t('dataExports.scopes.event', 'Event')}</option> <option value="event">{t('dataExports.scopes.event', 'Event')}</option>
@@ -167,7 +184,7 @@ export default function MobileDataExportsPage() {
value={eventId ? String(eventId) : ''} value={eventId ? String(eventId) : ''}
onChange={(event) => setEventId(event.target.value ? Number(event.target.value) : null)} onChange={(event) => setEventId(event.target.value ? Number(event.target.value) : null)}
compact compact
style={{ minWidth: 200 }} style={{ minWidth: 180, maxWidth: 220 }}
> >
<option value="">{t('dataExports.fields.eventPlaceholder', 'Choose event')}</option> <option value="">{t('dataExports.fields.eventPlaceholder', 'Choose event')}</option>
{events.map((event) => ( {events.map((event) => (
@@ -187,8 +204,20 @@ export default function MobileDataExportsPage() {
{t('dataExports.fields.includeMediaHint', 'Bigger ZIP; choose when needed.')} {t('dataExports.fields.includeMediaHint', 'Bigger ZIP; choose when needed.')}
</Text> </Text>
</YStack> </YStack>
<Switch checked={includeMedia} onCheckedChange={setIncludeMedia} /> <Switch
size="$3"
checked={includeMedia}
onCheckedChange={setIncludeMedia}
aria-label={t('dataExports.fields.includeMedia', 'Include raw media')}
>
<Switch.Thumb />
</Switch>
</XStack> </XStack>
{hasInProgress ? (
<Text fontSize="$xs" color={muted}>
{t('dataExports.request.progress', 'Export is running. This list refreshes automatically.')}
</Text>
) : null}
</YStack> </YStack>
<CTAButton <CTAButton
label={requesting ? t('dataExports.actions.requesting', 'Requesting...') : t('dataExports.actions.request', 'Request export')} label={requesting ? t('dataExports.actions.requesting', 'Requesting...') : t('dataExports.actions.request', 'Request export')}

View File

@@ -0,0 +1,320 @@
import React from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
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, 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';
export default function MobileEventAnalyticsPage() {
const { slug } = useParams<{ slug: string }>();
const { t, i18n } = useTranslation('management');
const navigate = useNavigate();
const { textStrong, muted, border, surface, primary, accentSoft } = useAdminTheme();
const dateLocale = i18n.language.startsWith('de') ? de : enGB;
const { data, isLoading, error } = useQuery<EventAnalytics, ApiError>({
queryKey: ['event-analytics', slug],
queryFn: () => getEventAnalytics(slug!),
enabled: Boolean(slug),
retry: false, // Don't retry if 403
});
const isFeatureLocked = error?.status === 403 || error?.code === 'feature_locked';
if (isFeatureLocked) {
return (
<MobileShell title={t('analytics.title', 'Analytics')} activeTab="home">
<MobileCard
space="$4"
padding="$6"
alignItems="center"
justifyContent="center"
borderColor={border}
backgroundColor={surface}
>
<YStack
width={64}
height={64}
borderRadius={32}
backgroundColor={accentSoft}
alignItems="center"
justifyContent="center"
marginBottom="$2"
>
<Lock size={32} color={primary} />
</YStack>
<YStack space="$2" alignItems="center">
<Text fontSize="$xl" fontWeight="900" color={textStrong} textAlign="center">
{t('analytics.lockedTitle', 'Unlock Analytics')}
</Text>
<Text fontSize="$sm" color={muted} textAlign="center">
{t('analytics.lockedBody', 'Get deep insights into your event engagement with the Premium package.')}
</Text>
</YStack>
<CTAButton
label={t('analytics.upgradeAction', 'Upgrade to Premium')}
onPress={() => navigate(adminPath('/mobile/billing/shop?feature=advanced_analytics'))}
/>
</MobileCard>
</MobileShell>
);
}
if (isLoading) {
return (
<MobileShell title={t('analytics.title', 'Analytics')} activeTab="home">
<YStack space="$3">
<SkeletonCard height={200} />
<SkeletonCard height={150} />
<SkeletonCard height={150} />
</YStack>
</MobileShell>
);
}
if (error || !data) {
return (
<MobileShell title={t('analytics.title', 'Analytics')} activeTab="home">
<MobileCard borderColor={border} padding="$4">
<Text color={muted}>{t('common.error', 'Something went wrong')}</Text>
</MobileCard>
</MobileShell>
);
}
const { timeline, contributors, tasks } = data;
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 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')}
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">
<TrendingUp size={18} color={primary} />
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{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 / maxTimelineCount) * 100;
const date = parseISO(point.timestamp);
// Show label every 3rd point or if few points
const showLabel = timeline.length < 8 || index % 3 === 0;
return (
<YStack key={point.timestamp} flex={1} alignItems="center" space="$1">
<YStack
width="100%"
height={`${Math.max(heightPercent, 4)}%`}
backgroundColor={primary}
opacity={0.8}
borderTopLeftRadius={4}
borderTopRightRadius={4}
/>
{showLabel && (
<Text fontSize={10} color={muted} numberOfLines={1}>
{format(date, 'HH:mm', { locale: dateLocale })}
</Text>
)}
</YStack>
);
})}
</XStack>
<Text fontSize="$xs" color={muted} textAlign="center">
{t('analytics.uploadsPerHour', 'Uploads per hour')}
</Text>
</YStack>
) : (
<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>
{/* Top Contributors */}
<MobileCard space="$3" borderColor={border} backgroundColor={surface}>
<XStack alignItems="center" space="$2">
<Trophy size={18} color={primary} />
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('analytics.contributorsTitle', 'Top Contributors')}
</Text>
</XStack>
{hasContributors ? (
<YStack space="$3">
{contributors.map((contributor, idx) => (
<XStack key={idx} alignItems="center" justifyContent="space-between" paddingVertical="$1">
<XStack alignItems="center" space="$3">
<YStack
width={28}
height={28}
borderRadius={14}
backgroundColor={idx < 3 ? accentSoft : '$gray5'}
alignItems="center"
justifyContent="center"
>
<Text fontSize="$xs" fontWeight="700" color={idx < 3 ? primary : muted}>
{idx + 1}
</Text>
</YStack>
<YStack>
<Text fontSize="$sm" fontWeight="600" color={textStrong}>
{contributor.name || t('common.anonymous', 'Anonymous')}
</Text>
<Text fontSize="$xs" color={muted}>
{t('analytics.likesCount', { count: contributor.likes, defaultValue: '{{count}} likes' })}
</Text>
</YStack>
</XStack>
<Text fontSize="$sm" fontWeight="700" color={primary}>
{contributor.count}
</Text>
</XStack>
))}
</YStack>
) : (
<EmptyState
message={t('analytics.noContributors', 'No contributors yet')}
actionLabel={t('analytics.emptyActionInvite', 'Invite guests')}
onAction={() => slug && navigate(adminPath(`/mobile/events/${slug}/members`))}
/>
)}
</MobileCard>
{/* Task Stats */}
<MobileCard space="$3" borderColor={border} backgroundColor={surface}>
<XStack alignItems="center" space="$2">
<ListTodo size={18} color={primary} />
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('analytics.tasksTitle', 'Popular Tasks')}
</Text>
</XStack>
{hasTasks ? (
<YStack space="$3">
{tasks.map((task) => {
const percent = (task.count / maxTaskCount) * 100;
return (
<YStack key={task.task_id} space="$1">
<XStack justifyContent="space-between">
<Text fontSize="$sm" color={textStrong} numberOfLines={1} flex={1}>
{task.task_name}
</Text>
<Text fontSize="$xs" fontWeight="700" color={textStrong}>
{task.count}
</Text>
</XStack>
<XStack height={6} backgroundColor="$gray4" borderRadius={3} overflow="hidden">
<YStack
height="100%"
width={`${percent}%`}
backgroundColor={primary}
borderRadius={3}
/>
</XStack>
</YStack>
);
})}
</YStack>
) : (
<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>
</MobileShell>
);
}
function EmptyState({
message,
actionLabel,
onAction,
}: {
message: string;
actionLabel?: string;
onAction?: () => void;
}) {
const { muted } = useAdminTheme();
return (
<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,15 +9,16 @@ import { MobileShell } from './components/MobileShell';
import { MobileCard, CTAButton } from './components/Primitives'; import { MobileCard, CTAButton } from './components/Primitives';
import { MobileField, MobileInput, MobileSelect, MobileTextArea } from './components/FormControls'; import { MobileField, MobileInput, MobileSelect, MobileTextArea } from './components/FormControls';
import { LegalConsentSheet } from './components/LegalConsentSheet'; 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 { resolveEventSlugAfterUpdate } from './eventFormNavigation';
import { adminPath } from '../constants'; import { adminPath } from '../constants';
import { isAuthError } from '../auth/tokens'; import { isAuthError } from '../auth/tokens';
import { getApiValidationMessage, isApiError } from '../lib/apiError'; import { getApiErrorMessage, getApiValidationMessage, isApiError } from '../lib/apiError';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { useBackNavigation } from './hooks/useBackNavigation'; import { useBackNavigation } from './hooks/useBackNavigation';
import { useAdminTheme } from './theme'; import { useAdminTheme } from './theme';
import { withAlpha } from './components/colors'; import { withAlpha } from './components/colors';
import { useAuth } from '../auth/context';
type FormState = { type FormState = {
name: string; name: string;
@@ -28,6 +29,7 @@ type FormState = {
published: boolean; published: boolean;
autoApproveUploads: boolean; autoApproveUploads: boolean;
tasksEnabled: boolean; tasksEnabled: boolean;
packageId: number | null;
}; };
export default function MobileEventFormPage() { export default function MobileEventFormPage() {
@@ -36,7 +38,9 @@ export default function MobileEventFormPage() {
const isEdit = Boolean(slug); const isEdit = Boolean(slug);
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation(['management', 'common']); const { t } = useTranslation(['management', 'common']);
const { user } = useAuth();
const { text, muted, subtle, danger, border, surface, primary } = useAdminTheme(); const { text, muted, subtle, danger, border, surface, primary } = useAdminTheme();
const isSuperAdmin = user?.role === 'super_admin' || user?.role === 'superadmin';
const [form, setForm] = React.useState<FormState>({ const [form, setForm] = React.useState<FormState>({
name: '', name: '',
@@ -47,9 +51,12 @@ export default function MobileEventFormPage() {
published: false, published: false,
autoApproveUploads: true, autoApproveUploads: true,
tasksEnabled: true, tasksEnabled: true,
packageId: null,
}); });
const [eventTypes, setEventTypes] = React.useState<TenantEventType[]>([]); const [eventTypes, setEventTypes] = React.useState<TenantEventType[]>([]);
const [typesLoading, setTypesLoading] = React.useState(false); 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 [loading, setLoading] = React.useState(isEdit);
const [saving, setSaving] = React.useState(false); const [saving, setSaving] = React.useState(false);
const [consentOpen, setConsentOpen] = React.useState(false); const [consentOpen, setConsentOpen] = React.useState(false);
@@ -76,6 +83,7 @@ export default function MobileEventFormPage() {
tasksEnabled: tasksEnabled:
(data.settings?.engagement_mode as string | undefined) !== 'photo_only' && (data.settings?.engagement_mode as string | undefined) !== 'photo_only' &&
(data.engagement_mode as string | undefined) !== 'photo_only', (data.engagement_mode as string | undefined) !== 'photo_only',
packageId: null,
}); });
setError(null); setError(null);
} catch (err) { } catch (err) {
@@ -94,9 +102,9 @@ export default function MobileEventFormPage() {
try { try {
const types = await getEventTypes(); const types = await getEventTypes();
setEventTypes(types); setEventTypes(types);
// Default to first type if none set const preferredType = types.find((type) => type.slug === 'wedding') ?? types[0] ?? null;
if (!form.eventTypeId && types.length) { if (preferredType) {
setForm((prev) => ({ ...prev, eventTypeId: types[0].id })); setForm((prev) => (prev.eventTypeId ? prev : { ...prev, eventTypeId: preferredType.id }));
} }
} catch { } catch {
// silently ignore; fallback to null // silently ignore; fallback to null
@@ -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() { async function handleSubmit() {
setSaving(true); setSaving(true);
setError(null); setError(null);
@@ -130,7 +163,8 @@ export default function MobileEventFormPage() {
slug: `${Date.now()}`, slug: `${Date.now()}`,
event_type_id: form.eventTypeId ?? undefined, event_type_id: form.eventTypeId ?? undefined,
event_date: form.date || undefined, event_date: form.date || undefined,
status: (form.published ? 'published' : 'draft') as const, status: form.published ? 'published' : 'draft',
package_id: isSuperAdmin ? form.packageId ?? undefined : undefined,
settings: { settings: {
location: form.location, location: form.location,
guest_upload_visibility: form.autoApproveUploads ? 'immediate' : 'review', guest_upload_visibility: form.autoApproveUploads ? 'immediate' : 'review',
@@ -152,7 +186,8 @@ export default function MobileEventFormPage() {
slug: `${Date.now()}`, slug: `${Date.now()}`,
event_type_id: form.eventTypeId ?? undefined, event_type_id: form.eventTypeId ?? undefined,
event_date: form.date || undefined, event_date: form.date || undefined,
status: (form.published ? 'published' : 'draft') as const, status: form.published ? 'published' : 'draft',
package_id: isSuperAdmin ? form.packageId ?? undefined : undefined,
settings: { settings: {
location: form.location, location: form.location,
guest_upload_visibility: form.autoApproveUploads ? 'immediate' : 'review', guest_upload_visibility: form.autoApproveUploads ? 'immediate' : 'review',
@@ -223,6 +258,31 @@ export default function MobileEventFormPage() {
/> />
</MobileField> </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')}> <MobileField label={t('eventForm.fields.date.label', 'Date & time')}>
<XStack alignItems="center" space="$2"> <XStack alignItems="center" space="$2">
<NativeDateTimeInput <NativeDateTimeInput

View File

@@ -6,7 +6,7 @@ import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text'; import { SizableText as Text } from '@tamagui/text';
import { MobileShell, HeaderActionButton } from './components/MobileShell'; import { MobileShell, HeaderActionButton } from './components/MobileShell';
import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Primitives'; import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Primitives';
import { MobileSelect } from './components/FormControls'; import { MobileSelect, MobileField } from './components/FormControls';
import { useEventContext } from '../context/EventContext'; import { useEventContext } from '../context/EventContext';
import { import {
approveAndLiveShowPhoto, approveAndLiveShowPhoto,
@@ -216,8 +216,8 @@ export default function MobileEventLiveShowQueuePage() {
</MobileCard> </MobileCard>
<MobileCard> <MobileCard>
<MobileField label={t('liveShowQueue.filterLabel', 'Live status')}>
<MobileSelect <MobileSelect
label={t('liveShowQueue.filterLabel', 'Live status')}
value={statusFilter} value={statusFilter}
onChange={(event) => setStatusFilter(event.target.value as LiveShowQueueStatus)} onChange={(event) => setStatusFilter(event.target.value as LiveShowQueueStatus)}
> >
@@ -227,6 +227,7 @@ export default function MobileEventLiveShowQueuePage() {
</option> </option>
))} ))}
</MobileSelect> </MobileSelect>
</MobileField>
</MobileCard> </MobileCard>
{error ? ( {error ? (

View File

@@ -12,7 +12,7 @@ import { MobileField, MobileInput, MobileSelect } from './components/FormControl
import { getEvent, getLiveShowLink, rotateLiveShowLink, updateEvent, LiveShowLink, LiveShowSettings, TenantEvent } from '../api'; import { getEvent, getLiveShowLink, rotateLiveShowLink, updateEvent, LiveShowLink, LiveShowSettings, TenantEvent } from '../api';
import { isAuthError } from '../auth/tokens'; import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage } from '../lib/apiError'; import { getApiErrorMessage } from '../lib/apiError';
import { formatEventDate, resolveEventDisplayName } from '../lib/events'; import { resolveEventDisplayName } from '../lib/events';
import { adminPath } from '../constants'; import { adminPath } from '../constants';
import { useBackNavigation } from './hooks/useBackNavigation'; import { useBackNavigation } from './hooks/useBackNavigation';
import { useAdminTheme } from './theme'; import { useAdminTheme } from './theme';
@@ -262,8 +262,7 @@ export default function MobileEventLiveShowSettingsPage() {
return ( return (
<MobileShell <MobileShell
activeTab="home" activeTab="home"
title={event ? resolveEventDisplayName(event) : t('liveShowSettings.title', 'Live Show settings')} title={t('liveShowSettings.title', 'Live Show settings')}
subtitle={event?.event_date ? formatEventDate(event.event_date, locale) ?? undefined : undefined}
onBack={back} onBack={back}
headerActions={ headerActions={
<HeaderActionButton onPress={() => load()} ariaLabel={t('common.refresh', 'Refresh')}> <HeaderActionButton onPress={() => load()} ariaLabel={t('common.refresh', 'Refresh')}>
@@ -341,7 +340,7 @@ export default function MobileEventLiveShowSettingsPage() {
{liveShowLink?.qr_code_data_url ? ( {liveShowLink?.qr_code_data_url ? (
<XStack space="$2" alignItems="center" marginTop="$2" flexWrap="wrap"> <XStack space="$2" alignItems="center" marginTop="$2" flexWrap="wrap">
<Pressable <Pressable
onPress={() => downloadQr(liveShowLink.qr_code_data_url, 'live-show-qr.png')} onPress={() => downloadQr(liveShowLink.qr_code_data_url!, 'live-show-qr.png')}
title={t('liveShowSettings.link.downloadQr', 'Download QR')} title={t('liveShowSettings.link.downloadQr', 'Download QR')}
aria-label={t('liveShowSettings.link.downloadQr', 'Download QR')} aria-label={t('liveShowSettings.link.downloadQr', 'Download QR')}
style={{ borderRadius: 12, cursor: 'pointer' }} style={{ borderRadius: 12, cursor: 'pointer' }}
@@ -578,14 +577,14 @@ function resolveName(name: TenantEvent['name']): string {
return 'Event'; return 'Event';
} }
function copyToClipboard(value: string, t: (key: string, fallback?: string) => string) { function copyToClipboard(value: string, t: any) {
navigator.clipboard navigator.clipboard
.writeText(value) .writeText(value)
.then(() => toast.success(t('liveShowSettings.link.copySuccess', 'Link copied'))) .then(() => toast.success(t('liveShowSettings.link.copySuccess', 'Link copied')))
.catch(() => toast.error(t('liveShowSettings.link.copyFailed', 'Link could not be copied'))); .catch(() => toast.error(t('liveShowSettings.link.copyFailed', 'Link could not be copied')));
} }
async function shareLink(value: string, event: TenantEvent | null, t: (key: string, fallback?: string) => string) { async function shareLink(value: string, event: TenantEvent | null, t: any) {
if (navigator.share) { if (navigator.share) {
try { try {
await navigator.share({ await navigator.share({
@@ -713,7 +712,7 @@ function IconAction({
}} }}
> >
<XStack alignItems="center" justifyContent="center"> <XStack alignItems="center" justifyContent="center">
{React.isValidElement(children) ? React.cloneElement(children, { color }) : children} {React.isValidElement(children) ? React.cloneElement(children as any, { color }) : children}
</XStack> </XStack>
</Pressable> </Pressable>
); );

View File

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

View File

@@ -453,7 +453,7 @@ export default function MobileEventPhotosPage() {
toast.error(t('mobilePhotos.loadFailed', 'Fotos konnten nicht geladen werden.')); toast.error(t('mobilePhotos.loadFailed', 'Fotos konnten nicht geladen werden.'));
} }
if (active) { if (active) {
setLightboxWithUrl(null, { replace: true }); setLightboxWithUrl(null);
} }
} finally { } finally {
if (active) { if (active) {
@@ -616,7 +616,7 @@ export default function MobileEventPhotosPage() {
function startAddonCheckout(scopeOrKey: 'photos' | 'gallery' | 'guests' | string) { function startAddonCheckout(scopeOrKey: 'photos' | 'gallery' | 'guests' | string) {
if (!slug) return; if (!slug) return;
const { scope, addonKey } = resolveScopeAndAddonKey(scopeOrKey); const { scope, addonKey } = resolveScopeAndAddonKey(scopeOrKey);
setConsentTarget({ scope, addonKey }); setConsentTarget({ scope: scope as any, addonKey });
setConsentOpen(true); setConsentOpen(true);
} }
@@ -635,7 +635,7 @@ export default function MobileEventPhotosPage() {
cancel_url: currentUrl, cancel_url: currentUrl,
accepted_terms: consents.acceptedTerms, accepted_terms: consents.acceptedTerms,
accepted_waiver: consents.acceptedWaiver, accepted_waiver: consents.acceptedWaiver,
}); } as any);
if (checkout.checkout_url) { if (checkout.checkout_url) {
window.location.href = checkout.checkout_url; window.location.href = checkout.checkout_url;
} else { } else {
@@ -710,7 +710,7 @@ export default function MobileEventPhotosPage() {
<XStack space="$2"> <XStack space="$2">
<CTAButton <CTAButton
label={selectionMode ? t('common.done', 'Done') : t('common.select', 'Select')} label={selectionMode ? t('common.done', 'Done') : t('common.select', 'Select')}
tone={selectionMode ? 'solid' : 'ghost'} tone={selectionMode ? 'primary' : 'ghost'}
fullWidth={false} fullWidth={false}
onPress={() => { onPress={() => {
if (selectionMode) { if (selectionMode) {
@@ -768,7 +768,7 @@ export default function MobileEventPhotosPage() {
addons={catalogAddons} addons={catalogAddons}
onCheckout={startAddonCheckout} onCheckout={startAddonCheckout}
busyScope={busyScope} busyScope={busyScope}
translate={translateLimits(t)} translate={translateLimits(t as any)}
textColor={text} textColor={text}
borderColor={border} borderColor={border}
/> />
@@ -1343,7 +1343,7 @@ function PhotoQuickActions({ photo, disabled = false, muted, surface, onAction }
key={action.key} key={action.key}
disabled={disabled} disabled={disabled}
aria-label={action.label} aria-label={action.label}
onPress={(event) => { onPress={(event: any) => {
event.stopPropagation(); event.stopPropagation();
if (!disabled) { if (!disabled) {
onAction(action.key); onAction(action.key);

View File

@@ -1,74 +1,72 @@
import React from 'react'; import React from 'react';
import { useLocation, useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Check, Copy, Download, Share2, Sparkles, Trophy, Users } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks'; import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text'; import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite'; import { Pressable } from '@tamagui/react-native-web-lite';
import { Sparkles, Camera, Link2, QrCode, RefreshCcw, Shield, Archive, ShoppingCart, Clock3, Share2 } from 'lucide-react'; import { MobileShell } from './components/MobileShell';
import toast from 'react-hot-toast'; import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Primitives';
import { LegalConsentSheet } from './components/LegalConsentSheet';
import { import {
getEvent, getEvent,
getEventStats, getEventStats,
getEventQrInvites, getEventQrInvites,
toggleEvent,
updateEvent, updateEvent,
createEventAddonCheckout, TenantEvent,
EventStats,
EventQrInvite,
EventAddonCatalogItem,
getAddonCatalog, getAddonCatalog,
submitTenantFeedback, createEventAddonCheckout,
type TenantEvent,
type EventStats,
type EventQrInvite,
type EventAddonCatalogItem,
} from '../api'; } from '../api';
import { isAuthError } from '../auth/tokens'; import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage } from '../lib/apiError'; import { getApiErrorMessage } from '../lib/apiError';
import { HeaderActionButton, MobileShell } from './components/MobileShell';
import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Primitives';
import { adminPath } from '../constants'; import { adminPath } from '../constants';
import { selectAddonKeyForScope } from './addons'; import toast from 'react-hot-toast';
import { LegalConsentSheet } from './components/LegalConsentSheet';
import { useBackNavigation } from './hooks/useBackNavigation'; import { useBackNavigation } from './hooks/useBackNavigation';
import { useAdminTheme } from './theme'; import { useAdminTheme } from './theme';
type GalleryCounts = {
photos: number;
likes: number;
pending: number;
};
export default function MobileEventRecapPage() { export default function MobileEventRecapPage() {
const { slug } = useParams<{ slug?: string }>(); const { slug } = useParams<{ slug: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const { t } = useTranslation('management'); const { t } = useTranslation('management');
const { textStrong, text, muted, subtle, danger, border, primary, accentSoft } = useAdminTheme(); const { textStrong, text, muted, border, primary, successText, danger } = useAdminTheme();
const [event, setEvent] = React.useState<TenantEvent | null>(null); const [event, setEvent] = React.useState<TenantEvent | null>(null);
const [stats, setStats] = React.useState<EventStats | null>(null); const [stats, setEventStats] = React.useState<EventStats | null>(null);
const [invites, setInvites] = React.useState<EventQrInvite[]>([]); const [invites, setInvites] = React.useState<EventQrInvite[]>([]);
const [addons, setAddons] = React.useState<EventAddonCatalogItem[]>([]); const [addons, setAddons] = React.useState<EventAddonCatalogItem[]>([]);
const [loading, setLoading] = React.useState(true); const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null); const [error, setError] = React.useState<string | null>(null);
const [busy, setBusy] = React.useState(false);
const [archiveBusy, setArchiveBusy] = React.useState(false);
const [checkoutBusy, setCheckoutBusy] = React.useState(false);
const [consentOpen, setConsentOpen] = React.useState(false); const [consentOpen, setConsentOpen] = React.useState(false);
const [consentBusy, setConsentBusy] = React.useState(false); const [busyScope, setBusyScope] = React.useState<string | null>(null);
const [consentAddonKey, setConsentAddonKey] = React.useState<string | null>(null);
const back = useBackNavigation(slug ? adminPath(`/mobile/events/${slug}`) : adminPath('/mobile/events')); const back = useBackNavigation(slug ? adminPath(`/mobile/events/${slug}`) : adminPath('/mobile/events'));
const load = React.useCallback(async () => { const load = React.useCallback(async () => {
if (!slug) return; if (!slug) return;
setLoading(true); setLoading(true);
try { try {
const [eventData, statsData, inviteData, addonData] = await Promise.all([ const [eventData, statsData, invitesData, addonsData] = await Promise.all([
getEvent(slug), getEvent(slug),
getEventStats(slug), getEventStats(slug),
getEventQrInvites(slug), getEventQrInvites(slug),
getAddonCatalog(), getAddonCatalog(),
]); ]);
setEvent(eventData); setEvent(eventData);
setStats(statsData); setEventStats(statsData);
setInvites(inviteData ?? []); setInvites(invitesData);
setAddons(addonData ?? []); setAddons(addonsData);
setError(null); setError(null);
} catch (err) { } catch (err) {
if (!isAuthError(err)) { if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('events.errors.loadFailed', 'Event konnte nicht geladen werden.'))); setError(getApiErrorMessage(err, t('events.errors.loadFailed', 'Recap konnte nicht geladen werden.')));
} }
} finally { } finally {
setLoading(false); setLoading(false);
@@ -79,323 +77,242 @@ export default function MobileEventRecapPage() {
void load(); void load();
}, [load]); }, [load]);
React.useEffect(() => { const handleCheckout = async (addonKey: string) => {
if (!location.search) return; if (!slug || busyScope) return;
const params = new URLSearchParams(location.search); setBusyScope(addonKey);
if (params.get('addon_success')) { try {
toast.success(t('events.success.addonApplied', 'Add-on angewendet. Limits aktualisieren sich in Kürze.')); const { checkout_url } = await createEventAddonCheckout(slug, {
params.delete('addon_success'); addon_key: addonKey,
navigate({ pathname: location.pathname, search: params.toString() ? `?${params.toString()}` : '' }, { replace: true }); success_url: window.location.href,
void load(); cancel_url: window.location.href,
});
if (checkout_url) {
window.location.href = checkout_url;
} }
}, [location.search, location.pathname, t, navigate, load]); } catch (err) {
toast.error(getApiErrorMessage(err, t('events.errors.checkoutFailed', 'Bezahlvorgang konnte nicht gestartet werden.')));
setBusyScope(null);
}
};
if (!slug) { const handleConsentConfirm = async (consents: { acceptedTerms: boolean }) => {
if (!slug || !busyScope) return;
try {
const { checkout_url } = await createEventAddonCheckout(slug, {
addon_key: busyScope,
success_url: window.location.href,
cancel_url: window.location.href,
accepted_terms: consents.acceptedTerms,
} as any);
if (checkout_url) {
window.location.href = checkout_url;
}
} catch (err) {
toast.error(getApiErrorMessage(err, t('events.errors.checkoutFailed', 'Bezahlvorgang konnte nicht gestartet werden.')));
setBusyScope(null);
setConsentOpen(false);
}
};
if (loading) {
return ( return (
<MobileShell activeTab="home" title={t('events.errors.notFoundTitle', 'Event nicht gefunden')} onBack={back}> <MobileShell activeTab="home" title={t('events.recap.title', 'Event Recap')} onBack={back}>
<YStack space="$3">
<SkeletonCard height={120} />
<SkeletonCard height={200} />
<SkeletonCard height={150} />
</YStack>
</MobileShell>
);
}
if (error || !event) {
return (
<MobileShell activeTab="home" title={t('events.recap.title', 'Event Recap')} onBack={back}>
<MobileCard> <MobileCard>
<Text color={danger}>{t('events.errors.notFoundBody', 'Kehre zur Eventliste zurück und wähle dort ein Event aus.')}</Text> <Text fontWeight="700" color={danger}>
{error || t('common.error', 'Ein Fehler ist aufgetreten.')}
</Text>
<CTAButton label={t('common.retry', 'Erneut versuchen')} onPress={load} tone="ghost" />
</MobileCard> </MobileCard>
</MobileShell> </MobileShell>
); );
} }
const activeInvite = invites.find((invite) => invite.is_active); const galleryCounts: GalleryCounts = {
const guestLink = event?.public_url ?? activeInvite?.url ?? (activeInvite?.token ? `/g/${activeInvite.token}` : null); photos: stats?.total ?? 0,
const galleryCounts = { likes: stats?.likes ?? 0,
photos: stats?.uploads_total ?? stats?.total ?? 0,
pending: stats?.pending_photos ?? 0, pending: stats?.pending_photos ?? 0,
likes: stats?.likes_total ?? stats?.likes ?? 0,
}; };
async function toggleGallery() { const activeInvite = invites.find((i) => i.is_active) ?? invites[0] ?? null;
if (!slug) return; const guestLink = activeInvite?.url ?? '';
setBusy(true);
try {
const updated = await toggleEvent(slug);
setEvent(updated);
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('events.errors.toggleFailed', 'Status konnte nicht angepasst werden.')));
}
} finally {
setBusy(false);
}
}
async function archiveEvent() {
if (!slug || !event) return;
setArchiveBusy(true);
try {
const updated = await updateEvent(slug, { status: 'archived', is_active: false });
setEvent(updated);
toast.success(t('events.recap.archivedSuccess', 'Event archiviert. Galerie ist geschlossen.'));
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('events.errors.archiveFailed', 'Archivierung fehlgeschlagen.')));
}
} finally {
setArchiveBusy(false);
}
}
function startAddonCheckout() {
if (!slug) return;
const addonKey = selectAddonKeyForScope(addons, 'gallery');
setConsentAddonKey(addonKey);
setConsentOpen(true);
}
async function confirmAddonCheckout(consents: { acceptedTerms: boolean; acceptedWaiver: boolean }) {
if (!slug || !consentAddonKey) return;
const currentUrl = typeof window !== 'undefined' ? `${window.location.origin}${adminPath(`/mobile/events/${slug}/recap`)}` : '';
const successUrl = `${currentUrl}?addon_success=1`;
setCheckoutBusy(true);
setConsentBusy(true);
try {
const checkout = await createEventAddonCheckout(slug, {
addon_key: consentAddonKey,
quantity: 1,
success_url: successUrl,
cancel_url: currentUrl,
accepted_terms: consents.acceptedTerms,
accepted_waiver: consents.acceptedWaiver,
});
if (checkout.checkout_url) {
window.location.href = checkout.checkout_url;
} else {
toast.error(t('events.errors.checkoutMissing', 'Checkout konnte nicht gestartet werden.'));
}
} catch (err) {
if (!isAuthError(err)) {
toast.error(getApiErrorMessage(err, t('events.errors.checkoutFailed', 'Add-on Checkout fehlgeschlagen.')));
}
} finally {
setCheckoutBusy(false);
setConsentBusy(false);
setConsentOpen(false);
setConsentAddonKey(null);
}
}
async function submitFeedback(sentiment: 'positive' | 'neutral' | 'negative') {
if (!event) return;
try {
await submitTenantFeedback({
category: 'event_workspace_after_event',
event_slug: event.slug,
sentiment,
metadata: {
event_name: resolveName(event.name),
guest_link: guestLink,
},
});
toast.success(t('events.feedback.submitted', 'Danke!'));
} catch (err) {
toast.error(getApiErrorMessage(err, t('events.feedback.genericError', 'Feedback konnte nicht gesendet werden.')));
}
}
return ( return (
<MobileShell <MobileShell
activeTab="home" activeTab="home"
title={event ? resolveName(event.name) : t('events.placeholders.untitled', 'Unbenanntes Event')} title={t('events.recap.title', 'Event Recap')}
subtitle={event?.event_date ? formatDate(event.event_date) : undefined}
onBack={back} onBack={back}
headerActions={
<HeaderActionButton onPress={() => load()} ariaLabel={t('common.refresh', 'Refresh')}>
<RefreshCcw size={18} color={textStrong} />
</HeaderActionButton>
}
> >
{error ? ( <YStack space="$4">
<MobileCard> {/* Status & Summary */}
<Text color={danger}>{error}</Text> <MobileCard space="$3">
</MobileCard>
) : null}
{loading ? (
<YStack space="$2">
{Array.from({ length: 4 }).map((_, idx) => (
<SkeletonCard key={`sk-${idx}`} height={90} />
))}
</YStack>
) : event && stats ? (
<YStack space="$3">
<MobileCard space="$2">
<XStack alignItems="center" justifyContent="space-between"> <XStack alignItems="center" justifyContent="space-between">
<YStack space="$1"> <YStack space="$1">
<Text fontSize="$xs" color={muted} fontWeight="700" letterSpacing={1.2}> <Text fontSize="$xl" fontWeight="800" color={textStrong}>
{t('events.recap.badge', 'Nachbereitung')} {t('events.recap.done', 'Event beendet')}
</Text> </Text>
<Text fontSize="$lg" fontWeight="800" color={textStrong}>{resolveName(event.name)}</Text>
<Text fontSize="$sm" color={muted}> <Text fontSize="$sm" color={muted}>
{t('events.recap.subtitle', 'Abschluss, Export und Galerie-Laufzeit verwalten.')} {formatDate(event.event_date)}
</Text> </Text>
</YStack> </YStack>
<PillBadge tone={event.is_active ? 'success' : 'muted'}> <PillBadge tone="success">{t('events.recap.statusClosed', 'Archiviert')}</PillBadge>
{event.is_active ? t('events.recap.galleryOpen', 'Galerie geöffnet') : t('events.recap.galleryClosed', 'Galerie geschlossen')}
</PillBadge>
</XStack> </XStack>
<XStack space="$2" marginTop="$2" flexWrap="wrap">
<CTAButton <XStack flexWrap="wrap" gap="$2" marginTop="$1">
label={event.is_active ? t('events.recap.closeGallery', 'Galerie schließen') : t('events.recap.openGallery', 'Galerie öffnen')} <Stat label={t('events.stats.uploads', 'Uploads')} value={String(galleryCounts.photos)} />
onPress={toggleGallery} <Stat label={t('events.stats.pending', 'Offen')} value={String(galleryCounts.pending)} />
loading={busy} <Stat label={t('events.stats.likes', 'Likes')} value={String(galleryCounts.likes)} />
/>
<CTAButton label={t('events.recap.moderate', 'Uploads ansehen')} tone="ghost" onPress={() => navigate(adminPath(`/mobile/events/${slug}/photos`))} />
<CTAButton label={t('events.actions.edit', 'Bearbeiten')} tone="ghost" onPress={() => navigate(adminPath(`/mobile/events/${slug}/edit`))} />
</XStack> </XStack>
</MobileCard> </MobileCard>
<MobileCard space="$2"> {/* Share Section */}
<XStack alignItems="center" justifyContent="space-between"> <MobileCard space="$3">
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('events.recap.galleryTitle', 'Galerie-Status')}
</Text>
<PillBadge tone="muted">{t('events.recap.galleryCounts', '{{photos}} Fotos, {{pending}} offen, {{likes}} Likes', galleryCounts)}</PillBadge>
</XStack>
<XStack space="$2" marginTop="$2" flexWrap="wrap">
<Stat pill label={t('events.stats.uploads', 'Uploads')} value={String(galleryCounts.photos)} />
<Stat pill label={t('events.stats.pending', 'Offen')} value={String(galleryCounts.pending)} />
<Stat pill label={t('events.stats.likes', 'Likes')} value={String(galleryCounts.likes)} />
</XStack>
</MobileCard>
<MobileCard space="$2">
<XStack alignItems="center" space="$2"> <XStack alignItems="center" space="$2">
<Link2 size={16} color={textStrong} /> <Share2 size={18} color={primary} />
<Text fontSize="$md" fontWeight="800" color={textStrong}> <Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('events.recap.shareLink', 'Gäste-Link')} {t('events.recap.shareGallery', 'Galerie teilen')}
</Text> </Text>
</XStack> </XStack>
{guestLink ? ( <Text fontSize="$sm" color={text}>
<Text fontSize="$sm" color={text} selectable> {t('events.recap.shareBody', 'Deine Gäste können die Galerie auch nach dem Event weiterhin ansehen.')}
</Text>
<YStack space="$2" marginTop="$1">
<XStack
backgroundColor={border}
padding="$3"
borderRadius={12}
alignItems="center"
justifyContent="space-between"
>
<Text fontSize="$xs" color={muted} numberOfLines={1} flex={1}>
{guestLink} {guestLink}
</Text> </Text>
) : (
<Text fontSize="$sm" color={muted}>
{t('events.recap.noPublicUrl', 'Kein Gäste-Link gesetzt. Lege den öffentlichen Link im Event-Setup fest.')}
</Text>
)}
<XStack space="$2" marginTop="$2">
<CTAButton label={t('events.recap.copy', 'Kopieren')} tone="ghost" onPress={() => guestLink && copyToClipboard(guestLink, t)} /> <CTAButton label={t('events.recap.copy', 'Kopieren')} tone="ghost" onPress={() => guestLink && copyToClipboard(guestLink, t)} />
{guestLink ? ( </XStack>
{typeof navigator !== 'undefined' && !!navigator.share && (
<CTAButton label={t('events.recap.share', 'Teilen')} tone="ghost" onPress={() => shareLink(guestLink, event, t)} /> <CTAButton label={t('events.recap.share', 'Teilen')} tone="ghost" onPress={() => shareLink(guestLink, event, t)} />
) : null} )}
</XStack> </YStack>
{activeInvite?.qr_code_data_url ? ( {activeInvite?.qr_code_data_url ? (
<XStack space="$2" alignItems="center" marginTop="$2"> <YStack alignItems="center" space="$2" marginTop="$2">
<img <YStack
src={activeInvite.qr_code_data_url} padding="$2"
alt={t('events.qr.qrAlt', 'QR code')} backgroundColor="white"
style={{ width: 96, height: 96 }} borderRadius={12}
/> borderWidth={1}
<CTAButton label={t('events.recap.downloadQr', 'QR herunterladen')} tone="ghost" onPress={() => downloadQr(activeInvite.qr_code_data_url)} /> borderColor={border}
</XStack> >
) : null} <img src={activeInvite.qr_code_data_url} alt="QR" style={{ width: 120, height: 120 }} />
</MobileCard> </YStack>
<CTAButton label={t('events.recap.downloadQr', 'QR herunterladen')} tone="ghost" onPress={() => downloadQr(activeInvite.qr_code_data_url!)} />
<MobileCard space="$2">
<XStack alignItems="center" space="$2">
<ShoppingCart size={16} color={textStrong} />
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('events.sections.addons.title', 'Add-ons & Upgrades')}
</Text>
</XStack>
<Text fontSize="$sm" color={muted}>
{t('events.sections.addons.description', 'Zusätzliche Kontingente freischalten.')}
</Text>
<CTAButton
label={t('events.recap.extendGallery', 'Galerie verlängern')}
onPress={() => {
startAddonCheckout();
}}
loading={checkoutBusy}
/>
</MobileCard>
<MobileCard space="$2">
<XStack alignItems="center" space="$2">
<Shield size={16} color={textStrong} />
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('events.recap.settingsTitle', 'Gast-Einstellungen')}
</Text>
</XStack>
<ToggleRow
label={t('events.recap.downloads', 'Downloads erlauben')}
value={Boolean(event.settings?.guest_downloads_enabled)}
onToggle={(value) => updateSetting(event, setEvent, slug, 'guest_downloads_enabled', value, setError, t)}
/>
<ToggleRow
label={t('events.recap.sharing', 'Sharing erlauben')}
value={Boolean(event.settings?.guest_sharing_enabled)}
onToggle={(value) => updateSetting(event, setEvent, slug, 'guest_sharing_enabled', value, setError, t)}
/>
</MobileCard>
<MobileCard space="$2">
<XStack alignItems="center" space="$2">
<Archive size={16} color={textStrong} />
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('events.recap.archiveTitle', 'Event archivieren')}
</Text>
</XStack>
<Text fontSize="$sm" color={muted}>
{t('events.recap.archiveCopy', 'Schließt die Galerie und markiert das Event als abgeschlossen.')}
</Text>
<CTAButton label={t('events.recap.archive', 'Archivieren')} onPress={() => archiveEvent()} loading={archiveBusy} />
</MobileCard>
<MobileCard space="$2">
<XStack alignItems="center" space="$2">
<Sparkles size={16} color={textStrong} />
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('events.recap.feedbackTitle', 'Wie lief das Event?')}
</Text>
</XStack>
<XStack space="$2" marginTop="$2" flexWrap="wrap">
<CTAButton label={t('events.feedback.positive', 'War super')} tone="ghost" onPress={() => void submitFeedback('positive')} />
<CTAButton label={t('events.feedback.neutral', 'In Ordnung')} tone="ghost" onPress={() => void submitFeedback('neutral')} />
<CTAButton label={t('events.feedback.negative', 'Brauch(t)e Unterstützung')} tone="ghost" onPress={() => void submitFeedback('negative')} />
</XStack>
</MobileCard>
</YStack> </YStack>
) : null} ) : null}
</MobileCard>
{/* Settings */}
<MobileCard space="$3">
<XStack alignItems="center" space="$2">
<Users size={18} color={primary} />
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('events.recap.settings', 'Nachlauf-Optionen')}
</Text>
</XStack>
<YStack space="$1.5">
<ToggleOption
label={t('events.recap.allowDownloads', 'Gäste dürfen Fotos laden')}
value={Boolean(event.settings?.guest_downloads_enabled)}
onToggle={(value) => updateSetting(event, setEvent, slug, 'guest_downloads_enabled', value, setError, t as any)}
/>
<ToggleOption
label={t('events.recap.allowSharing', 'Gäste dürfen Fotos teilen')}
value={Boolean(event.settings?.guest_sharing_enabled)}
onToggle={(value) => updateSetting(event, setEvent, slug, 'guest_sharing_enabled', value, setError, t as any)}
/>
</YStack>
</MobileCard>
{/* Extensions */}
<MobileCard space="$3">
<XStack alignItems="center" space="$2">
<Sparkles size={18} color={primary} />
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('events.recap.addons', 'Galerie verlängern')}
</Text>
</XStack>
<Text fontSize="$sm" color={text}>
{t('events.recap.addonBody', 'Die Online-Zeit deiner Galerie neigt sich dem Ende? Hier kannst du sie verlängern.')}
</Text>
<YStack space="$2">
{addons
.filter((a) => a.key === 'gallery_extension')
.map((addon) => (
<CTAButton
key={addon.key}
label={t('events.recap.buyExtension', 'Galerie um 30 Tage verlängern')}
onPress={() => handleCheckout(addon.key)}
loading={busyScope === addon.key}
/>
))}
</YStack>
</MobileCard>
</YStack>
<LegalConsentSheet <LegalConsentSheet
open={consentOpen} open={consentOpen}
onClose={() => { onClose={() => {
if (consentBusy) return;
setConsentOpen(false); setConsentOpen(false);
setConsentAddonKey(null); setBusyScope(null);
}} }}
onConfirm={confirmAddonCheckout} onConfirm={handleConsentConfirm}
busy={consentBusy} busy={Boolean(busyScope)}
t={t} t={t as any}
/> />
</MobileShell> </MobileShell>
); );
} }
function Stat({ label, value }: { label: string; value: string }) { function Stat({ label, value, pill }: { label: string; value: string; pill?: boolean }) {
const { border, muted, textStrong } = useAdminTheme(); const { textStrong, muted, accentSoft, border } = useAdminTheme();
return ( return (
<MobileCard borderColor={border} space="$1.5"> <YStack
paddingHorizontal="$3"
paddingVertical="$2"
borderRadius={12}
borderWidth={1}
borderColor={border}
backgroundColor={pill ? accentSoft : 'transparent'}
minWidth={80}
>
<Text fontSize="$xs" color={muted}> <Text fontSize="$xs" color={muted}>
{label} {label}
</Text> </Text>
<Text fontSize="$md" fontWeight="800" color={textStrong}> <Text fontSize="$md" fontWeight="800" color={textStrong}>
{value} {value}
</Text> </Text>
</MobileCard> </YStack>
); );
} }
function ToggleRow({ label, value, onToggle }: { label: string; value: boolean; onToggle: (value: boolean) => void }) { function ToggleOption({ label, value, onToggle }: { label: string; value: boolean; onToggle: (val: boolean) => void }) {
const { textStrong } = useAdminTheme(); const { textStrong } = useAdminTheme();
return ( return (
<XStack alignItems="center" justifyContent="space-between" marginTop="$1.5"> <XStack alignItems="center" justifyContent="space-between" paddingVertical="$1">
<Text fontSize="$sm" color={textStrong}> <Text fontSize="$sm" color={textStrong} fontWeight="600">
{label} {label}
</Text> </Text>
<input <input
@@ -433,27 +350,25 @@ async function updateSetting(
} }
} }
function copyToClipboard(value: string, t: (key: string, fallback?: string) => string) { function copyToClipboard(value: string, t: any) {
navigator.clipboard if (typeof window !== 'undefined') {
.writeText(value) void window.navigator.clipboard.writeText(value);
.then(() => toast.success(t('events.recap.copySuccess', 'Link kopiert'))) toast.success(t('events.recap.copySuccess', 'Link kopiert'));
.catch(() => toast.error(t('events.recap.copyError', 'Link konnte nicht kopiert werden.'))); }
} }
async function shareLink(value: string, event: TenantEvent, t: (key: string, fallback?: string) => string) { async function shareLink(value: string, event: TenantEvent | null, t: any) {
if (navigator.share) { if (typeof window !== 'undefined' && navigator.share) {
try { try {
await navigator.share({ await navigator.share({
title: resolveName(event.name), title: resolveName(event?.name ?? ''),
text: t('events.recap.shareGuests', 'Gäste-Galerie teilen'), text: t('events.recap.shareText', 'Schau dir die Fotos von unserem Event an!'),
url: value, url: value,
}); });
return;
} catch { } catch {
// ignore // silently ignore or fallback to copy
} }
} }
copyToClipboard(value, t);
} }
function downloadQr(dataUrl: string) { function downloadQr(dataUrl: string) {

View File

@@ -183,7 +183,7 @@ export default function MobileEventTasksPage() {
setSearchTerm(''); setSearchTerm('');
}, [slug]); }, [slug]);
const scrollToSection = (ref: React.RefObject<HTMLDivElement>) => { const scrollToSection = (ref: React.RefObject<HTMLDivElement | null>) => {
if (ref.current) { if (ref.current) {
ref.current.scrollIntoView({ behavior: 'smooth', block: 'start' }); ref.current.scrollIntoView({ behavior: 'smooth', block: 'start' });
} }
@@ -561,8 +561,8 @@ export default function MobileEventTasksPage() {
/> />
</XStack> </XStack>
</MobileCard> </MobileCard>
<YGroup borderWidth={1} borderColor={border} borderRadius="$4" overflow="hidden"> <YGroup {...({ borderWidth: 1, borderColor: border, borderRadius: "$4", overflow: "hidden" } as any)}>
<YGroup.Item bordered> <YGroup.Item>
<ListItem <ListItem
hoverTheme hoverTheme
pressTheme pressTheme
@@ -661,9 +661,9 @@ export default function MobileEventTasksPage() {
<Text fontSize="$sm" color={muted}> <Text fontSize="$sm" color={muted}>
{t('events.tasks.count', '{{count}} Tasks', { count: filteredTasks.length })} {t('events.tasks.count', '{{count}} Tasks', { count: filteredTasks.length })}
</Text> </Text>
<YGroup borderWidth={1} borderColor={border} borderRadius="$4" overflow="hidden"> <YGroup {...({ borderWidth: 1, borderColor: border, borderRadius: "$4", overflow: "hidden" } as any)}>
{filteredTasks.map((task, idx) => ( {filteredTasks.map((task, idx) => (
<YGroup.Item key={task.id} bordered={idx < filteredTasks.length - 1}> <YGroup.Item key={task.id}>
<ListItem <ListItem
hoverTheme hoverTheme
pressTheme pressTheme
@@ -694,7 +694,7 @@ export default function MobileEventTasksPage() {
icon={<Trash2 size={14} color={dangerText} />} icon={<Trash2 size={14} color={dangerText} />}
aria-label={t('events.tasks.remove', 'Remove task')} aria-label={t('events.tasks.remove', 'Remove task')}
disabled={busyId === task.id} disabled={busyId === task.id}
onPress={(event) => { onPress={(event: any) => {
event?.stopPropagation?.(); event?.stopPropagation?.();
setDeleteCandidate(task); setDeleteCandidate(task);
}} }}
@@ -729,9 +729,9 @@ export default function MobileEventTasksPage() {
{t('events.tasks.libraryEmpty', 'Keine weiteren Aufgaben verfügbar.')} {t('events.tasks.libraryEmpty', 'Keine weiteren Aufgaben verfügbar.')}
</Text> </Text>
) : ( ) : (
<YGroup borderWidth={1} borderColor={border} borderRadius="$4" overflow="hidden"> <YGroup {...({ borderWidth: 1, borderColor: border, borderRadius: "$4", overflow: "hidden" } as any)}>
{(expandedLibrary ? library : library.slice(0, 6)).map((task, idx, arr) => ( {(expandedLibrary ? library : library.slice(0, 6)).map((task, idx, arr) => (
<YGroup.Item key={`lib-${task.id}`} bordered={idx < arr.length - 1}> <YGroup.Item key={`lib-${task.id}`}>
<ListItem <ListItem
hoverTheme hoverTheme
pressTheme pressTheme
@@ -786,9 +786,9 @@ export default function MobileEventTasksPage() {
{t('events.tasks.collectionsEmpty', 'Keine Pakete vorhanden.')} {t('events.tasks.collectionsEmpty', 'Keine Pakete vorhanden.')}
</Text> </Text>
) : ( ) : (
<YGroup borderWidth={1} borderColor={border} borderRadius="$4" overflow="hidden"> <YGroup {...({ borderWidth: 1, borderColor: border, borderRadius: "$4", overflow: "hidden" } as any)}>
{(expandedCollections ? collections : collections.slice(0, 6)).map((collection, idx, arr) => ( {(expandedCollections ? collections : collections.slice(0, 6)).map((collection, idx, arr) => (
<YGroup.Item key={collection.id} bordered={idx < arr.length - 1}> <YGroup.Item key={collection.id}>
<ListItem <ListItem
hoverTheme hoverTheme
pressTheme pressTheme
@@ -917,9 +917,9 @@ export default function MobileEventTasksPage() {
style={{ padding: 0 }} style={{ padding: 0 }}
/> />
</MobileField> </MobileField>
<YGroup borderWidth={1} borderColor={border} borderRadius="$4" overflow="hidden"> <YGroup {...({ borderWidth: 1, borderColor: border, borderRadius: "$4", overflow: "hidden" } as any)}>
{emotions.map((em, idx) => ( {emotions.map((em, idx) => (
<YGroup.Item key={`emo-${em.id}`} bordered={idx < emotions.length - 1}> <YGroup.Item key={`emo-${em.id}`}>
<ListItem <ListItem
hoverTheme hoverTheme
pressTheme pressTheme
@@ -1000,9 +1000,9 @@ export default function MobileEventTasksPage() {
}} }}
> >
<AlertDialog.Portal> <AlertDialog.Portal>
<AlertDialog.Overlay backgroundColor={`${overlay}66`} /> <AlertDialog.Overlay backgroundColor={`${overlay}66` as any} />
<AlertDialog.Content <AlertDialog.Content
borderRadius={20} {...({ borderRadius: 20 } as any)}
borderWidth={1} borderWidth={1}
borderColor={border} borderColor={border}
backgroundColor={surface} backgroundColor={surface}
@@ -1058,8 +1058,8 @@ export default function MobileEventTasksPage() {
title={t('events.tasks.actions', 'Aktionen')} title={t('events.tasks.actions', 'Aktionen')}
footer={null} footer={null}
> >
<YGroup borderWidth={1} borderColor={border} borderRadius="$4" overflow="hidden"> <YGroup {...({ borderWidth: 1, borderColor: border, borderRadius: "$4", overflow: "hidden" } as any)}>
<YGroup.Item bordered> <YGroup.Item>
<ListItem <ListItem
hoverTheme hoverTheme
pressTheme pressTheme
@@ -1077,7 +1077,7 @@ export default function MobileEventTasksPage() {
iconAfter={<ChevronRight size={14} color={subtle} />} iconAfter={<ChevronRight size={14} color={subtle} />}
/> />
</YGroup.Item> </YGroup.Item>
<YGroup.Item bordered> <YGroup.Item>
<ListItem <ListItem
hoverTheme hoverTheme
pressTheme pressTheme

View File

@@ -123,10 +123,14 @@ export default function MobileLoginPage() {
(mutation as { isPending?: boolean; isLoading?: boolean }).isPending ?? (mutation as { isPending?: boolean; isLoading?: boolean }).isPending ??
(mutation as { isPending?: boolean; isLoading?: boolean }).isLoading ?? (mutation as { isPending?: boolean; isLoading?: boolean }).isLoading ??
false; false;
const isFormValid = login.trim().length > 0 && password.length > 0;
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => { const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
setError(null); setError(null);
if (!isFormValid) {
return;
}
mutation.mutate({ mutation.mutate({
login, login,
password, password,
@@ -228,7 +232,7 @@ export default function MobileLoginPage() {
<Button <Button
type="submit" type="submit"
disabled={isSubmitting} disabled={isSubmitting || !isFormValid}
height={52} height={52}
borderRadius={16} borderRadius={16}
backgroundColor={primary} backgroundColor={primary}

View File

@@ -334,7 +334,7 @@ export default function MobileNotificationsPage() {
const reload = React.useCallback(async () => { const reload = React.useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
const data = await loadNotifications(t, events, { scope: scopeParam, status: statusParam, eventSlug: slug }); const data = await loadNotifications(t as any, events, { scope: scopeParam, status: statusParam, eventSlug: slug });
setNotifications(data); setNotifications(data);
setError(null); setError(null);
} catch (err) { } catch (err) {
@@ -691,6 +691,7 @@ export default function MobileNotificationsPage() {
} }
}} }}
title={selectedNotification?.title ?? t('mobileNotifications.title', 'Notifications')} title={selectedNotification?.title ?? t('mobileNotifications.title', 'Notifications')}
snapPoints={[94]}
footer={ footer={
selectedNotification && !selectedNotification.is_read ? ( selectedNotification && !selectedNotification.is_read ? (
<CTAButton label={t('notificationLogs.markRead', 'Mark as read')} onPress={() => markSelectedRead()} /> <CTAButton label={t('notificationLogs.markRead', 'Mark as read')} onPress={() => markSelectedRead()} />
@@ -705,7 +706,7 @@ export default function MobileNotificationsPage() {
<Text fontSize="$sm" color={muted}> <Text fontSize="$sm" color={muted}>
{selectedNotification.body} {selectedNotification.body}
</Text> </Text>
<XStack space="$2"> <XStack space="$2" flexWrap="wrap" style={{ rowGap: 8 }}>
<PillBadge tone={selectedNotification.tone === 'warning' ? 'warning' : 'muted'}> <PillBadge tone={selectedNotification.tone === 'warning' ? 'warning' : 'muted'}>
{selectedNotification.scope} {selectedNotification.scope}
</PillBadge> </PillBadge>

View File

@@ -0,0 +1,558 @@
import React from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
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 { MobileShell } from './components/MobileShell';
import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Primitives';
import { useAdminTheme } from './theme';
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, 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);
const recommendedFeature = searchParams.get('feature');
const { data: catalog, isLoading: loadingCatalog } = useQuery({
queryKey: ['packages', 'endcustomer'],
queryFn: () => getPackages('endcustomer'),
});
const { data: inventory, isLoading: loadingInventory } = useQuery({
queryKey: ['tenant-packages-overview'],
queryFn: () => getTenantPackagesOverview({ force: true }),
});
const isLoading = loadingCatalog || loadingInventory;
if (isLoading) {
return (
<MobileShell title={t('shop.title', 'Upgrade Package')} onBack={() => navigate(-1)} activeTab="profile">
<YStack space="$3">
<SkeletonCard height={150} />
<SkeletonCard height={150} />
</YStack>
</MobileShell>
);
}
if (selectedPackage) {
return (
<CheckoutConfirmation
pkg={selectedPackage}
onCancel={() => setSelectedPackage(null)}
/>
);
}
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) => {
if (recommendedPackageId) {
if (a.id === recommendedPackageId && b.id !== recommendedPackageId) return -1;
if (b.id === recommendedPackageId && a.id !== recommendedPackageId) return 1;
}
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">
{recommendedFeature && (
<MobileCard borderColor={primary} backgroundColor={accentSoft} space="$2" padding="$3">
<XStack space="$2" alignItems="center">
<Sparkles size={16} color={primary} />
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{t('shop.recommendationTitle', 'Recommended for you')}
</Text>
</XStack>
<Text fontSize="$xs" color={muted}>
{t('shop.recommendationBody', 'The highlighted package includes the feature you requested.')}
</Text>
</MobileCard>
)}
<YStack paddingHorizontal="$2">
<Text fontSize="$sm" color={muted}>
{t('shop.subtitle', 'Choose a package to unlock more features and limits.')}
</Text>
</YStack>
{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}
<YStack space="$3">
{viewMode === 'compare' ? (
<PackageShopCompareView
entries={packageEntries}
onSelect={(pkg) => setSelectedPackage(pkg)}
/>
) : (
packageEntries.map((entry) => (
<PackageShopCard
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>
);
}
function PackageShopCard({
pkg,
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 statusLabel = getPackageStatusLabel({ t, isActive, owned });
const isSubdued = Boolean((isDowngrade || !isUpgrade) && !isActive);
const canSelect = canSelectPackage(isUpgrade, isActive);
return (
<MobileCard
onPress={canSelect ? onSelect : undefined}
borderColor={isRecommended ? primary : (isActive ? '$green8' : border)}
borderWidth={isRecommended || isActive ? 2 : 1}
space="$3"
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">
<XStack space="$2" alignItems="center">
<Text fontSize="$lg" fontWeight="800" color={textStrong}>
{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>
<XStack space="$2" alignItems="center">
<Text fontSize="$md" color={primary} fontWeight="700">
{new Intl.NumberFormat(undefined, { style: 'currency', currency: 'EUR' }).format(pkg.price)}
</Text>
{statusLabel && (
<Text fontSize="$xs" color={muted} fontWeight="600">
{statusLabel}
</Text>
)}
</XStack>
</YStack>
<YStack marginTop="$2">
<ChevronRight size={20} color={muted} />
</YStack>
</XStack>
<YStack space="$1.5">
{pkg.max_photos ? (
<FeatureRow label={t('shop.limits.photos', '{{count}} Photos', { count: pkg.max_photos })} />
) : (
<FeatureRow label={t('shop.limits.unlimitedPhotos', 'Unlimited Photos')} />
)}
{pkg.gallery_days ? (
<FeatureRow label={t('shop.limits.days', '{{count}} Days Gallery', { count: pkg.gallery_days })} />
) : null}
{/* Render specific feature if it was requested */}
{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')
: isUpgrade
? t('shop.select', 'Select')
: t('shop.selectDisabled', 'Not available')
}
onPress={canSelect ? onSelect : undefined}
tone={isActive || !isUpgrade ? 'ghost' : 'primary'}
disabled={!canSelect}
/>
</MobileCard>
);
}
function FeatureRow({ label }: { label: string }) {
const { textStrong, primary } = useAdminTheme();
return (
<XStack alignItems="center" space="$2">
<Check size={14} color={primary} />
<Text fontSize="$sm" color={textStrong}>{label}</Text>
</XStack>
)
}
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 } = useAdminTheme();
const [agbAccepted, setAgbAccepted] = React.useState(false);
const [withdrawalAccepted, setWithdrawalAccepted] = React.useState(false);
const { busy, startCheckout } = usePackageCheckout();
const canProceed = agbAccepted && withdrawalAccepted;
const handleCheckout = async () => {
if (!canProceed || busy) return;
await startCheckout(pkg.id);
};
return (
<MobileShell title={t('shop.confirmTitle', 'Confirm Purchase')} onBack={onCancel} activeTab="profile">
<YStack space="$4">
<MobileCard space="$2" borderColor={border}>
<Text fontSize="$sm" color={muted}>{t('shop.confirmSubtitle', 'You are upgrading to:')}</Text>
<Text fontSize="$xl" fontWeight="800" color={textStrong}>{pkg.name}</Text>
<Text fontSize="$lg" color={primary} fontWeight="700">
{new Intl.NumberFormat(undefined, { style: 'currency', currency: 'EUR' }).format(pkg.price)}
</Text>
</MobileCard>
<MobileCard space="$3" borderColor={border}>
<XStack alignItems="center" space="$2">
<ShieldCheck size={18} color={textStrong} />
<Text fontSize="$md" fontWeight="700" color={textStrong}>{t('shop.legalTitle', 'Terms & Conditions')}</Text>
</XStack>
<XStack space="$3" alignItems="flex-start">
<Checkbox
id="agb"
size="$4"
checked={agbAccepted}
onCheckedChange={(checked) => setAgbAccepted(!!checked)}
>
<Checkbox.Indicator>
<Check />
</Checkbox.Indicator>
</Checkbox>
<Text fontSize="$sm" color={textStrong} flex={1} onPress={() => setAgbAccepted(!agbAccepted)}>
{t('shop.legal.agb', 'I agree to the Terms and Conditions and Privacy Policy.')}
</Text>
</XStack>
<XStack space="$3" alignItems="flex-start">
<Checkbox
id="withdrawal"
size="$4"
checked={withdrawalAccepted}
onCheckedChange={(checked) => setWithdrawalAccepted(!!checked)}
>
<Checkbox.Indicator>
<Check />
</Checkbox.Indicator>
</Checkbox>
<Text fontSize="$sm" color={textStrong} flex={1} onPress={() => setWithdrawalAccepted(!withdrawalAccepted)}>
{t('shop.legal.withdrawal', 'I agree that the contract execution begins immediately and I lose my right of withdrawal.')}
</Text>
</XStack>
</MobileCard>
<YStack space="$2">
<CTAButton
label={busy ? t('shop.processing', 'Processing...') : t('shop.payNow', 'Pay Now')}
onPress={handleCheckout}
disabled={!canProceed || busy}
tone="primary"
/>
<CTAButton
label={t('common.cancel', 'Cancel')}
onPress={onCancel}
tone="ghost"
disabled={busy}
/>
</YStack>
</YStack>
</MobileShell>
);
}

View File

@@ -82,9 +82,10 @@ export default function MobileProfilePage() {
<Text fontSize="$md" fontWeight="800" color={textColor}> <Text fontSize="$md" fontWeight="800" color={textColor}>
{t('mobileProfile.settings', 'Settings')} {t('mobileProfile.settings', 'Settings')}
</Text> </Text>
<YGroup borderRadius="$4" borderWidth={1} borderColor={borderColor} overflow="hidden"> <YStack space="$4">
<YGroup.Item bordered> <YGroup {...({ borderRadius: "$4", borderWidth: 1, borderColor: borderColor, overflow: "hidden" } as any)}>
<Pressable onPress={() => navigate(adminPath('/mobile/settings'))}> <YGroup.Item>
<Pressable onPress={() => navigate(adminPath('/mobile/profile/security'))}>
<ListItem <ListItem
hoverTheme hoverTheme
pressTheme pressTheme
@@ -99,7 +100,7 @@ export default function MobileProfilePage() {
/> />
</Pressable> </Pressable>
</YGroup.Item> </YGroup.Item>
<YGroup.Item bordered> <YGroup.Item>
<Pressable onPress={() => navigate(adminPath('/mobile/billing#packages'))}> <Pressable onPress={() => navigate(adminPath('/mobile/billing#packages'))}>
<ListItem <ListItem
hoverTheme hoverTheme
@@ -115,7 +116,7 @@ export default function MobileProfilePage() {
/> />
</Pressable> </Pressable>
</YGroup.Item> </YGroup.Item>
<YGroup.Item bordered> <YGroup.Item>
<Pressable onPress={() => navigate(adminPath('/mobile/billing#invoices'))}> <Pressable onPress={() => navigate(adminPath('/mobile/billing#invoices'))}>
<ListItem <ListItem
hoverTheme hoverTheme
@@ -131,7 +132,7 @@ export default function MobileProfilePage() {
/> />
</Pressable> </Pressable>
</YGroup.Item> </YGroup.Item>
<YGroup.Item bordered> <YGroup.Item>
<Pressable onPress={() => navigate(ADMIN_DATA_EXPORTS_PATH)}> <Pressable onPress={() => navigate(ADMIN_DATA_EXPORTS_PATH)}>
<ListItem <ListItem
hoverTheme hoverTheme
@@ -147,7 +148,7 @@ export default function MobileProfilePage() {
/> />
</Pressable> </Pressable>
</YGroup.Item> </YGroup.Item>
<YGroup.Item bordered> <YGroup.Item>
<ListItem <ListItem
paddingVertical="$2" paddingVertical="$2"
paddingHorizontal="$3" paddingHorizontal="$3"
@@ -203,6 +204,7 @@ export default function MobileProfilePage() {
/> />
</YGroup.Item> </YGroup.Item>
</YGroup> </YGroup>
</YStack>
</MobileCard> </MobileCard>
<CTAButton <CTAButton

View File

@@ -69,7 +69,7 @@ export default function MobileQrLayoutCustomizePage() {
const layoutParam = searchParams.get('layout'); const layoutParam = searchParams.get('layout');
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation('management'); const { t } = useTranslation('management');
const { textStrong, danger } = useAdminTheme(); const { textStrong, danger, muted, border, primary, surface, surfaceMuted, overlay, shadow } = useAdminTheme();
const [event, setEvent] = React.useState<TenantEvent | null>(null); const [event, setEvent] = React.useState<TenantEvent | null>(null);
const [invite, setInvite] = React.useState<EventQrInvite | null>(null); const [invite, setInvite] = React.useState<EventQrInvite | null>(null);
@@ -420,9 +420,9 @@ function renderEventName(name: TenantEvent['name'] | null | undefined): string |
function getDefaultSlots(): Record<string, SlotDefinition> { function getDefaultSlots(): Record<string, SlotDefinition> {
return { return {
headline: { x: 0.08, y: 0.12, w: 0.84, fontSize: 90, fontWeight: 800, fontFamily: DEFAULT_DISPLAY_FONT, align: 'center' }, headline: { x: 0.08, y: 0.12, w: 0.84, fontSize: 90, fontWeight: 800, fontFamily: DEFAULT_DISPLAY_FONT, align: 'center' as const },
subtitle: { x: 0.1, y: 0.21, w: 0.8, fontSize: 37, fontWeight: 600, fontFamily: DEFAULT_BODY_FONT, align: 'center' }, subtitle: { x: 0.1, y: 0.21, w: 0.8, fontSize: 37, fontWeight: 600, fontFamily: DEFAULT_BODY_FONT, align: 'center' as const },
description: { x: 0.1, y: 0.66, w: 0.8, fontSize: 26, fontFamily: DEFAULT_BODY_FONT, lineHeight: 1.4, align: 'center' }, description: { x: 0.1, y: 0.66, w: 0.8, fontSize: 26, fontFamily: DEFAULT_BODY_FONT, lineHeight: 1.4, align: 'center' as const },
instructions: { x: 0.1, y: 0.7, w: 0.8, fontSize: 24, fontFamily: DEFAULT_BODY_FONT, lineHeight: 1.5 }, instructions: { x: 0.1, y: 0.7, w: 0.8, fontSize: 24, fontFamily: DEFAULT_BODY_FONT, lineHeight: 1.5 },
qr: { x: 0.39, y: 0.37, w: 0.27 }, qr: { x: 0.39, y: 0.37, w: 0.27 },
}; };
@@ -453,9 +453,9 @@ function resolveSlots(layout: EventQrInviteLayout | null, isFoldable: boolean, o
const baseSlots = isFoldable const baseSlots = isFoldable
? { ? {
headline: { x: 0.08, y: 0.15, w: 0.84, fontSize: 50, fontWeight: 800, fontFamily: DEFAULT_DISPLAY_FONT, align: 'center' }, headline: { x: 0.08, y: 0.15, w: 0.84, fontSize: 50, fontWeight: 800, fontFamily: DEFAULT_DISPLAY_FONT, align: 'center' as const },
subtitle: { x: 0.1, y: 0.21, w: 0.8, fontSize: 37, fontWeight: 600, fontFamily: DEFAULT_BODY_FONT, align: 'center' }, subtitle: { x: 0.1, y: 0.21, w: 0.8, fontSize: 37, fontWeight: 600, fontFamily: DEFAULT_BODY_FONT, align: 'center' as const },
description: { x: 0.1, y: 0.66, w: 0.8, fontSize: 13, fontFamily: DEFAULT_BODY_FONT, lineHeight: 1.4, align: 'center' }, description: { x: 0.1, y: 0.66, w: 0.8, fontSize: 13, fontFamily: DEFAULT_BODY_FONT, lineHeight: 1.4, align: 'center' as const },
instructions: { x: 0.12, y: 0.36, w: 0.76, fontSize: 13, fontFamily: DEFAULT_BODY_FONT, lineHeight: 1.3 }, instructions: { x: 0.12, y: 0.36, w: 0.76, fontSize: 13, fontFamily: DEFAULT_BODY_FONT, lineHeight: 1.3 },
qr: { x: 0.3, y: 0.3, w: 0.28 }, qr: { x: 0.3, y: 0.3, w: 0.28 },
} }
@@ -520,8 +520,8 @@ function buildFabricOptions({
const elements: LayoutElement[] = []; const elements: LayoutElement[] = [];
const textColor = layout?.preview?.text ?? ADMIN_COLORS.backdrop; const textColor = layout?.preview?.text ?? ADMIN_COLORS.backdrop;
const accentColor = layout?.preview?.accent ?? ADMIN_COLORS.primary; const accentColor = layout?.preview?.accent ?? ADMIN_COLORS.primary;
const secondaryColor = layout?.preview?.secondary ?? ADMIN_COLORS.text; const secondaryColor = (layout?.preview as any)?.secondary ?? ADMIN_COLORS.text;
const badgeColor = layout?.preview?.badge ?? accentColor; const badgeColor = (layout?.preview as any)?.badge ?? accentColor;
const rotatePoint = (cx: number, cy: number, x: number, y: number, angleDeg: number) => { const rotatePoint = (cx: number, cy: number, x: number, y: number, angleDeg: number) => {
const rad = (angleDeg * Math.PI) / 180; const rad = (angleDeg * Math.PI) / 180;
@@ -862,15 +862,18 @@ function TextStep({
textFields, textFields,
onChange, onChange,
onSave, onSave,
onBulkAdd,
saving, saving,
}: { }: {
onBack: () => void; onBack: () => void;
textFields: { headline: string; subtitle: string; description: string; instructions: string[] }; textFields: { headline: string; subtitle: string; description: string; instructions: string[] };
onChange: (fields: { headline: string; subtitle: string; description: string; instructions: string[] }) => void; onChange: (fields: { headline: string; subtitle: string; description: string; instructions: string[] }) => void;
onSave: () => void; onSave: () => void;
onBulkAdd?: () => void;
saving: boolean; saving: boolean;
}) { }) {
const { t } = useTranslation('management'); const { t } = useTranslation('management');
const { textStrong, border, surface, muted } = useAdminTheme();
const updateField = (key: 'headline' | 'subtitle' | 'description', value: string) => { const updateField = (key: 'headline' | 'subtitle' | 'description', value: string) => {
onChange({ ...textFields, [key]: value }); onChange({ ...textFields, [key]: value });
@@ -941,7 +944,7 @@ function TextStep({
onChangeText={(val) => updateInstruction(idx, val)} onChangeText={(val) => updateInstruction(idx, val)}
placeholder={t('events.qr.instructionsPlaceholder', 'Schritt hinzufügen')} placeholder={t('events.qr.instructionsPlaceholder', 'Schritt hinzufügen')}
numberOfLines={2} numberOfLines={2}
flex={1} {...({ flex: 1 } as any)}
size="$4" size="$4"
/> />
<Pressable onPress={() => removeInstruction(idx)} disabled={textFields.instructions.length === 1}> <Pressable onPress={() => removeInstruction(idx)} disabled={textFields.instructions.length === 1}>
@@ -1096,7 +1099,8 @@ function PreviewStep({
const aspectRatio = `${canvasBase.width}/${canvasBase.height}`; const aspectRatio = `${canvasBase.width}/${canvasBase.height}`;
const paper = resolvePaper(layout); const paper = resolvePaper(layout);
const isLandscape = ((layout?.orientation ?? (isFoldable ? 'landscape' : 'portrait')) || '').toLowerCase() === 'landscape'; const orientation = ((layout?.orientation ?? (isFoldable ? 'landscape' : 'portrait')) || '').toString().toLowerCase();
const isLandscape = orientation === 'landscape';
const orientationLabel = isLandscape const orientationLabel = isLandscape
? t('events.qr.orientation.landscape', 'Landscape') ? t('events.qr.orientation.landscape', 'Landscape')
: t('events.qr.orientation.portrait', 'Portrait'); : t('events.qr.orientation.portrait', 'Portrait');
@@ -1157,14 +1161,14 @@ function PreviewStep({
try { try {
await loadFonts(); await loadFonts();
const pdfBytes = await generatePdfBytes(exportOptions, paper, orientation); const pdfBytes = await generatePdfBytes(exportOptions, paper, orientation);
const blob = new Blob([pdfBytes], { type: 'application/pdf' }); const blob = new Blob([pdfBytes as any], { type: 'application/pdf' });
triggerDownloadFromBlob(blob, 'qr-layout.pdf'); triggerDownloadFromBlob(blob, 'qr-layout.pdf');
} catch (err) { } catch (err) {
toast.error(t('events.qr.exportFailed', 'Export fehlgeschlagen')); toast.error(t('events.qr.exportFailed', 'Export fehlgeschlagen'));
console.error(err); console.error(err);
} }
}} }}
style={{ flex: 1, minWidth: 0 }} style={{ flex: 1, minWidth: 0 } as any}
/> />
<CTAButton <CTAButton
label={t('events.qr.exportPng', 'Export PNG')} label={t('events.qr.exportPng', 'Export PNG')}
@@ -1178,7 +1182,7 @@ function PreviewStep({
console.error(err); console.error(err);
} }
}} }}
style={{ flex: 1, minWidth: 0 }} style={{ flex: 1, minWidth: 0 } as any}
/> />
</XStack> </XStack>
</YStack> </YStack>
@@ -1315,7 +1319,7 @@ function LayoutControls({
return ( return (
<Accordion.Item value={slotKey} key={slotKey}> <Accordion.Item value={slotKey} key={slotKey}>
<Accordion.Trigger padding="$2" borderWidth={1} borderColor={border} borderRadius={12} backgroundColor={surfaceMuted}> <Accordion.Trigger {...({ padding: "$2", borderWidth: 1, borderColor: border, borderRadius: 12, backgroundColor: surfaceMuted } as any)}>
<XStack justifyContent="space-between" alignItems="center" flex={1}> <XStack justifyContent="space-between" alignItems="center" flex={1}>
<Text fontSize="$sm" fontWeight="700" color={textStrong}> <Text fontSize="$sm" fontWeight="700" color={textStrong}>
{label} {label}
@@ -1323,7 +1327,7 @@ function LayoutControls({
<ChevronDown size={16} color={muted} /> <ChevronDown size={16} color={muted} />
</XStack> </XStack>
</Accordion.Trigger> </Accordion.Trigger>
<Accordion.Content paddingTop="$2"> <Accordion.Content {...({ paddingTop: "$2" } as any)}>
<YStack space="$2" padding="$2" borderWidth={1} borderColor={border} borderRadius={12}> <YStack space="$2" padding="$2" borderWidth={1} borderColor={border} borderRadius={12}>
<XStack space="$3"> <XStack space="$3">
<YStack flex={1} space="$1"> <YStack flex={1} space="$1">
@@ -1546,7 +1550,7 @@ function LayoutControls({
{qrSlot ? ( {qrSlot ? (
<Accordion.Item value="qr"> <Accordion.Item value="qr">
<Accordion.Trigger padding="$2" borderWidth={1} borderColor={border} borderRadius={12} backgroundColor={surfaceMuted}> <Accordion.Trigger {...({ padding: "$2", borderWidth: 1, borderColor: border, borderRadius: 12, backgroundColor: surfaceMuted } as any)}>
<XStack justifyContent="space-between" alignItems="center" flex={1}> <XStack justifyContent="space-between" alignItems="center" flex={1}>
<Text fontSize="$sm" fontWeight="700" color={textStrong}> <Text fontSize="$sm" fontWeight="700" color={textStrong}>
{t('events.qr.qr_code_label', 'QRCode')} {t('events.qr.qr_code_label', 'QRCode')}
@@ -1554,7 +1558,7 @@ function LayoutControls({
<ChevronDown size={16} color={muted} /> <ChevronDown size={16} color={muted} />
</XStack> </XStack>
</Accordion.Trigger> </Accordion.Trigger>
<Accordion.Content paddingTop="$2"> <Accordion.Content {...({ paddingTop: "$2" } as any)}>
<YStack space="$2" padding="$2" borderWidth={1} borderColor={border} borderRadius={12}> <YStack space="$2" padding="$2" borderWidth={1} borderColor={border} borderRadius={12}>
<XStack space="$2"> <XStack space="$2">
<YStack flex={1} space="$1"> <YStack flex={1} space="$1">

View File

@@ -544,6 +544,7 @@ function PreviewStep({
presets, presets,
textFields, textFields,
qrUrl, qrUrl,
qrImage,
onExport, onExport,
}: { }: {
onBack: () => void; onBack: () => void;
@@ -552,6 +553,7 @@ function PreviewStep({
presets: { id: string; src: string; label: string }[]; presets: { id: string; src: string; label: string }[];
textFields: { headline: string; subtitle: string; description: string; instructions: string[] }; textFields: { headline: string; subtitle: string; description: string; instructions: string[] };
qrUrl: string; qrUrl: string;
qrImage: string;
onExport: (format: 'pdf' | 'png') => void; onExport: (format: 'pdf' | 'png') => void;
}) { }) {
const { t } = useTranslation('management'); const { t } = useTranslation('management');

View File

@@ -241,8 +241,8 @@ export default function MobileSettingsPage() {
{t('mobileSettings.notificationsLoading', 'Loading settings ...')} {t('mobileSettings.notificationsLoading', 'Loading settings ...')}
</Text> </Text>
) : ( ) : (
<YGroup borderRadius="$4" borderWidth={1} borderColor={border} overflow="hidden"> <YGroup {...({ borderRadius: "$4", borderWidth: 1, borderColor: border, overflow: "hidden" } as any)}>
<YGroup.Item bordered> <YGroup.Item>
<ListItem <ListItem
hoverTheme hoverTheme
pressTheme pressTheme
@@ -280,7 +280,7 @@ export default function MobileSettingsPage() {
/> />
</YGroup.Item> </YGroup.Item>
{AVAILABLE_PREFS.map((key, index) => ( {AVAILABLE_PREFS.map((key, index) => (
<YGroup.Item key={key} bordered={index < AVAILABLE_PREFS.length - 1}> <YGroup.Item key={key}>
<ListItem <ListItem
hoverTheme hoverTheme
pressTheme pressTheme

View File

@@ -19,6 +19,7 @@ vi.mock('../../api', () => ({
getEvent: vi.fn(), getEvent: vi.fn(),
updateEvent: vi.fn(), updateEvent: vi.fn(),
getEventTypes: vi.fn().mockResolvedValue([]), getEventTypes: vi.fn().mockResolvedValue([]),
getPackages: vi.fn().mockResolvedValue([]),
trackOnboarding: vi.fn(), trackOnboarding: vi.fn(),
})); }));
@@ -81,6 +82,11 @@ vi.mock('../theme', () => ({
}), }),
})); }));
vi.mock('../../auth/context', () => ({
useAuth: () => ({ user: { role: 'tenant_admin' } }),
}));
import { getEventTypes } from '../../api';
import MobileEventFormPage from '../EventFormPage'; import MobileEventFormPage from '../EventFormPage';
describe('MobileEventFormPage', () => { describe('MobileEventFormPage', () => {
@@ -94,4 +100,18 @@ describe('MobileEventFormPage', () => {
expect(backMock).toHaveBeenCalled(); expect(backMock).toHaveBeenCalled();
}); });
it('defaults event type to wedding when available', async () => {
vi.mocked(getEventTypes).mockResolvedValueOnce([
{ id: 11, slug: 'conference', name: 'Conference', name_translations: {}, icon: null, settings: {} },
{ id: 22, slug: 'wedding', name: 'Wedding', name_translations: {}, icon: null, settings: {} },
]);
await act(async () => {
render(<MobileEventFormPage />);
});
const select = screen.getByRole('combobox');
expect(select).toHaveValue('22');
});
}); });

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