Compare commits
44 Commits
cff014ede5
...
beads-sync
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9fa1546f7 | ||
|
|
7c6eee187c | ||
|
|
fbd46b8e5c | ||
|
|
886b336a08 | ||
|
|
02237735ec | ||
|
|
5e420a0dd8 | ||
|
|
2a55ae934f | ||
|
|
e4100f7800 | ||
|
|
7786e3d134 | ||
|
|
30f3d148bb | ||
|
|
1970c259ed | ||
|
|
dc5c80cda4 | ||
|
|
75a9bcee12 | ||
|
|
6fe363640f | ||
|
|
3df0542013 | ||
|
|
4f4a527010 | ||
|
|
e69c94ad20 | ||
|
|
5afa96251b | ||
|
|
24f053d4c4 | ||
|
|
ec360ed860 | ||
|
|
83e78d7c66 | ||
|
|
9b1c5bf978 | ||
|
|
fb23a0a2f3 | ||
|
|
2287e7f32c | ||
|
|
cceed361b7 | ||
|
|
02363792c8 | ||
|
|
e93a00f0fc | ||
|
|
c1be7dd1ef | ||
|
|
f01a0e823b | ||
|
|
915aede66e | ||
|
|
b854e3feaa | ||
|
|
4bcaef53f7 | ||
|
|
8f1d3a3eb6 | ||
|
|
ab2cf3e023 | ||
|
|
ce0ab269c9 | ||
|
|
dce24bb86a | ||
|
|
03bf178d61 | ||
|
|
8ebaf6c31d | ||
|
|
1b6dc63ec6 | ||
|
|
accc63f4a2 | ||
|
|
59e318e7b9 | ||
|
|
3de1d3deab | ||
|
|
e9afbeb028 | ||
|
|
3e2b63f71f |
@@ -9,16 +9,20 @@
|
||||
{"id":"fotospiel-app-1we","title":"Live Show: define trusted uploader rules \u0026 default retention window","description":"# Decision: Trusted uploader rules \u0026 default retention window\n\n## Context\nModeration is required for many events, but we also want a fast “auto-approve trusted sources” mode.\n\nWe currently track photo ingestion sources in `photos.ingest_source` (e.g. `tenant_admin`, `photobooth`, `sparkbooth`, `guest_pwa`). Guest uploads are token-based and do not have strong identity guarantees.\n\n## Definitions\n- **Trusted uploader**: uploads that can bypass Live Show manual moderation.\n- **Retention window**: time window for which approved photos remain eligible for rotation in the Live Show.\n\n## Options (trusted rules)\n### A) Trust by ingestion source only (recommended for V1)\nAuto-approve for Live Show only when `ingest_source` is one of:\n- `tenant_admin` (authenticated staff actions)\n- `photobooth` / `sparkbooth` (controlled integrations)\n\nAll `guest_pwa` uploads require manual approval when moderation is enabled.\n\n**Pros**\n- Harder to spoof; aligns with real security boundaries.\n- Simple to explain and operate.\n\n**Cons**\n- Guests never auto-approve; more moderator work.\n\n### B) Trust by guest device id (not recommended without stronger proof)\nUse `created_by_device_id` / `X-Device-Id` to whitelist devices.\n\n**Risk**\n- Device IDs are not cryptographically bound; a motivated guest could spoof the header.\n\nIf we want this later, we should introduce a **server-issued signed device token** (pairing flow) and validate it on upload.\n\n### C) Trust by invitation/QR (future)\nGuests who joined with a special “staff QR/pairing token” become trusted.\n\n## Recommended decision\nChoose **Option A** for V1.\n\n### Moderation mode semantics (proposed)\n- `off`: all photos with “submit to live show” become `approved` immediately *except* photos that are already flagged/removed by other moderation pipelines.\n- `manual`: all guest PWA photos become `pending`; trusted sources auto-approve.\n- `trusted_only`: same as manual, but UI copy emphasises that only booth/staff are automatic.\n\n## Retention window (defaults)\n### Recommendation\nDefault `retention_window_hours = 12` (configurable per event).\n\nRationale:\n- Keeps the “eligible set” bounded for performance.\n- Fits most event durations; avoids showing very old photos late in the night.\n\n### Notes\n- Even with a retention window, we can still show older photos via “curated” mode (e.g. featured/top-liked) if product wants.\n\n## Edge cases\n- **High-volume**: moderators may not keep up → allow temporary switch to “trusted_only” + announce to guests.\n- **Abuse**: if a trusted integration misbehaves, operator can disable trusted auto-approve.\n- **Reversal**: approving a previously rejected photo must be tracked with audit info (who/when).\n\n## Decision needed from product\n- Confirm the default retention window: 12h vs 6h vs “entire event”.\n- Confirm whether “trusted_only” should auto-approve `tenant_admin` uploads (recommended: yes).\n- Confirm whether guest auto-approve is desired in V1 (recommended: no, unless we build pairing).\n","acceptance_criteria":"- Trusted rules options listed, with security risk called out for device-id trust\\n- Clear V1 recommendation (trust by ingest_source only)\\n- Moderation mode semantics defined\\n- Default retention window recommendation + product decision questions","notes":"Decision: V1 trusted auto-approve uses ingest_source only (tenant_admin/photobooth/sparkbooth). Default retention_window_hours = 12.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-05T11:43:32.455339503+01:00","created_by":"soeren","updated_at":"2026-01-05T12:06:45.973092473+01:00","closed_at":"2026-01-05T12:06:45.973092473+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-1we","depends_on_id":"fotospiel-app-vro","type":"blocks","created_at":"2026-01-05T11:44:02.062725386+01:00","created_by":"soeren"}]}
|
||||
{"id":"fotospiel-app-25q","title":"Security review: payments/webhooks code audit (signatures, idempotency, linkage)","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:05:25.747336642+01:00","created_by":"soeren","updated_at":"2026-01-01T16:05:25.747336642+01:00"}
|
||||
{"id":"fotospiel-app-29o","title":"Paddle catalog sync: PackageResource sync status badges + timestamp","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:01:10.009385187+01:00","created_by":"soeren","updated_at":"2026-01-01T16:01:15.639525807+01:00","closed_at":"2026-01-01T16:01:15.639525807+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-29r","title":"Photobooth uploader: add watch-folder upload pipeline + persist creds","status":"closed","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-12T16:51:27.198056063+01:00","created_by":"Codex Agent","updated_at":"2026-01-12T17:07:04.06719869+01:00","closed_at":"2026-01-12T17:07:04.06719869+01:00","close_reason":"Closed"}
|
||||
{"id":"fotospiel-app-2b5","title":"Uploader: connect code expiry countdown","description":"Part of epic fotospiel-app-5aa. Show time-to-expiry for the active connect code in the client.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:04:05.74962406+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:04:05.74962406+01:00"}
|
||||
{"id":"fotospiel-app-2hq","title":"Security review: marketing/API controller+validation review","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:05:08.862737923+01:00","created_by":"soeren","updated_at":"2026-01-01T16:05:08.862737923+01:00"}
|
||||
{"id":"fotospiel-app-2yn","title":"Event-Admin: Reset link routing + notifications + tests","description":"Point password reset emails to event-admin reset page; add rate limiting and tests for the new flow.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T10:45:09.279245468+01:00","created_by":"soeren","updated_at":"2026-01-06T11:01:49.083154811+01:00","closed_at":"2026-01-06T11:01:49.083154811+01:00","close_reason":"Closed"}
|
||||
{"id":"fotospiel-app-33m","title":"Security review checklist: Guest PWA dynamic tests","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:04:40.730459361+01:00","created_by":"soeren","updated_at":"2026-01-01T16:04:40.730459361+01:00"}
|
||||
{"id":"fotospiel-app-38f","title":"Paddle catalog sync: surface last sync error/log context in admin","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:59:14.865414785+01:00","created_by":"soeren","updated_at":"2026-01-02T21:16:09.109922491+01:00","closed_at":"2026-01-02T21:16:09.109922491+01:00","close_reason":"Completed"}
|
||||
{"id":"fotospiel-app-3ut","title":"SEC-API-03 Synthetic monitoring + alert config","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:52:46.793875724+01:00","created_by":"soeren","updated_at":"2026-01-01T15:52:46.793875724+01:00"}
|
||||
{"id":"fotospiel-app-3xa","title":"Security review: event admin code audit (policies, PKCE, file handling)","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:05:20.115675149+01:00","created_by":"soeren","updated_at":"2026-01-01T16:05:20.115675149+01:00"}
|
||||
{"id":"fotospiel-app-43mp","title":"Help-System für Event Admin PWA planen","notes":"Context help links wired into priority admin pages.","status":"in_progress","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-23T08:21:47.812129626+01:00","created_by":"Codex Agent","updated_at":"2026-01-23T09:19:45.828239299+01:00"}
|
||||
{"id":"fotospiel-app-4ar","title":"SEC-BILL-03 Failed capture notifications + ledger hook","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:54:33.266516715+01:00","created_by":"soeren","updated_at":"2026-01-01T15:54:33.266516715+01:00"}
|
||||
{"id":"fotospiel-app-4en","title":"Add translations for Mobile Package Shop","description":"The new MobilePackageShopPage.tsx uses translation keys like 'shop.title', 'shop.legal.agb', etc. Ensure these are added to the management.json files for de and en.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T18:05:50.469751088+01:00","created_by":"soeren","updated_at":"2026-01-06T18:14:19.984343737+01:00","closed_at":"2026-01-06T18:14:19.984346372+01:00"}
|
||||
{"id":"fotospiel-app-4i4","title":"Security review: map roles/data","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:02:58.370301875+01:00","created_by":"soeren","updated_at":"2026-01-01T16:03:03.997327414+01:00","closed_at":"2026-01-01T16:03:03.997327414+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-4zu","title":"SEC-IO-02 Refresh-token management UI + audit logs","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:51:50.24186222+01:00","created_by":"soeren","updated_at":"2026-01-04T16:10:39.752587431+01:00","closed_at":"2026-01-04T16:10:39.752587431+01:00","close_reason":"Obsolete: authentication now uses Sanctum PATs; OAuth/refresh-token tables removed and no refresh-token flow remains. See docs/archive/prp/13-backend-authentication.md and docs/archive/prp/marketing-checkout-payment-architecture.md."}
|
||||
{"id":"fotospiel-app-4zy","title":"Refine Dashboard Translations","description":"Fix missing translations in the modern dashboard UI and use proper i18n keys for stats and status labels.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-17T16:35:14.464529363+01:00","created_by":"Codex Agent","updated_at":"2026-01-17T16:35:14.464529363+01:00"}
|
||||
{"id":"fotospiel-app-539","title":"Live Show: public player view with effects engine","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-01-05T11:11:36.821959901+01:00","created_by":"soeren","updated_at":"2026-01-05T18:30:13.318396255+01:00","closed_at":"2026-01-05T18:30:13.318396255+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-539","depends_on_id":"fotospiel-app-qne","type":"blocks","created_at":"2026-01-05T11:12:58.721858159+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-539","depends_on_id":"fotospiel-app-6zc","type":"blocks","created_at":"2026-01-05T11:13:07.289796993+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-539","depends_on_id":"fotospiel-app-h5d","type":"blocks","created_at":"2026-01-05T11:44:42.719445471+01:00","created_by":"soeren"}]}
|
||||
{"id":"fotospiel-app-539.2","title":"Live Show player shell + routing + data layer","description":"Add /show/{token} route + guest player page shell, Live Show API client, SSE/polling subscription and state model.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-05T15:57:41.587003393+01:00","created_by":"soeren","updated_at":"2026-01-05T16:44:39.577762479+01:00","closed_at":"2026-01-05T16:44:39.577762479+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-539.2","depends_on_id":"fotospiel-app-539","type":"parent-child","created_at":"2026-01-05T15:57:41.641767879+01:00","created_by":"soeren"}]}
|
||||
{"id":"fotospiel-app-539.3","title":"Live Show playback engine (queue, pacing, layouts)","description":"Implement player playback scheduler, queue management, and layout rendering for single/split/grid.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-05T15:57:56.531080931+01:00","created_by":"soeren","updated_at":"2026-01-05T17:40:45.929168571+01:00","closed_at":"2026-01-05T17:40:45.929168571+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-539.3","depends_on_id":"fotospiel-app-539","type":"parent-child","created_at":"2026-01-05T15:57:56.631147026+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-539.3","depends_on_id":"fotospiel-app-539.2","type":"blocks","created_at":"2026-01-05T15:57:56.655278463+01:00","created_by":"soeren"}]}
|
||||
@@ -28,20 +32,27 @@
|
||||
{"id":"fotospiel-app-574","title":"Paddle catalog sync: extend PaddleClient tests/mocks for catalog endpoints","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:59:03.486301225+01:00","created_by":"soeren","updated_at":"2026-01-02T21:11:39.626820206+01:00","closed_at":"2026-01-02T21:11:39.626820206+01:00","close_reason":"Deprioritized"}
|
||||
{"id":"fotospiel-app-576","title":"Tenant admin onboarding: legacy asset audit + component inventory","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:07:59.996563146+01:00","created_by":"soeren","updated_at":"2026-01-01T16:08:05.599274641+01:00","closed_at":"2026-01-01T16:08:05.599274641+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-579","title":"Live Show: tests (backend + UI smoke)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-05T11:11:57.246607374+01:00","created_by":"soeren","updated_at":"2026-01-05T19:37:35.590123482+01:00","closed_at":"2026-01-05T19:37:35.590123482+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-579","depends_on_id":"fotospiel-app-539","type":"blocks","created_at":"2026-01-05T11:13:27.729131522+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-579","depends_on_id":"fotospiel-app-xg5","type":"blocks","created_at":"2026-01-05T11:13:37.425191011+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-579","depends_on_id":"fotospiel-app-qne","type":"blocks","created_at":"2026-01-05T11:13:46.257175231+01:00","created_by":"soeren"}]}
|
||||
{"id":"fotospiel-app-5aa","title":"Photobooth uploader: reliability + UX upgrades","status":"open","priority":2,"issue_type":"epic","owner":"codex-agent@example.com","created_at":"2026-01-13T11:01:29.745168595+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:01:29.745168595+01:00"}
|
||||
{"id":"fotospiel-app-5dl","title":"Paddle catalog sync: PaddleCatalogService scaffold","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:00:24.916655836+01:00","created_by":"soeren","updated_at":"2026-01-01T16:00:30.566084195+01:00","closed_at":"2026-01-01T16:00:30.566084195+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-5hk","title":"Fix staging coupon seed 500 for E2E","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-03T15:12:53.643644221+01:00","created_by":"soeren","updated_at":"2026-01-04T16:21:46.441797374+01:00","closed_at":"2026-01-04T16:21:46.441797374+01:00","close_reason":"Resolved elsewhere; staging coupon seed 500 no longer reproducible after recent backend changes."}
|
||||
{"id":"fotospiel-app-5ie","title":"Help docs: Live Show how-to + recommended hardware (DE/EN)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-05T11:12:05.973844187+01:00","created_by":"soeren","updated_at":"2026-01-05T19:42:44.39939087+01:00","closed_at":"2026-01-05T19:42:44.39939087+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-5ie","depends_on_id":"fotospiel-app-vro","type":"blocks","created_at":"2026-01-05T11:13:54.925412888+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-5ie","depends_on_id":"fotospiel-app-539","type":"blocks","created_at":"2026-01-05T11:14:03.257649076+01:00","created_by":"soeren"}]}
|
||||
{"id":"fotospiel-app-5iy","title":"Security review: confirm env/header defaults","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:03:20.808188183+01:00","created_by":"soeren","updated_at":"2026-01-01T16:03:26.388002115+01:00","closed_at":"2026-01-01T16:03:26.388002115+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-5s3","title":"Localized SEO: canonical/hreflang tags + localized navigation","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:02:03.909947355+01:00","created_by":"soeren","updated_at":"2026-01-01T16:02:09.550647107+01:00","closed_at":"2026-01-01T16:02:09.550647107+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-5veo","title":"Investigate vite build timeout","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-21T12:49:14.166622473+01:00","created_by":"Codex Agent","updated_at":"2026-01-21T12:49:14.166622473+01:00"}
|
||||
{"id":"fotospiel-app-5zl","title":"Ensure checkout step 3 requires login for Paddle checkout","description":"Problem: Paddle checkout on step 3 fails when user is not logged in. Step 3 must enforce authentication before initializing Paddle checkout.\\n\\nSuggestions:\\n- Protect step 3 route/controller with auth middleware and redirect to login with intended return URL.\\n- Gate step 3 UI/CTA on auth state; show inline login prompt and disable Paddle until authenticated.\\n- Require auth in backend endpoint that creates Paddle transaction/session; return 401 and send user to login.\\n- Optionally preflight at end of step 2 to prompt login before advancing.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-04T12:31:43.215017311+01:00","created_by":"soeren","updated_at":"2026-01-04T12:42:45.088723058+01:00","closed_at":"2026-01-04T12:42:45.088723058+01:00","close_reason":"Closed"}
|
||||
{"id":"fotospiel-app-64l","title":"SEC-FE-01 CSP nonce/hashing rollout","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:54:47.607047443+01:00","created_by":"soeren","updated_at":"2026-01-01T15:55:56.477104351+01:00","closed_at":"2026-01-01T15:55:56.477104351+01:00","close_reason":"Completed in codebase (verified) - duplicate of fotospiel-app-zli"}
|
||||
{"id":"fotospiel-app-6dp","title":"Coupon ops enhancements (redemption service, preview endpoint, widget, export)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:09:09.275919717+01:00","created_by":"soeren","updated_at":"2026-01-01T16:09:14.882264149+01:00","closed_at":"2026-01-01T16:09:14.882264149+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-6oj","title":"Security review: media pipeline code audit (AV/EXIF, signed URLs, storage separation)","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:05:31.390878341+01:00","created_by":"soeren","updated_at":"2026-01-01T16:05:31.390878341+01:00"}
|
||||
{"id":"fotospiel-app-6yt","title":"Paddle migration: register sandbox webhooks + document events consumed","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:56:34.333714988+01:00","created_by":"soeren","updated_at":"2026-01-02T22:23:52.212191068+01:00","closed_at":"2026-01-02T22:23:52.212191068+01:00","close_reason":"Completed"}
|
||||
{"id":"fotospiel-app-6yz","title":"Uploader: activity log export","description":"Part of epic fotospiel-app-5aa. Add in-app log view and export/copy diagnostics for support.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:04:27.73767403+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:04:27.73767403+01:00"}
|
||||
{"id":"fotospiel-app-6zc","title":"Live Show: Admin app settings \u0026 effect presets","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-01-05T11:11:27.038815978+01:00","created_by":"soeren","updated_at":"2026-01-05T15:02:42.035082497+01:00","closed_at":"2026-01-05T15:02:42.035082497+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-6zc","depends_on_id":"fotospiel-app-vro","type":"blocks","created_at":"2026-01-05T11:12:50.048055484+01:00","created_by":"soeren"}]}
|
||||
{"id":"fotospiel-app-7bu","title":"Paddle migration: extend config/env handling for Paddle keys/webhook secrets","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:57:27.242854801+01:00","created_by":"soeren","updated_at":"2026-01-01T15:57:32.890355888+01:00","closed_at":"2026-01-01T15:57:32.890355888+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-7u1","title":"Paddle catalog sync: PaddlePackagePull job","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:00:47.468892178+01:00","created_by":"soeren","updated_at":"2026-01-01T16:00:53.126602817+01:00","closed_at":"2026-01-01T16:00:53.126602817+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-7uu","title":"Uploader: improve file readiness detection","description":"Part of epic fotospiel-app-5aa. Use size + last-write stabilization to avoid partial uploads.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:01:54.142231578+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:01:54.142231578+01:00"}
|
||||
{"id":"fotospiel-app-7x1","title":"Uploader: response format manual override","description":"Part of epic fotospiel-app-5aa. Allow manual response format override when connect code doesn't set it.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:03:54.824613016+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:03:54.824613016+01:00"}
|
||||
{"id":"fotospiel-app-83q","title":"Implement Advanced Analytics","description":"Full plan: Phase 1 (MVP) includes Activity Timeline, Top Contributors, and Task Stats. Phase 2 includes Engagement Funnel, Vibe Check, and PDF Export. See chat history for details.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T15:40:08.826105426+01:00","created_by":"soeren","updated_at":"2026-01-06T16:15:17.722450844+01:00","closed_at":"2026-01-06T16:15:17.722455019+01:00"}
|
||||
{"id":"fotospiel-app-8iw","title":"Modernize Tenant Admin PWA UI","status":"open","priority":1,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-17T14:36:39.802617182+01:00","created_by":"Codex Agent","updated_at":"2026-01-17T14:36:39.802617182+01:00"}
|
||||
{"id":"fotospiel-app-8ui","title":"Uploader: persist queue across restarts","description":"Part of epic fotospiel-app-5aa. Persist pending upload queue to disk (settings or local DB) so restarts don't lose files.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:01:42.213478619+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:01:42.213478619+01:00"}
|
||||
{"id":"fotospiel-app-95m","title":"Paddle migration: admin catalog sync UI for packages","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:57:49.790409261+01:00","created_by":"soeren","updated_at":"2026-01-01T15:57:55.418180246+01:00","closed_at":"2026-01-01T15:57:55.418180246+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-99d","title":"Paddle migration: marketing checkout uses Paddle-hosted checkout","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:58:12.298063897+01:00","created_by":"soeren","updated_at":"2026-01-01T15:58:17.968032021+01:00","closed_at":"2026-01-01T15:58:17.968032021+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-99o","title":"Fix German welcome phrasing with article-safe app_name","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-04T11:50:17.410390085+01:00","created_by":"soeren","updated_at":"2026-01-04T12:19:55.741616753+01:00","closed_at":"2026-01-04T12:19:55.741616753+01:00","close_reason":"Closed"}
|
||||
@@ -62,9 +73,12 @@
|
||||
{"id":"fotospiel-app-bqm","title":"Paddle catalog sync: unit tests for service + jobs","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:01:22.090498843+01:00","created_by":"soeren","updated_at":"2026-01-01T16:01:27.71412122+01:00","closed_at":"2026-01-01T16:01:27.71412122+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-bxu","title":"Checkout refactor: Stripe/Paddle payment integration + webhooks","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:06:32.279485614+01:00","created_by":"soeren","updated_at":"2026-01-01T16:06:37.876950599+01:00","closed_at":"2026-01-01T16:06:37.876950599+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-bzb","title":"Paddle catalog sync: migration for paddle sync columns","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:00:02.362257158+01:00","created_by":"soeren","updated_at":"2026-01-01T16:00:08.018770606+01:00","closed_at":"2026-01-01T16:00:08.018770606+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-cht","title":"Uploader: disk space low warning","description":"Part of epic fotospiel-app-5aa. Highlight low disk space thresholds in UI.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:03:32.710631234+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:03:32.710631234+01:00"}
|
||||
{"id":"fotospiel-app-ci5","title":"Paddle catalog sync: configure log channel/Slack hook for sync outcomes","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:59:20.543083527+01:00","created_by":"soeren","updated_at":"2026-01-02T22:02:15.857149244+01:00","closed_at":"2026-01-02T22:02:15.857149244+01:00","close_reason":"Completed"}
|
||||
{"id":"fotospiel-app-cwq","title":"Integrations health: unified Paddle/RevenueCat/webhook status dashboard","description":"Add a superadmin integrations health dashboard for Paddle/RevenueCat/webhooks.\nScope: show latest webhook processing status/lag, recent failures, retry backlog, and config presence (env set) without exposing secrets.\nInclude per-provider status badges and time-window filters, plus links to related logs/actions.\n","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-02T17:34:20.84661157+01:00","created_by":"soeren","updated_at":"2026-01-02T18:33:07.133704488+01:00","closed_at":"2026-01-02T18:33:07.133704488+01:00","close_reason":"Closed"}
|
||||
{"id":"fotospiel-app-d39","title":"Superadmin control surface spec and access matrix","description":"Define the minimal superadmin control surface, permissions, and mapping to tenant/guest responsibilities. Document scope and non-goals.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T14:16:06.994379577+01:00","updated_at":"2026-01-01T14:20:43.080701114+01:00","closed_at":"2026-01-01T14:20:43.080701114+01:00"}
|
||||
{"id":"fotospiel-app-dar","title":"Uploader: retry policy for failed uploads","description":"Part of epic fotospiel-app-5aa. Auto-retry with backoff and retry limit before marking failed.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:03:00.808893045+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:03:00.808893045+01:00"}
|
||||
{"id":"fotospiel-app-de7","title":"Re-run admin Playwright tests with valid E2E credentials","status":"open","priority":3,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-15T19:53:26.674926731+01:00","created_by":"Codex Agent","updated_at":"2026-01-15T19:53:26.674926731+01:00"}
|
||||
{"id":"fotospiel-app-dl5","title":"SEC-API-01 Signed URL middleware + asset migration","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:52:24.24098702+01:00","created_by":"soeren","updated_at":"2026-01-01T15:52:29.8793891+01:00","closed_at":"2026-01-01T15:52:29.8793891+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-dm4","title":"SEC-BILL-01 Checkout session linkage + idempotency locks","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:53:26.350238207+01:00","created_by":"soeren","updated_at":"2026-01-01T15:53:31.997737421+01:00","closed_at":"2026-01-01T15:53:31.997737421+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-dmb","title":"Security review checklist: Event Admin dynamic tests","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:04:46.359468828+01:00","created_by":"soeren","updated_at":"2026-01-01T16:04:46.359468828+01:00"}
|
||||
@@ -85,6 +99,7 @@
|
||||
{"id":"fotospiel-app-iyh","title":"Security review follow-ups: signed URL TTLs, guest asset throttles, CORS allowlist, logging hygiene","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:05:42.642109576+01:00","created_by":"soeren","updated_at":"2026-01-01T16:05:42.642109576+01:00"}
|
||||
{"id":"fotospiel-app-jk4","title":"Checkout refactor: CheckoutController + marketing route alignment","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:06:21.088319132+01:00","created_by":"soeren","updated_at":"2026-01-01T16:06:26.663419594+01:00","closed_at":"2026-01-01T16:06:26.663419594+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-jqy","title":"Tenant admin onboarding: Playwright skeleton for welcome flow","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:08:11.226297707+01:00","created_by":"soeren","updated_at":"2026-01-01T16:08:16.827679424+01:00","closed_at":"2026-01-01T16:08:16.827679424+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-jy1","title":"Uploader: clear failed uploads UI","description":"Part of epic fotospiel-app-5aa. Add action to clear/reset failed items and counters.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:03:13.134661157+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:03:13.134661157+01:00"}
|
||||
{"id":"fotospiel-app-ko0","title":"Security review checklist: Webhooks/Billing dynamic tests","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:04:51.987093237+01:00","created_by":"soeren","updated_at":"2026-01-01T16:04:51.987093237+01:00"}
|
||||
{"id":"fotospiel-app-kry","title":"Paddle catalog sync: add DTO helpers for Paddle product/price responses","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:58:57.817750548+01:00","created_by":"soeren","updated_at":"2026-01-02T21:11:27.970220923+01:00","closed_at":"2026-01-02T21:11:27.970220923+01:00","close_reason":"Deprioritized"}
|
||||
{"id":"fotospiel-app-kso","title":"SEC-MS-02 Streaming upload refactor + tests","description":"Current state (code scan)\n- Guest uploads: App\\\\Http\\\\Controllers\\\\Api\\\\EventPublicController@upload uses Storage::disk()-\u003eputFile (stream-friendly) but still does watermark/thumbnail work inline.\n- Tenant admin uploads: App\\\\Http\\\\Controllers\\\\Api\\\\Tenant\\\\PhotoController@store and @uploadDirect use Storage::disk()-\u003eput($path, file_get_contents(...)) which loads entire file into memory.\n- Photobooth ingest already streams from import disk via readStream -\u003e Storage::disk()-\u003eput($path, $stream).\n- Presigned upload flow is stubbed to a local upload-direct endpoint; no true presigned S3 handling yet.\n- No tenant upload feature tests exist; guest upload tests exist and cover limits/security.\n\nGoal\n- Stream uploads to disk (avoid full in-memory buffers) for tenant-admin upload endpoints and keep behavior consistent across sources.\n\nPlan\n1) Introduce a small streaming upload helper/service\n - New service (e.g. App\\\\Services\\\\Storage\\\\UploadStreamService) that accepts UploadedFile + disk + destination path.\n - Use fopen on UploadedFile::getRealPath (or $file-\u003egetStream()) and Storage::disk($disk)-\u003eput($path, $stream) / writeStream.\n - Always close stream; return stored size and checksum (hash_file on stored path) for asset metadata.\n\n2) Refactor tenant upload endpoints to use streaming\n - Update PhotoController@store and @uploadDirect to use the helper instead of file_get_contents.\n - Use Storage::disk()-\u003eputFileAs (or helper) to preserve deterministic paths without buffering.\n - Keep existing validation, watermark, thumbnail, asset recording, and package usage logic.\n\n3) Optional consistency pass on guest upload\n - Consider routing EventPublicController@upload through the same helper for consistent storage + checksum handling, while keeping current validation/limits.\n\n4) Tests\n - Add Feature tests for tenant upload endpoints:\n - /api/v1/tenant/events/{slug}/photos (store) uploads a fake image and persists Photo + EventMediaAsset with expected path/size.\n - /api/v1/tenant/events/{slug}/upload-direct (presigned) uploads a fake image and stores asset + thumbnail.\n - Ensure existing guest upload tests still pass (no behavioral changes).\n\n5) Safety/ops\n - Verify streaming logic handles empty/invalid files gracefully and still reports errors via ApiError.\n - Keep request-time processing (thumb/watermark) unchanged for now; consider queuing in a follow-up if CPU spikes persist.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:53:03.729137616+01:00","created_by":"soeren","updated_at":"2026-01-02T20:51:17.752365339+01:00","closed_at":"2026-01-02T20:51:17.752365339+01:00","close_reason":"Closed"}
|
||||
@@ -92,6 +107,8 @@
|
||||
{"id":"fotospiel-app-l3n","title":"Session changes 2025-09-08 (PRP split, PWA scaffolding, Filament resources, API)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:10:18.204088457+01:00","created_by":"soeren","updated_at":"2026-01-01T16:10:23.815135505+01:00","closed_at":"2026-01-01T16:10:23.815135505+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-l6a","title":"Registration flow fixes: JSON redirect, error clearing, role handling","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:07:16.253760139+01:00","created_by":"soeren","updated_at":"2026-01-01T16:07:21.964843904+01:00","closed_at":"2026-01-01T16:07:21.964843904+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-l8q","title":"SEC-GT-02 Join-token analytics dashboard (Grafana)","description":"Logging + Filament summaries exist; Grafana dashboard still missing.","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:52:12.920875329+01:00","created_by":"soeren","updated_at":"2026-01-01T15:52:12.920875329+01:00"}
|
||||
{"id":"fotospiel-app-lj6","title":"Uploader: folder health enhancements","description":"Part of epic fotospiel-app-5aa. Track last file seen, write permissions, and show clearer folder status.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:03:22.843330813+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:03:22.843330813+01:00"}
|
||||
{"id":"fotospiel-app-llq","title":"Uploader: lock settings after connect","description":"Part of epic fotospiel-app-5aa. Prevent accidental changes to base URL/credentials unless explicitly unlocked.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:03:43.40971185+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:03:43.40971185+01:00"}
|
||||
{"id":"fotospiel-app-ln3","title":"Paddle catalog sync: announce workflow change to admin users","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:59:49.021233635+01:00","created_by":"soeren","updated_at":"2026-01-02T21:11:09.349495631+01:00","closed_at":"2026-01-02T21:11:09.349495631+01:00","close_reason":"Deprioritized"}
|
||||
{"id":"fotospiel-app-lnb","title":"SEC-GT-01 Hash join tokens + data migration","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:52:01.658868778+01:00","created_by":"soeren","updated_at":"2026-01-01T15:52:07.314317124+01:00","closed_at":"2026-01-01T15:52:07.314317124+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-lnf","title":"Remove legacy registration page assets","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-06T08:37:39.419274918+01:00","created_by":"soeren","updated_at":"2026-01-06T08:37:39.419274918+01:00"}
|
||||
@@ -101,6 +118,7 @@
|
||||
{"id":"fotospiel-app-ml7","title":"SEC-GT-03 Tighten gallery/photo rate limits + alerting","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:52:18.593415508+01:00","created_by":"soeren","updated_at":"2026-01-01T15:52:18.593415508+01:00"}
|
||||
{"id":"fotospiel-app-mol","title":"Coupon ops: wire analytics into Matomo dashboard","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:09:27.722458747+01:00","created_by":"soeren","updated_at":"2026-01-02T23:28:18.178704873+01:00","closed_at":"2026-01-02T23:28:18.178704873+01:00","close_reason":"Closed"}
|
||||
{"id":"fotospiel-app-mpu","title":"Checkout refactor: test coverage + rollout notes","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:06:43.488302531+01:00","created_by":"soeren","updated_at":"2026-01-01T16:06:49.13645691+01:00","closed_at":"2026-01-01T16:06:49.13645691+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-mwi","title":"Uploader: duplicate detection / upload cache","description":"Part of epic fotospiel-app-5aa. Track uploaded files (path/hash) to avoid re-uploads after restart.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:02:06.432781468+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:02:06.432781468+01:00"}
|
||||
{"id":"fotospiel-app-mx5","title":"Localized SEO: sitemap updated with locale alternates","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:02:15.177013722+01:00","created_by":"soeren","updated_at":"2026-01-01T16:02:20.812287917+01:00","closed_at":"2026-01-01T16:02:20.812287917+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-mxw","title":"Security review: configure env assumptions for dynamic testing","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:04:29.498402235+01:00","created_by":"soeren","updated_at":"2026-01-01T16:04:29.498402235+01:00"}
|
||||
{"id":"fotospiel-app-n8q","title":"Paddle migration: draft production cutover procedure","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:56:51.427425262+01:00","created_by":"soeren","updated_at":"2026-01-02T22:28:41.469357437+01:00","closed_at":"2026-01-02T22:28:41.469357437+01:00","close_reason":"Completed"}
|
||||
@@ -118,11 +136,16 @@
|
||||
{"id":"fotospiel-app-qlj","title":"Paddle catalog sync: verify legacy packages mapped before auto-sync","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:59:43.333792314+01:00","created_by":"soeren","updated_at":"2026-01-02T21:46:52.797515024+01:00","closed_at":"2026-01-02T21:46:52.797515024+01:00","close_reason":"Completed"}
|
||||
{"id":"fotospiel-app-qne","title":"Live Show: realtime delivery channel (WS/SSE) + fallback polling","acceptance_criteria":"- Public Live Show endpoints exist for state, updates, and SSE stream\\n- Updates endpoint supports cursor (after_approved_at + after_id)\\n- SSE emits photo.approved and ping, with settings updates when version changes\\n- Feature tests cover state, updates, invalid token","notes":"Added LiveShowController with public endpoints: /api/v1/live-show/{token} (state), /updates (polling), /stream (SSE). Provides live-show settings (defaults + event.settings.live_show merge), settings_version hash, ordered approved photo feed with cursor. SSE emits photo.approved, settings.updated, ping. Added routes in routes/api.php. Added Photo live_status default. Tests: tests/Feature/LiveShowRealtimeTest.php. Ran Pint + test.","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-01-05T11:11:06.028871737+01:00","created_by":"soeren","updated_at":"2026-01-05T13:08:33.936740582+01:00","closed_at":"2026-01-05T13:08:33.936740582+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-qne","depends_on_id":"fotospiel-app-t1k","type":"blocks","created_at":"2026-01-05T11:12:30.363982215+01:00","created_by":"soeren"}]}
|
||||
{"id":"fotospiel-app-qtn","title":"Security review kickoff mitigations (CORS allowlist, headers, upload hardening, signed URLs)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:09:46.310873311+01:00","created_by":"soeren","updated_at":"2026-01-01T16:09:51.914359487+01:00","closed_at":"2026-01-01T16:09:51.914359487+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-rpv","title":"Uploader: connection test (no upload)","description":"Part of epic fotospiel-app-5aa. Add lightweight ping/test for upload URL + credentials.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:02:39.061938692+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:02:39.061938692+01:00"}
|
||||
{"id":"fotospiel-app-sbs","title":"Compliance tools: data export + retention overrides","description":"GDPR-compliant export requests and retention override workflows for tenants/events.","status":"closed","priority":3,"issue_type":"feature","created_at":"2026-01-01T14:20:16.530289009+01:00","updated_at":"2026-01-02T20:13:31.704875591+01:00","closed_at":"2026-01-02T20:13:31.704875591+01:00","close_reason":"Closed"}
|
||||
{"id":"fotospiel-app-sdg","title":"Uploader: watch include/exclude patterns","description":"Part of epic fotospiel-app-5aa. Configurable file patterns (ignore tmp/preview) for watcher.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:02:17.188267106+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:02:17.188267106+01:00"}
|
||||
{"id":"fotospiel-app-sju","title":"Live Show link sharing + QR in admin","description":"Expose Live Show link in Event Admin with copy/share/open actions and embedded QR (use simplesoftwareio/simple-qrcode, no external service). Add API endpoints for link fetch/rotate, admin UI card with rotate confirmation, and tests.","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-05T20:00:25.427132538+01:00","created_by":"soeren","updated_at":"2026-01-05T20:00:25.427132538+01:00"}
|
||||
{"id":"fotospiel-app-spq8","title":"Eslint fails due to existing repo violations","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-19T18:49:19.208323875+01:00","created_by":"Codex Agent","updated_at":"2026-01-19T18:49:19.208323875+01:00"}
|
||||
{"id":"fotospiel-app-swb","title":"Security review: replace public asset URLs with signed routes","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:04:05.610098299+01:00","created_by":"soeren","updated_at":"2026-01-01T16:04:11.215921463+01:00","closed_at":"2026-01-01T16:04:11.215921463+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-t1k","title":"Live Show: data model \u0026 status workflow (pending/approved/ready)","acceptance_criteria":"- DB migrations add event token + photo live fields + indexes\\n- Token generation supports rotation (no expiry)\\n- Photo live workflow methods set timestamps/reviewer consistently\\n- Feature test covers token + workflow","notes":"Implemented Live Show data model: events.live_show_token + live_show_token_rotated_at; photos.live_status + timestamps/reviewer/rejection fields + indexes. Added PhotoLiveStatus enum and Photo workflow methods (markLivePending/approveForLiveShow/rejectForLiveShow). Added Event helpers (ensureLiveShowToken/rotateLiveShowToken). Tests: tests/Feature/LiveShowDataModelTest.php.","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-01-05T11:10:56.560421826+01:00","created_by":"soeren","updated_at":"2026-01-05T12:22:51.967913423+01:00","closed_at":"2026-01-05T12:22:51.967913423+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-t1k","depends_on_id":"fotospiel-app-vro","type":"blocks","created_at":"2026-01-05T11:12:20.345646244+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-t1k","depends_on_id":"fotospiel-app-h5d","type":"blocks","created_at":"2026-01-05T11:44:12.439413712+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-t1k","depends_on_id":"fotospiel-app-1eu","type":"blocks","created_at":"2026-01-05T11:44:22.588642567+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-t1k","depends_on_id":"fotospiel-app-1we","type":"blocks","created_at":"2026-01-05T11:44:31.775634827+01:00","created_by":"soeren"}]}
|
||||
{"id":"fotospiel-app-t2s","title":"Uploader: multiple event profiles","description":"Part of epic fotospiel-app-5aa. Save multiple event profiles and allow quick switching.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:04:18.20222112+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:04:18.20222112+01:00"}
|
||||
{"id":"fotospiel-app-tqg","title":"Tenant admin onboarding: staging E2E validation","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:08:57.448899354+01:00","created_by":"soeren","updated_at":"2026-01-01T16:08:57.448899354+01:00"}
|
||||
{"id":"fotospiel-app-tsb","title":"Uploader: upload throttling presets","description":"Part of epic fotospiel-app-5aa. Add optional delay/presets to smooth upload bursts.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:02:27.111436345+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:02:27.111436345+01:00"}
|
||||
{"id":"fotospiel-app-ty9","title":"Security review: data classes \u0026 retention baseline","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:03:09.595870306+01:00","created_by":"soeren","updated_at":"2026-01-01T16:03:15.211042718+01:00","closed_at":"2026-01-01T16:03:15.211042718+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-tym","title":"Ops health dashboard (queues, storage, upload pipeline)","description":"Superadmin ops dashboard showing queue backlog, failed jobs, storage thresholds, and upload pipeline health.","notes":"Implemented Ops Health dashboard with storage+queue widgets, new translations, and navigation wiring.","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-01-01T14:20:04.991351193+01:00","updated_at":"2026-01-02T17:34:10.326367902+01:00","closed_at":"2026-01-02T17:34:10.326367902+01:00","close_reason":"Closed"}
|
||||
{"id":"fotospiel-app-ugk","title":"Paddle catalog sync: feature test for artisan command","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:01:33.309716868+01:00","created_by":"soeren","updated_at":"2026-01-01T16:01:38.940407157+01:00","closed_at":"2026-01-01T16:01:38.940407157+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
@@ -141,6 +164,7 @@
|
||||
{"id":"fotospiel-app-wku","title":"Security review: run dynamic testing harness (identities, DAST, fuzz uploads)","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:05:37.008239379+01:00","created_by":"soeren","updated_at":"2026-01-01T16:05:37.008239379+01:00"}
|
||||
{"id":"fotospiel-app-xg5","title":"Live Show: Admin app moderation queue UI","acceptance_criteria":"- Dedicated Live Show moderation API endpoints exist for list + approve/reject/clear\\n- Admin mobile UI exposes Live Show queue with status filter and actions\\n- PhotoResource includes live_* fields for admin UI\\n- Feature tests cover list + approve/reject/clear workflows","notes":"Added dedicated Live Show moderation API (tenant admin): /events/{slug}/live-show/photos + approve/reject/clear actions. Added LiveShowPhotoController + FormRequests. PhotoResource now exposes live_* fields. Admin app: new Live Show queue page, route, and Event detail shortcut tile. Admin API updated with Live Show functions + types. Added translations (EN/DE) for Live Show queue UI. Tests: tests/Feature/LiveShowPhotoControllerTest.php.","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-01-05T11:11:15.006484132+01:00","created_by":"soeren","updated_at":"2026-01-05T14:03:41.410176482+01:00","closed_at":"2026-01-05T14:03:41.410176482+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-xg5","depends_on_id":"fotospiel-app-t1k","type":"blocks","created_at":"2026-01-05T11:12:38.94145573+01:00","created_by":"soeren"}]}
|
||||
{"id":"fotospiel-app-xht","title":"Paddle migration: tenant ↔ Paddle customer sync + webhook handlers","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:58:01.028435913+01:00","created_by":"soeren","updated_at":"2026-01-01T15:58:06.685122343+01:00","closed_at":"2026-01-01T15:58:06.685122343+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-xik","title":"Uploader: richer error details","description":"Part of epic fotospiel-app-5aa. Surface HTTP status/body summary in last error and recent uploads.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:02:49.591107008+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:02:49.591107008+01:00"}
|
||||
{"id":"fotospiel-app-y1f","title":"Compliance tools: superadmin data export + retention override UI","description":"Add superadmin compliance tools for data exports and retention overrides.\nScope: list export requests, status, expiry, and allow manual retry/cancel; add per-tenant/event retention override UI with audit logging.\nEnsure access is restricted to superadmins and no PII is exposed beyond existing export metadata.\n","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-02T17:34:29.825347299+01:00","created_by":"soeren","updated_at":"2026-01-02T22:49:53.586758621+01:00","closed_at":"2026-01-02T22:49:53.586758621+01:00","close_reason":"Closed"}
|
||||
{"id":"fotospiel-app-yii","title":"Implement 'Upgrade to Premium' flow for Analytics Upsell","description":"The Analytics page currently has an upsell screen for non-premium users. The 'Upgrade to Premium' button redirects to the billing page, but the actual upgrade/purchase flow needs to be fully implemented and verified to allow users to unlock the feature.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T16:13:55.446495378+01:00","created_by":"soeren","updated_at":"2026-01-06T16:35:41.968964977+01:00","closed_at":"2026-01-06T16:35:41.968970147+01:00"}
|
||||
{"id":"fotospiel-app-z2k","title":"Ops health widget visual polish","description":"Replace Tailwind utility styling in ops health widget with Filament components and icon-driven layout.","notes":"Updated queue health widget layout to use Filament cards, badges, empty states, and grid utilities; added status strip and alert rail.","status":"closed","priority":3,"issue_type":"task","created_at":"2026-01-01T21:34:39.851728527+01:00","created_by":"soeren","updated_at":"2026-01-01T21:34:59.834597413+01:00","closed_at":"2026-01-01T21:34:59.834597413+01:00","close_reason":"completed"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
fotospiel-app-9em
|
||||
fotospiel-app-29r
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -13,6 +13,8 @@ fotospiel-tenant-app
|
||||
/storage/*.key
|
||||
/storage/pail
|
||||
/vendor
|
||||
/clients/photobooth-uploader/**/bin
|
||||
/clients/photobooth-uploader/**/obj
|
||||
.env
|
||||
.env.backup
|
||||
.env.production
|
||||
@@ -26,3 +28,6 @@ yarn-error.log
|
||||
/.vscode
|
||||
test-results
|
||||
GEMINI.md
|
||||
.beads/.sync.lock
|
||||
.beads/daemon-error
|
||||
.beads/sync_base.jsonl
|
||||
|
||||
@@ -40,7 +40,7 @@ class Login extends BaseLogin implements HasForms
|
||||
}
|
||||
|
||||
// SuperAdmin-spezifisch: Prüfe auf SuperAdmin-Rolle, keine Tenant-Prüfung
|
||||
if ($user->role !== 'super_admin') {
|
||||
if (! $user->isSuperAdmin()) {
|
||||
$authGuard->logout();
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
|
||||
@@ -45,11 +45,11 @@ class GuestPolicySettingsPage extends Page
|
||||
|
||||
public int $join_token_failure_decay_minutes = 5;
|
||||
|
||||
public int $join_token_access_limit = 120;
|
||||
public int $join_token_access_limit = 300;
|
||||
|
||||
public int $join_token_access_decay_minutes = 1;
|
||||
|
||||
public int $join_token_download_limit = 60;
|
||||
public int $join_token_download_limit = 120;
|
||||
|
||||
public int $join_token_download_decay_minutes = 1;
|
||||
|
||||
@@ -69,9 +69,9 @@ class GuestPolicySettingsPage extends Page
|
||||
$this->per_device_upload_limit = (int) ($settings->per_device_upload_limit ?? 50);
|
||||
$this->join_token_failure_limit = (int) ($settings->join_token_failure_limit ?? 10);
|
||||
$this->join_token_failure_decay_minutes = (int) ($settings->join_token_failure_decay_minutes ?? 5);
|
||||
$this->join_token_access_limit = (int) ($settings->join_token_access_limit ?? 120);
|
||||
$this->join_token_access_limit = (int) ($settings->join_token_access_limit ?? 300);
|
||||
$this->join_token_access_decay_minutes = (int) ($settings->join_token_access_decay_minutes ?? 1);
|
||||
$this->join_token_download_limit = (int) ($settings->join_token_download_limit ?? 60);
|
||||
$this->join_token_download_limit = (int) ($settings->join_token_download_limit ?? 120);
|
||||
$this->join_token_download_decay_minutes = (int) ($settings->join_token_download_decay_minutes ?? 1);
|
||||
$this->join_token_ttl_hours = (int) ($settings->join_token_ttl_hours ?? 168);
|
||||
$this->share_link_ttl_hours = (int) ($settings->share_link_ttl_hours ?? 48);
|
||||
|
||||
@@ -3,9 +3,12 @@
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Checkout\CheckoutSessionStatusRequest;
|
||||
use App\Models\CheckoutSession;
|
||||
use App\Models\Package;
|
||||
use App\Models\PackagePurchase;
|
||||
use App\Models\TenantPackage;
|
||||
use App\Services\Checkout\CheckoutSessionService;
|
||||
use App\Services\Paddle\PaddleCheckoutService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -14,7 +17,10 @@ use Illuminate\Validation\ValidationException;
|
||||
|
||||
class PackageController extends Controller
|
||||
{
|
||||
public function __construct(private readonly PaddleCheckoutService $paddleCheckout) {}
|
||||
public function __construct(
|
||||
private readonly PaddleCheckoutService $paddleCheckout,
|
||||
private readonly CheckoutSessionService $sessions,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
@@ -165,23 +171,82 @@ class PackageController extends Controller
|
||||
|
||||
$package = Package::findOrFail($request->integer('package_id'));
|
||||
$tenant = $request->attributes->get('tenant');
|
||||
$user = $request->user();
|
||||
|
||||
if (! $tenant) {
|
||||
throw ValidationException::withMessages(['tenant' => 'Tenant context missing.']);
|
||||
}
|
||||
|
||||
if (! $user) {
|
||||
throw ValidationException::withMessages(['user' => 'User context missing.']);
|
||||
}
|
||||
|
||||
if (! $package->paddle_price_id) {
|
||||
throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Paddle price.']);
|
||||
}
|
||||
|
||||
$session = $this->sessions->createOrResume($user, $package, [
|
||||
'tenant' => $tenant,
|
||||
]);
|
||||
|
||||
$this->sessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE);
|
||||
|
||||
$now = now();
|
||||
|
||||
$session->forceFill([
|
||||
'accepted_terms_at' => $now,
|
||||
'accepted_privacy_at' => $now,
|
||||
'accepted_withdrawal_notice_at' => $now,
|
||||
'digital_content_waiver_at' => null,
|
||||
'legal_version' => config('app.legal_version', $now->toDateString()),
|
||||
])->save();
|
||||
|
||||
$payload = [
|
||||
'success_url' => $request->input('success_url'),
|
||||
'return_url' => $request->input('return_url'),
|
||||
'metadata' => [
|
||||
'checkout_session_id' => $session->id,
|
||||
'legal_version' => $session->legal_version,
|
||||
'accepted_terms' => true,
|
||||
],
|
||||
];
|
||||
|
||||
$checkout = $this->paddleCheckout->createCheckout($tenant, $package, $payload);
|
||||
|
||||
return response()->json($checkout);
|
||||
$session->forceFill([
|
||||
'paddle_checkout_id' => $checkout['id'] ?? $session->paddle_checkout_id,
|
||||
'provider_metadata' => array_merge($session->provider_metadata ?? [], array_filter([
|
||||
'paddle_checkout_id' => $checkout['id'] ?? null,
|
||||
'paddle_checkout_url' => $checkout['checkout_url'] ?? null,
|
||||
'paddle_expires_at' => $checkout['expires_at'] ?? null,
|
||||
])),
|
||||
])->save();
|
||||
|
||||
return response()->json(array_merge($checkout, [
|
||||
'checkout_session_id' => $session->id,
|
||||
]));
|
||||
}
|
||||
|
||||
public function checkoutSessionStatus(CheckoutSessionStatusRequest $request, CheckoutSession $session): JsonResponse
|
||||
{
|
||||
$history = $session->status_history ?? [];
|
||||
$reason = null;
|
||||
|
||||
foreach (array_reverse($history) as $entry) {
|
||||
if (($entry['status'] ?? null) === $session->status) {
|
||||
$reason = $entry['reason'] ?? null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$checkoutUrl = data_get($session->provider_metadata ?? [], 'paddle_checkout_url');
|
||||
|
||||
return response()->json([
|
||||
'status' => $session->status,
|
||||
'completed_at' => optional($session->completed_at)->toIso8601String(),
|
||||
'reason' => $reason,
|
||||
'checkout_url' => is_string($checkoutUrl) ? $checkoutUrl : null,
|
||||
]);
|
||||
}
|
||||
|
||||
private function handleFreePurchase(Request $request, Package $package, $tenant): JsonResponse
|
||||
|
||||
45
app/Http/Controllers/Api/PhotoboothConnectController.php
Normal file
45
app/Http/Controllers/Api/PhotoboothConnectController.php
Normal 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'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ use App\Models\Package;
|
||||
use App\Models\PackagePurchase;
|
||||
use App\Models\Photo;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\EventJoinTokenService;
|
||||
use App\Support\ApiError;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -88,12 +89,15 @@ class EventController extends Controller
|
||||
$tenant = Tenant::findOrFail($tenantId);
|
||||
}
|
||||
|
||||
$actor = $request->user();
|
||||
$isSuperAdmin = $actor instanceof User && $actor->isSuperAdmin();
|
||||
|
||||
// Package check is now handled by middleware
|
||||
|
||||
$validated = $request->validated();
|
||||
$tenantId = $tenant->id;
|
||||
|
||||
$requestedPackageId = $validated['package_id'] ?? null;
|
||||
$requestedPackageId = $isSuperAdmin ? $request->integer('package_id') : null;
|
||||
unset($validated['package_id']);
|
||||
|
||||
$tenantPackage = $tenant->tenantPackages()
|
||||
@@ -108,6 +112,10 @@ class EventController extends Controller
|
||||
$package = Package::query()->find($requestedPackageId);
|
||||
}
|
||||
|
||||
if (! $package && $isSuperAdmin) {
|
||||
$package = $this->resolveOwnerPackage();
|
||||
}
|
||||
|
||||
if (! $package && $tenantPackage) {
|
||||
$package = $tenantPackage->package ?? Package::query()->find($tenantPackage->package_id);
|
||||
}
|
||||
@@ -121,7 +129,7 @@ class EventController extends Controller
|
||||
$requiresWaiver = $package->isEndcustomer();
|
||||
$latestPurchase = $requiresWaiver ? $this->resolveLatestPackagePurchase($tenant, $package) : null;
|
||||
$existingWaiver = $latestPurchase ? data_get($latestPurchase->metadata, 'consents.digital_content_waiver_at') : null;
|
||||
$needsWaiver = $requiresWaiver && ! $existingWaiver;
|
||||
$needsWaiver = ! $isSuperAdmin && $requiresWaiver && ! $existingWaiver;
|
||||
|
||||
if ($needsWaiver && ! $request->boolean('accepted_waiver')) {
|
||||
throw ValidationException::withMessages([
|
||||
@@ -182,7 +190,7 @@ class EventController extends Controller
|
||||
|
||||
$eventData = Arr::only($eventData, $allowed);
|
||||
|
||||
$event = DB::transaction(function () use ($tenant, $eventData, $package) {
|
||||
$event = DB::transaction(function () use ($tenant, $eventData, $package, $isSuperAdmin) {
|
||||
$event = Event::create($eventData);
|
||||
|
||||
EventPackage::create([
|
||||
@@ -193,7 +201,7 @@ class EventController extends Controller
|
||||
'gallery_expires_at' => $package->gallery_days ? now()->addDays($package->gallery_days) : null,
|
||||
]);
|
||||
|
||||
if ($package->isReseller()) {
|
||||
if ($package->isReseller() && ! $isSuperAdmin) {
|
||||
$note = sprintf('Event #%d created (%s)', $event->id, $event->name);
|
||||
|
||||
if (! $tenant->consumeEventAllowance(1, 'event.create', $note)) {
|
||||
@@ -229,6 +237,15 @@ class EventController extends Controller
|
||||
->first();
|
||||
}
|
||||
|
||||
private function resolveOwnerPackage(): ?Package
|
||||
{
|
||||
$ownerPackage = Package::query()
|
||||
->where('slug', 'pro')
|
||||
->first();
|
||||
|
||||
return $ownerPackage ?? Package::query()->find(3);
|
||||
}
|
||||
|
||||
private function recordEventStartWaiver(Tenant $tenant, Package $package, ?PackagePurchase $purchase): void
|
||||
{
|
||||
$timestamp = now();
|
||||
|
||||
@@ -135,7 +135,7 @@ class EventMemberController extends Controller
|
||||
$user->password = Hash::make(Str::random(32));
|
||||
}
|
||||
|
||||
if ($user->tenant_id && (int) $user->tenant_id !== (int) $tenant->id && $user->role !== 'super_admin') {
|
||||
if ($user->tenant_id && (int) $user->tenant_id !== (int) $tenant->id && ! $user->isSuperAdmin()) {
|
||||
throw ValidationException::withMessages([
|
||||
'email' => __('Dieser Benutzer ist einem anderen Mandanten zugeordnet.'),
|
||||
]);
|
||||
@@ -143,9 +143,9 @@ class EventMemberController extends Controller
|
||||
|
||||
$user->tenant_id = $tenant->id;
|
||||
|
||||
if ($role === 'tenant_admin' && $user->role !== 'super_admin') {
|
||||
if ($role === 'tenant_admin' && ! $user->isSuperAdmin()) {
|
||||
$user->role = 'tenant_admin';
|
||||
} elseif (! in_array($user->role, ['tenant_admin', 'super_admin'], true)) {
|
||||
} elseif (! in_array($user->role, ['tenant_admin', 'super_admin', 'superadmin'], true)) {
|
||||
$user->role = 'member';
|
||||
}
|
||||
|
||||
|
||||
@@ -525,13 +525,13 @@ class PhotoController extends Controller
|
||||
]);
|
||||
|
||||
// Only tenant admins can moderate
|
||||
if (isset($validated['status']) && ! $this->tokenHasScope($request, 'tenant:write')) {
|
||||
if (isset($validated['status']) && ! $this->tokenHasScope($request, 'tenant-admin')) {
|
||||
return ApiError::response(
|
||||
'insufficient_scope',
|
||||
'Insufficient Scopes',
|
||||
'You are not allowed to moderate photos for this event.',
|
||||
Response::HTTP_FORBIDDEN,
|
||||
['required_scope' => 'tenant:write']
|
||||
['required_scope' => 'tenant-admin']
|
||||
);
|
||||
}
|
||||
|
||||
@@ -823,6 +823,11 @@ class PhotoController extends Controller
|
||||
|
||||
private function tokenHasScope(Request $request, string $scope): bool
|
||||
{
|
||||
$accessToken = $request->user()?->currentAccessToken();
|
||||
if ($accessToken && $accessToken->can($scope)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$scopes = $request->user()->scopes ?? ($request->attributes->get('decoded_token')['scopes'] ?? []);
|
||||
|
||||
if (! is_array($scopes)) {
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -193,11 +193,11 @@ class TenantAdminTokenController extends Controller
|
||||
$abilities[] = 'tenant:'.$user->tenant_id;
|
||||
}
|
||||
|
||||
if (in_array($user->role, ['tenant_admin', 'admin', 'super_admin'], true)) {
|
||||
if (in_array($user->role, ['tenant_admin', 'admin', 'super_admin', 'superadmin'], true)) {
|
||||
$abilities[] = 'tenant-admin';
|
||||
}
|
||||
|
||||
if ($user->role === 'super_admin') {
|
||||
if ($user->isSuperAdmin()) {
|
||||
$abilities[] = 'super-admin';
|
||||
}
|
||||
|
||||
@@ -219,7 +219,7 @@ class TenantAdminTokenController extends Controller
|
||||
|
||||
private function ensureUserCanAccessPanel(User $user): void
|
||||
{
|
||||
if (in_array($user->role, ['tenant_admin', 'admin', 'super_admin'], true)) {
|
||||
if (in_array($user->role, ['tenant_admin', 'admin', 'super_admin', 'superadmin'], true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ use App\Models\User;
|
||||
use App\Notifications\TenantFeedbackSubmitted;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class TenantFeedbackController extends Controller
|
||||
{
|
||||
@@ -56,7 +56,7 @@ class TenantFeedbackController extends Controller
|
||||
]);
|
||||
|
||||
$recipients = User::query()
|
||||
->where('role', 'super_admin')
|
||||
->whereIn('role', ['super_admin', 'superadmin'])
|
||||
->whereNotNull('email')
|
||||
->get();
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ class TenantAdminPasswordResetController extends Controller
|
||||
|
||||
private function canAccessEventAdmin(User $user): bool
|
||||
{
|
||||
if (in_array($user->role, ['tenant_admin', 'admin', 'super_admin'], true)) {
|
||||
if (in_array($user->role, ['tenant_admin', 'admin', 'super_admin', 'superadmin'], true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -155,7 +155,7 @@ class AuthenticatedSessionController extends Controller
|
||||
}
|
||||
|
||||
// Super admins go to Filament superadmin panel
|
||||
if ($user && $user->role === 'super_admin') {
|
||||
if ($user && $user->isSuperAdmin()) {
|
||||
return '/super-admin';
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ class TenantAdminAuthController extends Controller
|
||||
$user = Auth::user();
|
||||
|
||||
// Allow only tenant_admin and super_admin
|
||||
if ($user && in_array($user->role, ['tenant_admin', 'super_admin'])) {
|
||||
if ($user && in_array($user->role, ['tenant_admin', 'super_admin', 'superadmin'], true)) {
|
||||
return view('admin');
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ class TenantAdminGoogleController extends Controller
|
||||
/** @var User|null $user */
|
||||
$user = User::query()->where('email', $email)->first();
|
||||
|
||||
if (! $user || ! in_array($user->role, ['tenant_admin', 'super_admin'], true)) {
|
||||
if (! $user || ! in_array($user->role, ['tenant_admin', 'super_admin', 'superadmin'], true)) {
|
||||
return $this->sendBackWithError($request, 'google_no_match', 'No tenant admin account is linked to this Google address.');
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Packages\PackageLimitEvaluator;
|
||||
use App\Support\ApiError;
|
||||
use Closure;
|
||||
@@ -26,7 +27,7 @@ class CreditCheckMiddleware
|
||||
]);
|
||||
}
|
||||
|
||||
if ($this->requiresCredits($request)) {
|
||||
if ($this->requiresCredits($request) && ! $this->shouldBypassCreditCheck($request, $tenant)) {
|
||||
$violation = $this->limitEvaluator->assessEventCreation($tenant);
|
||||
|
||||
if ($violation !== null) {
|
||||
@@ -43,6 +44,24 @@ class CreditCheckMiddleware
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
private function shouldBypassCreditCheck(Request $request, Tenant $tenant): bool
|
||||
{
|
||||
$user = $request->user();
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $user->isSuperAdmin()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $user->tenant_id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (int) $user->tenant_id === (int) $tenant->id;
|
||||
}
|
||||
|
||||
private function requiresCredits(Request $request): bool
|
||||
{
|
||||
return $request->isMethod('post')
|
||||
|
||||
@@ -42,7 +42,7 @@ class EnsureTenantAdminToken
|
||||
/** @var Tenant|null $tenant */
|
||||
$tenant = $user->tenant;
|
||||
|
||||
if (! $tenant && $user->role === 'super_admin') {
|
||||
if (! $tenant && $user->isSuperAdmin()) {
|
||||
$requestedTenantId = $this->resolveRequestedTenantId($request);
|
||||
|
||||
if ($requestedTenantId !== null) {
|
||||
@@ -50,14 +50,14 @@ class EnsureTenantAdminToken
|
||||
}
|
||||
}
|
||||
|
||||
if (! $tenant && $user->role !== 'super_admin') {
|
||||
if (! $tenant && ! $user->isSuperAdmin()) {
|
||||
return $this->forbiddenResponse('Tenant context missing for user.');
|
||||
}
|
||||
|
||||
if ($tenant) {
|
||||
$request->attributes->set('tenant_id', $tenant->id);
|
||||
$request->attributes->set('tenant', $tenant);
|
||||
} elseif ($user->role === 'super_admin') {
|
||||
} elseif ($user->isSuperAdmin()) {
|
||||
$requestedTenantId = $this->resolveRequestedTenantId($request);
|
||||
if ($requestedTenantId !== null) {
|
||||
$request->attributes->set('tenant_id', $requestedTenantId);
|
||||
@@ -96,7 +96,7 @@ class EnsureTenantAdminToken
|
||||
*/
|
||||
protected function allowedRoles(): array
|
||||
{
|
||||
return ['tenant_admin', 'super_admin', 'admin'];
|
||||
return ['tenant_admin', 'super_admin', 'superadmin', 'admin'];
|
||||
}
|
||||
|
||||
protected function forbiddenRoleMessage(): string
|
||||
|
||||
@@ -9,7 +9,7 @@ class EnsureTenantCollaboratorToken extends EnsureTenantAdminToken
|
||||
{
|
||||
protected function allowedRoles(): array
|
||||
{
|
||||
return ['tenant_admin', 'super_admin', 'admin', 'member'];
|
||||
return ['tenant_admin', 'super_admin', 'superadmin', 'admin', 'member'];
|
||||
}
|
||||
|
||||
protected function forbiddenRoleMessage(): string
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Packages\PackageLimitEvaluator;
|
||||
use App\Support\ApiError;
|
||||
use Closure;
|
||||
@@ -26,7 +27,7 @@ class PackageMiddleware
|
||||
]);
|
||||
}
|
||||
|
||||
if ($this->requiresPackageCheck($request)) {
|
||||
if ($this->requiresPackageCheck($request) && ! $this->shouldBypassPackageCheck($request, $tenant)) {
|
||||
$violation = $this->detectViolation($request, $tenant);
|
||||
|
||||
if ($violation !== null) {
|
||||
@@ -43,6 +44,24 @@ class PackageMiddleware
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
private function shouldBypassPackageCheck(Request $request, Tenant $tenant): bool
|
||||
{
|
||||
$user = $request->user();
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $user->isSuperAdmin()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $user->tenant_id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (int) $user->tenant_id === (int) $tenant->id;
|
||||
}
|
||||
|
||||
private function requiresPackageCheck(Request $request): bool
|
||||
{
|
||||
return $request->isMethod('post') && (
|
||||
|
||||
@@ -112,7 +112,7 @@ class RedirectIfAuthenticated extends BaseMiddleware
|
||||
return '/event-admin/dashboard';
|
||||
}
|
||||
|
||||
if ($user && $user->role === 'super_admin') {
|
||||
if ($user && $user->isSuperAdmin()) {
|
||||
return '/super-admin';
|
||||
}
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@ namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class SuperAdminAuth
|
||||
{
|
||||
@@ -21,17 +21,17 @@ class SuperAdminAuth
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if (!Auth::check()) {
|
||||
if (! Auth::check()) {
|
||||
abort(403, 'Nicht angemeldet.');
|
||||
}
|
||||
|
||||
$user = Auth::user();
|
||||
Log::info('SuperAdminAuth: User ID ' . $user->id . ', role: ' . $user->role);
|
||||
Log::info('SuperAdminAuth: User ID '.$user->id.', role: '.$user->role);
|
||||
|
||||
if ($user->role !== 'super_admin') {
|
||||
abort(403, 'Zugriff nur für SuperAdmin. User ID: ' . $user->id . ', Role: ' . $user->role);
|
||||
if (! $user->isSuperAdmin()) {
|
||||
abort(403, 'Zugriff nur für SuperAdmin. User ID: '.$user->id.', Role: '.$user->role);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ class EventStoreRequest extends FormRequest
|
||||
'event_date' => ['required', 'date', 'after_or_equal:today'],
|
||||
'location' => ['nullable', 'string', 'max:255'],
|
||||
'event_type_id' => ['required', 'exists:event_types,id'],
|
||||
'package_id' => ['nullable', 'integer', 'exists:packages,id'],
|
||||
'max_participants' => ['nullable', 'integer', 'min:1', 'max:10000'],
|
||||
'public_url' => ['nullable', 'url', 'max:500'],
|
||||
'custom_domain' => ['nullable', 'string', 'max:255'],
|
||||
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,19 @@ namespace App\Listeners\GuestNotifications;
|
||||
use App\Enums\GuestNotificationAudience;
|
||||
use App\Enums\GuestNotificationType;
|
||||
use App\Events\GuestPhotoUploaded;
|
||||
use App\Models\GuestNotification;
|
||||
use App\Models\Photo;
|
||||
use App\Services\GuestNotificationService;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class SendPhotoUploadedNotification
|
||||
{
|
||||
private const DEDUPE_WINDOW_SECONDS = 30;
|
||||
|
||||
private const GROUP_WINDOW_MINUTES = 10;
|
||||
|
||||
private const MAX_GROUP_PHOTOS = 6;
|
||||
|
||||
/**
|
||||
* @param int[] $milestones
|
||||
*/
|
||||
@@ -25,7 +33,20 @@ class SendPhotoUploadedNotification
|
||||
? sprintf('%s hat gerade ein Foto gemacht 🎉', $guestLabel)
|
||||
: 'Es gibt neue Fotos!';
|
||||
|
||||
$this->notifications->createNotification(
|
||||
$recent = $this->findRecentPhotoNotification($event->event->id);
|
||||
if ($recent) {
|
||||
if ($this->shouldSkipDuplicate($recent, $event->photoId, $title)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$notification = $this->updateGroupedNotification($recent, $event->photoId);
|
||||
$this->markUploaderRead($notification, $event->guestIdentifier);
|
||||
$this->maybeCreateMilestoneNotification($event, $guestLabel);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$notification = $this->notifications->createNotification(
|
||||
$event->event,
|
||||
GuestNotificationType::PHOTO_ACTIVITY,
|
||||
$title,
|
||||
@@ -34,11 +55,15 @@ class SendPhotoUploadedNotification
|
||||
'audience_scope' => GuestNotificationAudience::ALL,
|
||||
'payload' => [
|
||||
'photo_id' => $event->photoId,
|
||||
'photo_ids' => [$event->photoId],
|
||||
'count' => 1,
|
||||
],
|
||||
'expires_at' => now()->addHours(3),
|
||||
]
|
||||
);
|
||||
|
||||
$this->markUploaderRead($notification, $event->guestIdentifier);
|
||||
|
||||
$this->maybeCreateMilestoneNotification($event, $guestLabel);
|
||||
}
|
||||
|
||||
@@ -87,4 +112,94 @@ class SendPhotoUploadedNotification
|
||||
|
||||
return $guestIdentifier;
|
||||
}
|
||||
|
||||
private function findRecentPhotoNotification(int $eventId): ?GuestNotification
|
||||
{
|
||||
$cutoff = Carbon::now()->subMinutes(self::GROUP_WINDOW_MINUTES);
|
||||
|
||||
return GuestNotification::query()
|
||||
->where('event_id', $eventId)
|
||||
->where('type', GuestNotificationType::PHOTO_ACTIVITY)
|
||||
->active()
|
||||
->notExpired()
|
||||
->where('created_at', '>=', $cutoff)
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
}
|
||||
|
||||
private function shouldSkipDuplicate(GuestNotification $notification, int $photoId, string $title): bool
|
||||
{
|
||||
$payload = $notification->payload;
|
||||
if (is_array($payload)) {
|
||||
$payloadIds = array_filter(
|
||||
array_map(
|
||||
fn ($value) => is_numeric($value) ? (int) $value : null,
|
||||
(array) ($payload['photo_ids'] ?? [])
|
||||
),
|
||||
fn ($value) => $value !== null && $value > 0
|
||||
);
|
||||
if (in_array($photoId, $payloadIds, true)) {
|
||||
return true;
|
||||
}
|
||||
if (is_numeric($payload['photo_id'] ?? null) && (int) $payload['photo_id'] === $photoId) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
$cutoff = Carbon::now()->subSeconds(self::DEDUPE_WINDOW_SECONDS);
|
||||
if ($notification->created_at instanceof Carbon && $notification->created_at->greaterThanOrEqualTo($cutoff)) {
|
||||
return $notification->title === $title;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function updateGroupedNotification(GuestNotification $notification, int $photoId): GuestNotification
|
||||
{
|
||||
$payload = is_array($notification->payload) ? $notification->payload : [];
|
||||
$photoIds = array_filter(
|
||||
array_map(
|
||||
fn ($value) => is_numeric($value) ? (int) $value : null,
|
||||
(array) ($payload['photo_ids'] ?? [])
|
||||
),
|
||||
fn ($value) => $value !== null && $value > 0
|
||||
);
|
||||
$photoIds[] = $photoId;
|
||||
$photoIds = array_values(array_unique($photoIds));
|
||||
$photoIds = array_slice($photoIds, 0, self::MAX_GROUP_PHOTOS);
|
||||
|
||||
$existingCount = is_numeric($payload['count'] ?? null)
|
||||
? max(1, (int) $payload['count'])
|
||||
: max(1, count($photoIds) - 1);
|
||||
$newCount = $existingCount + 1;
|
||||
|
||||
$notification->forceFill([
|
||||
'title' => $this->buildGroupedTitle($newCount),
|
||||
'payload' => [
|
||||
'count' => $newCount,
|
||||
'photo_ids' => $photoIds,
|
||||
],
|
||||
])->save();
|
||||
|
||||
return $notification;
|
||||
}
|
||||
|
||||
private function buildGroupedTitle(int $count): string
|
||||
{
|
||||
if ($count <= 1) {
|
||||
return 'Es gibt neue Fotos!';
|
||||
}
|
||||
|
||||
return sprintf('Es gibt %d neue Fotos!', $count);
|
||||
}
|
||||
|
||||
private function markUploaderRead(GuestNotification $notification, string $guestIdentifier): void
|
||||
{
|
||||
$guestIdentifier = trim($guestIdentifier);
|
||||
if ($guestIdentifier === '' || $guestIdentifier === 'anonymous') {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->notifications->markAsRead($notification, $guestIdentifier);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,9 +41,9 @@ class GuestPolicySetting extends Model
|
||||
'per_device_upload_limit' => 50,
|
||||
'join_token_failure_limit' => (int) config('join_tokens.failure_limit', 10),
|
||||
'join_token_failure_decay_minutes' => (int) config('join_tokens.failure_decay_minutes', 5),
|
||||
'join_token_access_limit' => (int) config('join_tokens.access_limit', 120),
|
||||
'join_token_access_limit' => (int) config('join_tokens.access_limit', 300),
|
||||
'join_token_access_decay_minutes' => (int) config('join_tokens.access_decay_minutes', 1),
|
||||
'join_token_download_limit' => (int) config('join_tokens.download_limit', 60),
|
||||
'join_token_download_limit' => (int) config('join_tokens.download_limit', 120),
|
||||
'join_token_download_decay_minutes' => (int) config('join_tokens.download_decay_minutes', 1),
|
||||
'join_token_ttl_hours' => 168,
|
||||
'share_link_ttl_hours' => (int) config('share-links.ttl_hours', 48),
|
||||
|
||||
25
app/Models/PhotoboothConnectCode.php
Normal file
25
app/Models/PhotoboothConnectCode.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class PhotoboothConnectCode extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\PhotoboothConnectCodeFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'expires_at' => 'datetime',
|
||||
'redeemed_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function event(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Event::class);
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,16 @@ class User extends Authenticatable implements FilamentHasTenants, FilamentUser,
|
||||
];
|
||||
}
|
||||
|
||||
public function isSuperAdmin(): bool
|
||||
{
|
||||
return self::isSuperAdminRole($this->role);
|
||||
}
|
||||
|
||||
public static function isSuperAdminRole(?string $role): bool
|
||||
{
|
||||
return in_array($role, ['super_admin', 'superadmin'], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the user by the given credentials.
|
||||
*/
|
||||
@@ -127,12 +137,12 @@ class User extends Authenticatable implements FilamentHasTenants, FilamentUser,
|
||||
|
||||
public function canAccessPanel(Panel $panel): bool
|
||||
{
|
||||
if (! $this->email_verified_at && $this->role !== 'super_admin') {
|
||||
if (! $this->email_verified_at && ! $this->isSuperAdmin()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return match ($panel->getId()) {
|
||||
'superadmin' => $this->role === 'super_admin',
|
||||
'superadmin' => $this->isSuperAdmin(),
|
||||
'admin' => $this->role === 'tenant_admin',
|
||||
default => false,
|
||||
};
|
||||
@@ -140,7 +150,7 @@ class User extends Authenticatable implements FilamentHasTenants, FilamentUser,
|
||||
|
||||
public function canAccessTenant(Model $tenant): bool
|
||||
{
|
||||
if ($this->role === 'super_admin') {
|
||||
if ($this->isSuperAdmin()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -155,7 +165,7 @@ class User extends Authenticatable implements FilamentHasTenants, FilamentUser,
|
||||
|
||||
public function getTenants(Panel $panel): array|Collection
|
||||
{
|
||||
if ($this->role === 'super_admin') {
|
||||
if ($this->isSuperAdmin()) {
|
||||
return Tenant::query()->orderBy('name')->get();
|
||||
}
|
||||
|
||||
|
||||
@@ -12,12 +12,11 @@ class PurchaseHistoryPolicy
|
||||
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return $user->role === 'super_admin';
|
||||
return $user->isSuperAdmin();
|
||||
}
|
||||
|
||||
public function view(User $user, PurchaseHistory $purchaseHistory): bool
|
||||
{
|
||||
return $user->role === 'super_admin';
|
||||
return $user->isSuperAdmin();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ class TenantPolicy
|
||||
*/
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return $user->role === 'super_admin';
|
||||
return $user->isSuperAdmin();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -35,7 +35,7 @@ class TenantPolicy
|
||||
*/
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return $user->role === 'super_admin';
|
||||
return $user->isSuperAdmin();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -43,7 +43,7 @@ class TenantPolicy
|
||||
*/
|
||||
public function update(User $user, Tenant $tenant): bool
|
||||
{
|
||||
return $user->role === 'super_admin';
|
||||
return $user->isSuperAdmin();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -51,7 +51,7 @@ class TenantPolicy
|
||||
*/
|
||||
public function delete(User $user, Tenant $tenant): bool
|
||||
{
|
||||
return $user->role === 'super_admin';
|
||||
return $user->isSuperAdmin();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,6 +59,6 @@ class TenantPolicy
|
||||
*/
|
||||
public function suspend(User $user, Tenant $tenant): bool
|
||||
{
|
||||
return $user->role === 'super_admin';
|
||||
return $user->isSuperAdmin();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,7 +155,15 @@ class AppServiceProvider extends ServiceProvider
|
||||
|
||||
$key = $tenantId ? 'tenant:'.$tenantId : ('ip:'.($request->ip() ?? 'unknown'));
|
||||
|
||||
return Limit::perMinute(100)->by($key);
|
||||
return Limit::perMinute(600)->by($key);
|
||||
});
|
||||
|
||||
RateLimiter::for('guest-api', function (Request $request) {
|
||||
return Limit::perMinute(300)->by('guest-api:'.($request->ip() ?? 'unknown'));
|
||||
});
|
||||
|
||||
RateLimiter::for('photobooth-connect', function (Request $request) {
|
||||
return Limit::perMinute(30)->by('photobooth-connect:'.($request->ip() ?? 'unknown'));
|
||||
});
|
||||
|
||||
RateLimiter::for('tenant-auth', function (Request $request) {
|
||||
|
||||
@@ -46,7 +46,7 @@ class AuthServiceProvider extends ServiceProvider
|
||||
});
|
||||
|
||||
Gate::before(function (User $user): ?bool {
|
||||
return $user->role === 'super_admin' ? true : null;
|
||||
return $user->isSuperAdmin() ? true : null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ class SuperAdminAuditLogger
|
||||
|
||||
private function shouldLog(?User $actor): bool
|
||||
{
|
||||
if (! $actor || $actor->role !== 'super_admin') {
|
||||
if (! $actor || ! $actor->isSuperAdmin()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -126,6 +126,36 @@ class GuestNotificationService
|
||||
return null;
|
||||
}
|
||||
|
||||
$photoId = Arr::get($payload, 'photo_id');
|
||||
if (is_numeric($photoId)) {
|
||||
$photoId = max(1, (int) $photoId);
|
||||
} else {
|
||||
$photoId = null;
|
||||
}
|
||||
|
||||
$photoIds = Arr::get($payload, 'photo_ids');
|
||||
if (is_array($photoIds)) {
|
||||
$photoIds = array_values(array_unique(array_filter(array_map(function ($value) {
|
||||
if (! is_numeric($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$int = (int) $value;
|
||||
|
||||
return $int > 0 ? $int : null;
|
||||
}, $photoIds))));
|
||||
$photoIds = array_slice($photoIds, 0, 10);
|
||||
} else {
|
||||
$photoIds = [];
|
||||
}
|
||||
|
||||
$count = Arr::get($payload, 'count');
|
||||
if (is_numeric($count)) {
|
||||
$count = max(1, min(9999, (int) $count));
|
||||
} else {
|
||||
$count = null;
|
||||
}
|
||||
|
||||
$cta = Arr::get($payload, 'cta');
|
||||
if (is_array($cta)) {
|
||||
$cta = [
|
||||
@@ -142,6 +172,9 @@ class GuestNotificationService
|
||||
|
||||
$clean = array_filter([
|
||||
'cta' => $cta,
|
||||
'photo_id' => $photoId,
|
||||
'photo_ids' => $photoIds,
|
||||
'count' => $count,
|
||||
]);
|
||||
|
||||
return $clean === [] ? null : $clean;
|
||||
|
||||
@@ -17,6 +17,11 @@ class PaddleDiscountService
|
||||
*/
|
||||
public function createDiscount(Coupon $coupon): array
|
||||
{
|
||||
$existing = $this->findExistingDiscount($coupon->code);
|
||||
if ($existing !== null) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
$payload = $this->buildDiscountPayload($coupon);
|
||||
|
||||
$response = $this->client->post('/discounts', $payload);
|
||||
@@ -82,6 +87,35 @@ class PaddleDiscountService
|
||||
return Arr::get($response, 'data', $response);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
protected function findExistingDiscount(?string $code): ?array
|
||||
{
|
||||
$normalized = Str::upper(trim((string) $code));
|
||||
if ($normalized === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$response = $this->client->get('/discounts', [
|
||||
'code' => $normalized,
|
||||
'per_page' => 1,
|
||||
]);
|
||||
|
||||
$items = Arr::get($response, 'data', []);
|
||||
if (! is_array($items) || $items === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$match = Collection::make($items)->first(static function ($item) use ($normalized) {
|
||||
$codeValue = Str::upper((string) Arr::get($item, 'code', ''));
|
||||
|
||||
return $codeValue === $normalized ? $item : null;
|
||||
});
|
||||
|
||||
return is_array($match) ? $match : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
|
||||
80
app/Services/Photobooth/PhotoboothConnectCodeService.php
Normal file
80
app/Services/Photobooth/PhotoboothConnectCodeService.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Photobooth;
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Models\PhotoboothConnectCode;
|
||||
|
||||
class PhotoboothConnectCodeService
|
||||
{
|
||||
public function create(Event $event, ?int $expiresInMinutes = null): array
|
||||
{
|
||||
$length = (int) config('photobooth.connect_code.length', 6);
|
||||
$length = max(4, min(8, $length));
|
||||
|
||||
$expiresInMinutes = $expiresInMinutes ?: (int) config('photobooth.connect_code.expires_minutes', 10);
|
||||
$expiresInMinutes = max(1, min(120, $expiresInMinutes));
|
||||
|
||||
$code = null;
|
||||
$hash = null;
|
||||
$max = (10 ** $length) - 1;
|
||||
|
||||
for ($attempts = 0; $attempts < 5; $attempts++) {
|
||||
$candidate = str_pad((string) random_int(0, $max), $length, '0', STR_PAD_LEFT);
|
||||
$candidateHash = hash('sha256', $candidate);
|
||||
|
||||
$exists = PhotoboothConnectCode::query()
|
||||
->where('code_hash', $candidateHash)
|
||||
->whereNull('redeemed_at')
|
||||
->where('expires_at', '>=', now())
|
||||
->exists();
|
||||
|
||||
if (! $exists) {
|
||||
$code = $candidate;
|
||||
$hash = $candidateHash;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $code || ! $hash) {
|
||||
$code = str_pad((string) random_int(0, $max), $length, '0', STR_PAD_LEFT);
|
||||
$hash = hash('sha256', $code);
|
||||
}
|
||||
|
||||
$expiresAt = now()->addMinutes($expiresInMinutes);
|
||||
|
||||
$record = PhotoboothConnectCode::query()->create([
|
||||
'event_id' => $event->getKey(),
|
||||
'code_hash' => $hash,
|
||||
'expires_at' => $expiresAt,
|
||||
]);
|
||||
|
||||
return [
|
||||
'code' => $code,
|
||||
'record' => $record,
|
||||
'expires_at' => $expiresAt,
|
||||
];
|
||||
}
|
||||
|
||||
public function redeem(string $code): ?PhotoboothConnectCode
|
||||
{
|
||||
$hash = hash('sha256', $code);
|
||||
|
||||
/** @var PhotoboothConnectCode|null $record */
|
||||
$record = PhotoboothConnectCode::query()
|
||||
->where('code_hash', $hash)
|
||||
->whereNull('redeemed_at')
|
||||
->where('expires_at', '>=', now())
|
||||
->first();
|
||||
|
||||
if (! $record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$record->forceFill([
|
||||
'redeemed_at' => now(),
|
||||
])->save();
|
||||
|
||||
return $record;
|
||||
}
|
||||
}
|
||||
@@ -24,15 +24,15 @@ class TenantAuth
|
||||
}
|
||||
|
||||
$user = $request->user();
|
||||
if ($user && in_array($user->role, ['tenant_admin', 'admin', 'super_admin', 'member'], true)) {
|
||||
if ($user->role !== 'super_admin' || (int) $user->tenant_id === (int) $tenantId) {
|
||||
if ($user && in_array($user->role, ['tenant_admin', 'admin', 'super_admin', 'superadmin', 'member'], true)) {
|
||||
if (! $user->isSuperAdmin() || (int) $user->tenant_id === (int) $tenantId) {
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
|
||||
$user = User::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereIn('role', ['tenant_admin', 'admin', 'member'])
|
||||
->whereIn('role', ['tenant_admin', 'admin', 'super_admin', 'superadmin', 'member'])
|
||||
->orderByDesc('email_verified_at')
|
||||
->orderBy('id')
|
||||
->first();
|
||||
|
||||
10
clients/photobooth-uploader/PhotoboothUploader/App.axaml
Normal file
10
clients/photobooth-uploader/PhotoboothUploader/App.axaml
Normal 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>
|
||||
23
clients/photobooth-uploader/PhotoboothUploader/App.axaml.cs
Normal file
23
clients/photobooth-uploader/PhotoboothUploader/App.axaml.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace PhotoboothUploader;
|
||||
|
||||
public partial class App : Application
|
||||
{
|
||||
public override void Initialize()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
{
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
desktop.MainWindow = new MainWindow();
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace PhotoboothUploader.Models;
|
||||
|
||||
public enum UploadStatus
|
||||
{
|
||||
Queued,
|
||||
Uploading,
|
||||
Success,
|
||||
Failed,
|
||||
}
|
||||
|
||||
public sealed class UploadItem : INotifyPropertyChanged
|
||||
{
|
||||
private UploadStatus _status;
|
||||
private DateTimeOffset _updatedAt;
|
||||
|
||||
public UploadItem(string path)
|
||||
{
|
||||
Path = path;
|
||||
FileName = System.IO.Path.GetFileName(path);
|
||||
UpdatedAt = DateTimeOffset.Now;
|
||||
Status = UploadStatus.Queued;
|
||||
}
|
||||
|
||||
public string Path { get; }
|
||||
|
||||
public string FileName { get; }
|
||||
|
||||
public UploadStatus Status
|
||||
{
|
||||
get => _status;
|
||||
set
|
||||
{
|
||||
if (_status != value)
|
||||
{
|
||||
_status = value;
|
||||
UpdatedAt = DateTimeOffset.Now;
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(StatusLabel));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public DateTimeOffset UpdatedAt
|
||||
{
|
||||
get => _updatedAt;
|
||||
private set
|
||||
{
|
||||
_updatedAt = value;
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(UpdatedLabel));
|
||||
}
|
||||
}
|
||||
|
||||
public string StatusLabel => Status switch
|
||||
{
|
||||
UploadStatus.Uploading => "Upload läuft",
|
||||
UploadStatus.Success => "Fertig",
|
||||
UploadStatus.Failed => "Fehlgeschlagen",
|
||||
_ => "Wartet",
|
||||
};
|
||||
|
||||
public string UpdatedLabel => $"{UpdatedAt:HH:mm}";
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
private void OnPropertyChanged([CallerMemberName] string? propertyName = null)
|
||||
{
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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>
|
||||
21
clients/photobooth-uploader/PhotoboothUploader/Program.cs
Normal file
21
clients/photobooth-uploader/PhotoboothUploader/Program.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
using Avalonia;
|
||||
using System;
|
||||
|
||||
namespace PhotoboothUploader;
|
||||
|
||||
class Program
|
||||
{
|
||||
// Initialization code. Don't use any Avalonia, third-party APIs or any
|
||||
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
|
||||
// yet and stuff might break.
|
||||
[STAThread]
|
||||
public static void Main(string[] args) => BuildAvaloniaApp()
|
||||
.StartWithClassicDesktopLifetime(args);
|
||||
|
||||
// Avalonia configuration, don't remove; also used by visual designer.
|
||||
public static AppBuilder BuildAvaloniaApp()
|
||||
=> AppBuilder.Configure<App>()
|
||||
.UsePlatformDetect()
|
||||
.WithInterFont()
|
||||
.LogToTrace();
|
||||
}
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
}
|
||||
18
clients/photobooth-uploader/PhotoboothUploader/app.manifest
Normal file
18
clients/photobooth-uploader/PhotoboothUploader/app.manifest
Normal file
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<!-- This manifest is used on Windows only.
|
||||
Don't remove it as it might cause problems with window transparency and embedded controls.
|
||||
For more details visit https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests -->
|
||||
<assemblyIdentity version="1.0.0.0" name="PhotoboothUploader.Desktop"/>
|
||||
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<!-- A list of the Windows versions that this application has been tested on
|
||||
and is designed to work with. Uncomment the appropriate elements
|
||||
and Windows will automatically select the most compatible environment. -->
|
||||
|
||||
<!-- Windows 10 -->
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||
</application>
|
||||
</compatibility>
|
||||
</assembly>
|
||||
@@ -4,9 +4,9 @@ return [
|
||||
'failure_limit' => (int) env('JOIN_TOKEN_FAILURE_LIMIT', 10),
|
||||
'failure_decay_minutes' => (int) env('JOIN_TOKEN_FAILURE_DECAY', 5),
|
||||
|
||||
'access_limit' => (int) env('JOIN_TOKEN_ACCESS_LIMIT', 120),
|
||||
'access_limit' => (int) env('JOIN_TOKEN_ACCESS_LIMIT', 300),
|
||||
'access_decay_minutes' => (int) env('JOIN_TOKEN_ACCESS_DECAY', 1),
|
||||
|
||||
'download_limit' => (int) env('JOIN_TOKEN_DOWNLOAD_LIMIT', 60),
|
||||
'download_limit' => (int) env('JOIN_TOKEN_DOWNLOAD_LIMIT', 120),
|
||||
'download_decay_minutes' => (int) env('JOIN_TOKEN_DOWNLOAD_DECAY', 1),
|
||||
];
|
||||
|
||||
@@ -34,4 +34,8 @@ return [
|
||||
'rate_limit_per_minute' => (int) env('SPARKBOOTH_RATE_LIMIT_PER_MINUTE', env('PHOTOBOOTH_RATE_LIMIT_PER_MINUTE', 20)),
|
||||
'response_format' => env('SPARKBOOTH_RESPONSE_FORMAT', 'json'),
|
||||
],
|
||||
'connect_code' => [
|
||||
'length' => (int) env('PHOTOBOOTH_CONNECT_CODE_LENGTH', 6),
|
||||
'expires_minutes' => (int) env('PHOTOBOOTH_CONNECT_CODE_EXPIRES_MINUTES', 10),
|
||||
],
|
||||
];
|
||||
|
||||
29
database/factories/PhotoboothConnectCodeFactory.php
Normal file
29
database/factories/PhotoboothConnectCodeFactory.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Event;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\PhotoboothConnectCode>
|
||||
*/
|
||||
class PhotoboothConnectCodeFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
$rawCode = str_pad((string) $this->faker->numberBetween(0, 999999), 6, '0', STR_PAD_LEFT);
|
||||
|
||||
return [
|
||||
'event_id' => Event::factory(),
|
||||
'code_hash' => hash('sha256', $rawCode),
|
||||
'expires_at' => now()->addMinutes(10),
|
||||
'redeemed_at' => null,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('photobooth_connect_codes', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('event_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('code_hash', 64)->unique();
|
||||
$table->timestamp('expires_at');
|
||||
$table->timestamp('redeemed_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('photobooth_connect_codes');
|
||||
}
|
||||
};
|
||||
16
database/seeders/PhotoboothConnectCodeSeeder.php
Normal file
16
database/seeders/PhotoboothConnectCodeSeeder.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class PhotoboothConnectCodeSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Run the database seeds.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class SuperAdminSeeder extends Seeder
|
||||
{
|
||||
@@ -12,12 +14,49 @@ class SuperAdminSeeder extends Seeder
|
||||
{
|
||||
$email = env('ADMIN_EMAIL', 'admin@example.com');
|
||||
$password = env('ADMIN_PASSWORD', 'ChangeMe123!');
|
||||
User::updateOrCreate(['email'=>$email], [
|
||||
$user = User::updateOrCreate(['email' => $email], [
|
||||
'first_name' => 'Super',
|
||||
'last_name' => 'Admin',
|
||||
'password' => Hash::make($password),
|
||||
'role' => 'super_admin',
|
||||
]);
|
||||
|
||||
$tenantSlug = env('OWNER_TENANT_SLUG', 'owner-tenant');
|
||||
$tenantName = env('OWNER_TENANT_NAME', 'Owner Tenant');
|
||||
|
||||
$tenant = Tenant::query()->firstOrCreate(
|
||||
['slug' => $tenantSlug],
|
||||
[
|
||||
'name' => $tenantName,
|
||||
'email' => $email,
|
||||
'contact_email' => $email,
|
||||
'user_id' => $user->id,
|
||||
'is_active' => true,
|
||||
'is_suspended' => false,
|
||||
'settings' => [
|
||||
'contact_email' => $email,
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
if (! $tenant->slug) {
|
||||
$tenant->forceFill(['slug' => Str::slug($tenantName)])->save();
|
||||
}
|
||||
|
||||
if (! $tenant->user_id) {
|
||||
$tenant->forceFill(['user_id' => $user->id])->save();
|
||||
}
|
||||
|
||||
if (! $tenant->email) {
|
||||
$tenant->forceFill(['email' => $email])->save();
|
||||
}
|
||||
|
||||
if (! $tenant->contact_email) {
|
||||
$tenant->forceFill(['contact_email' => $email])->save();
|
||||
}
|
||||
|
||||
if ($user->tenant_id !== $tenant->id) {
|
||||
$user->forceFill(['tenant_id' => $tenant->id])->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -2458,7 +2458,7 @@ export async function getTenantPaddleTransactions(cursor?: string): Promise<{
|
||||
export async function createTenantPaddleCheckout(
|
||||
packageId: number,
|
||||
urls?: { success_url?: string; return_url?: string }
|
||||
): Promise<{ checkout_url: string; id: string; expires_at?: string }> {
|
||||
): Promise<{ checkout_url: string; id: string; expires_at?: string; checkout_session_id?: string }> {
|
||||
const response = await authorizedFetch('/api/v1/tenant/packages/paddle-checkout', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -2468,12 +2468,22 @@ export async function createTenantPaddleCheckout(
|
||||
return_url: urls?.return_url,
|
||||
}),
|
||||
});
|
||||
return await jsonOrThrow<{ checkout_url: string; id: string; expires_at?: string }>(
|
||||
return await jsonOrThrow<{ checkout_url: string; id: string; expires_at?: string; checkout_session_id?: string }>(
|
||||
response,
|
||||
'Failed to create checkout'
|
||||
);
|
||||
}
|
||||
|
||||
export async function getTenantPackageCheckoutStatus(
|
||||
checkoutSessionId: string,
|
||||
): Promise<{ status: string; completed_at?: string | null; reason?: string | null; checkout_url?: string | null }> {
|
||||
const response = await authorizedFetch(`/api/v1/tenant/packages/checkout-session/${checkoutSessionId}/status`);
|
||||
return await jsonOrThrow<{ status: string; completed_at?: string | null; reason?: string | null; checkout_url?: string | null }>(
|
||||
response,
|
||||
'Failed to load checkout status'
|
||||
);
|
||||
}
|
||||
|
||||
export async function createTenantBillingPortalSession(): Promise<{ url: string }> {
|
||||
const response = await authorizedFetch('/api/v1/tenant/billing/portal', {
|
||||
method: 'POST',
|
||||
|
||||
@@ -34,6 +34,27 @@
|
||||
"more": "Weitere Einträge konnten nicht geladen werden.",
|
||||
"portal": "Paddle-Portal konnte nicht geöffnet werden."
|
||||
},
|
||||
"checkoutSuccess": "Checkout abgeschlossen. Dein Paket wird in Kürze aktiviert.",
|
||||
"checkoutCancelled": "Checkout wurde abgebrochen.",
|
||||
"checkoutActivated": "Dein Paket ist jetzt aktiv.",
|
||||
"checkoutPendingTitle": "Paket wird aktiviert",
|
||||
"checkoutPendingBody": "Das kann ein paar Minuten dauern. Wir aktualisieren den Status, sobald das Paket aktiv ist.",
|
||||
"checkoutPendingBadge": "Ausstehend",
|
||||
"checkoutPendingRefresh": "Aktualisieren",
|
||||
"checkoutPendingDismiss": "Ausblenden",
|
||||
"checkoutFailedTitle": "Checkout fehlgeschlagen",
|
||||
"checkoutFailedBody": "Die Zahlung wurde nicht abgeschlossen. Du kannst es erneut versuchen oder den Support kontaktieren.",
|
||||
"checkoutFailedBadge": "Fehlgeschlagen",
|
||||
"checkoutFailedRetry": "Erneut versuchen",
|
||||
"checkoutFailedDismiss": "Ausblenden",
|
||||
"checkoutActionTitle": "Aktion erforderlich",
|
||||
"checkoutActionBody": "Schließe die Zahlung ab, um das Paket zu aktivieren.",
|
||||
"checkoutActionBadge": "Aktion nötig",
|
||||
"checkoutActionButton": "Checkout fortsetzen",
|
||||
"checkoutFailureReasons": {
|
||||
"paddle_failed": "Die Zahlung wurde abgelehnt.",
|
||||
"paddle_cancelled": "Der Checkout wurde abgebrochen."
|
||||
},
|
||||
"sections": {
|
||||
"invoices": {
|
||||
"title": "Rechnungen & Zahlungen",
|
||||
@@ -176,6 +197,8 @@
|
||||
},
|
||||
"common": {
|
||||
"all": "Alle",
|
||||
"anonymous": "Anonym",
|
||||
"error": "Etwas ist schiefgelaufen",
|
||||
"loadMore": "Mehr laden",
|
||||
"processing": "Verarbeite …",
|
||||
"select": "Auswählen",
|
||||
@@ -2875,16 +2898,25 @@
|
||||
"analytics": {
|
||||
"title": "Analytics",
|
||||
"upgradeAction": "Upgrade auf Premium",
|
||||
"kpiTitle": "Event-Überblick",
|
||||
"kpiUploads": "Uploads",
|
||||
"kpiContributors": "Beitragende",
|
||||
"kpiLikes": "Likes",
|
||||
"activityTitle": "Aktivitäts-Zeitachse",
|
||||
"timeframe": "Letzte {{hours}} Stunden",
|
||||
"timeframeHint": "Ältere Aktivität ausgeblendet",
|
||||
"uploadsPerHour": "Uploads pro Stunde",
|
||||
"noActivity": "Noch keine Uploads",
|
||||
"emptyActionShareQr": "QR-Code teilen",
|
||||
"contributorsTitle": "Top-Beitragende",
|
||||
"likesCount": "{{count}} Likes",
|
||||
"likesCount_one": "{{count}} Like",
|
||||
"likesCount_other": "{{count}} Likes",
|
||||
"noContributors": "Noch keine Beitragenden",
|
||||
"emptyActionInvite": "Gäste einladen",
|
||||
"tasksTitle": "Beliebte Aufgaben",
|
||||
"noTasks": "Noch keine Aufgabenaktivität",
|
||||
"emptyActionOpenTasks": "Aufgaben öffnen",
|
||||
"lockedTitle": "Analytics freischalten",
|
||||
"lockedBody": "Erhalte tiefe Einblicke in die Interaktionen deines Events mit dem Premium-Paket."
|
||||
},
|
||||
@@ -2893,6 +2925,26 @@
|
||||
"subtitle": "Wähle ein Paket, um mehr Funktionen und Limits freizuschalten.",
|
||||
"recommendationTitle": "Empfohlen für dich",
|
||||
"recommendationBody": "Das hervorgehobene Paket enthält das gewünschte Feature.",
|
||||
"compare": {
|
||||
"title": "Pakete vergleichen",
|
||||
"helper": "Wische, um Pakete nebeneinander zu vergleichen.",
|
||||
"toggleCards": "Karten",
|
||||
"toggleCompare": "Vergleichen",
|
||||
"headers": {
|
||||
"plan": "Paket",
|
||||
"price": "Preis"
|
||||
},
|
||||
"rows": {
|
||||
"photos": "Fotos",
|
||||
"guests": "Gäste",
|
||||
"days": "Galerietage"
|
||||
},
|
||||
"values": {
|
||||
"included": "Enthalten",
|
||||
"notIncluded": "Nicht enthalten",
|
||||
"unlimited": "Unbegrenzt"
|
||||
}
|
||||
},
|
||||
"select": "Auswählen",
|
||||
"manage": "Paket verwalten",
|
||||
"limits": {
|
||||
@@ -2906,7 +2958,13 @@
|
||||
},
|
||||
"features": {
|
||||
"advanced_analytics": "Erweiterte Analytics",
|
||||
"basic_uploads": "Basis-Uploads",
|
||||
"custom_branding": "Eigenes Branding",
|
||||
"custom_tasks": "Benutzerdefinierte Aufgaben",
|
||||
"limited_sharing": "Begrenztes Teilen",
|
||||
"live_slideshow": "Live-Slideshow",
|
||||
"priority_support": "Priorisierter Support",
|
||||
"unlimited_sharing": "Unbegrenztes Teilen",
|
||||
"watermark_removal": "Kein Wasserzeichen"
|
||||
},
|
||||
"status": {
|
||||
@@ -2918,7 +2976,9 @@
|
||||
},
|
||||
"badges": {
|
||||
"recommended": "Empfohlen",
|
||||
"active": "Aktiv"
|
||||
"active": "Aktiv",
|
||||
"upgrade": "Upgrade",
|
||||
"downgrade": "Downgrade"
|
||||
},
|
||||
"confirmTitle": "Kauf bestätigen",
|
||||
"confirmSubtitle": "Du upgradest auf:",
|
||||
@@ -2931,6 +2991,7 @@
|
||||
"payNow": "Jetzt zahlen",
|
||||
"errors": {
|
||||
"checkout": "Checkout fehlgeschlagen"
|
||||
}
|
||||
},
|
||||
"selectDisabled": "Nicht verfügbar"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,27 @@
|
||||
"more": "Unable to load more entries.",
|
||||
"portal": "Unable to open the Paddle portal."
|
||||
},
|
||||
"checkoutSuccess": "Checkout completed. Your package will activate shortly.",
|
||||
"checkoutCancelled": "Checkout was cancelled.",
|
||||
"checkoutActivated": "Your package is now active.",
|
||||
"checkoutPendingTitle": "Activating your package",
|
||||
"checkoutPendingBody": "This can take a few minutes. We will update this screen once the package is active.",
|
||||
"checkoutPendingBadge": "Pending",
|
||||
"checkoutPendingRefresh": "Refresh",
|
||||
"checkoutPendingDismiss": "Dismiss",
|
||||
"checkoutFailedTitle": "Checkout failed",
|
||||
"checkoutFailedBody": "The payment did not complete. You can try again or contact support.",
|
||||
"checkoutFailedBadge": "Failed",
|
||||
"checkoutFailedRetry": "Try again",
|
||||
"checkoutFailedDismiss": "Dismiss",
|
||||
"checkoutActionTitle": "Action required",
|
||||
"checkoutActionBody": "Complete your payment to activate the package.",
|
||||
"checkoutActionBadge": "Action needed",
|
||||
"checkoutActionButton": "Continue checkout",
|
||||
"checkoutFailureReasons": {
|
||||
"paddle_failed": "The payment was declined.",
|
||||
"paddle_cancelled": "The checkout was cancelled."
|
||||
},
|
||||
"sections": {
|
||||
"invoices": {
|
||||
"title": "Invoices & payments",
|
||||
@@ -172,6 +193,8 @@
|
||||
},
|
||||
"common": {
|
||||
"all": "All",
|
||||
"anonymous": "Anonymous",
|
||||
"error": "Something went wrong",
|
||||
"loadMore": "Load more",
|
||||
"processing": "Processing…",
|
||||
"select": "Select",
|
||||
@@ -2879,16 +2902,25 @@
|
||||
"analytics": {
|
||||
"title": "Analytics",
|
||||
"upgradeAction": "Upgrade to Premium",
|
||||
"kpiTitle": "Event snapshot",
|
||||
"kpiUploads": "Uploads",
|
||||
"kpiContributors": "Contributors",
|
||||
"kpiLikes": "Likes",
|
||||
"activityTitle": "Activity Timeline",
|
||||
"timeframe": "Last {{hours}} hours",
|
||||
"timeframeHint": "Older activity hidden",
|
||||
"uploadsPerHour": "Uploads per hour",
|
||||
"noActivity": "No uploads yet",
|
||||
"emptyActionShareQr": "Share your QR code",
|
||||
"contributorsTitle": "Top Contributors",
|
||||
"likesCount": "{{count}} likes",
|
||||
"likesCount_one": "{{count}} like",
|
||||
"likesCount_other": "{{count}} likes",
|
||||
"noContributors": "No contributors yet",
|
||||
"emptyActionInvite": "Invite guests",
|
||||
"tasksTitle": "Popular Tasks",
|
||||
"noTasks": "No task activity yet",
|
||||
"emptyActionOpenTasks": "Open tasks",
|
||||
"lockedTitle": "Unlock Analytics",
|
||||
"lockedBody": "Get deep insights into your event engagement with the Premium package."
|
||||
},
|
||||
@@ -2897,6 +2929,26 @@
|
||||
"subtitle": "Choose a package to unlock more features and limits.",
|
||||
"recommendationTitle": "Recommended for you",
|
||||
"recommendationBody": "The highlighted package includes the feature you requested.",
|
||||
"compare": {
|
||||
"title": "Compare plans",
|
||||
"helper": "Swipe to compare packages side by side.",
|
||||
"toggleCards": "Cards",
|
||||
"toggleCompare": "Compare",
|
||||
"headers": {
|
||||
"plan": "Plan",
|
||||
"price": "Price"
|
||||
},
|
||||
"rows": {
|
||||
"photos": "Photos",
|
||||
"guests": "Guests",
|
||||
"days": "Gallery days"
|
||||
},
|
||||
"values": {
|
||||
"included": "Included",
|
||||
"notIncluded": "Not included",
|
||||
"unlimited": "Unlimited"
|
||||
}
|
||||
},
|
||||
"select": "Select",
|
||||
"manage": "Manage Plan",
|
||||
"limits": {
|
||||
@@ -2910,7 +2962,13 @@
|
||||
},
|
||||
"features": {
|
||||
"advanced_analytics": "Advanced Analytics",
|
||||
"basic_uploads": "Basic uploads",
|
||||
"custom_branding": "Custom Branding",
|
||||
"custom_tasks": "Custom tasks",
|
||||
"limited_sharing": "Limited sharing",
|
||||
"live_slideshow": "Live slideshow",
|
||||
"priority_support": "Priority support",
|
||||
"unlimited_sharing": "Unlimited sharing",
|
||||
"watermark_removal": "No Watermark"
|
||||
},
|
||||
"status": {
|
||||
@@ -2922,7 +2980,9 @@
|
||||
},
|
||||
"badges": {
|
||||
"recommended": "Recommended",
|
||||
"active": "Active"
|
||||
"active": "Active",
|
||||
"upgrade": "Upgrade",
|
||||
"downgrade": "Downgrade"
|
||||
},
|
||||
"confirmTitle": "Confirm Purchase",
|
||||
"confirmSubtitle": "You are upgrading to:",
|
||||
@@ -2935,6 +2995,7 @@
|
||||
"payNow": "Pay Now",
|
||||
"errors": {
|
||||
"checkout": "Checkout failed"
|
||||
}
|
||||
},
|
||||
"selectDisabled": "Not available"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
createTenantBillingPortalSession,
|
||||
getTenantPackagesOverview,
|
||||
getTenantPaddleTransactions,
|
||||
getTenantPackageCheckoutStatus,
|
||||
TenantPackageSummary,
|
||||
PaddleTransactionSummary,
|
||||
} from '../api';
|
||||
@@ -27,6 +28,14 @@ import {
|
||||
getPackageFeatureLabel,
|
||||
getPackageLimitEntries,
|
||||
} from './lib/packageSummary';
|
||||
import {
|
||||
PendingCheckout,
|
||||
loadPendingCheckout,
|
||||
shouldClearPendingCheckout,
|
||||
storePendingCheckout,
|
||||
} from './lib/billingCheckout';
|
||||
|
||||
const CHECKOUT_POLL_INTERVAL_MS = 10000;
|
||||
|
||||
export default function MobileBillingPage() {
|
||||
const { t } = useTranslation('management');
|
||||
@@ -40,6 +49,11 @@ export default function MobileBillingPage() {
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [portalBusy, setPortalBusy] = React.useState(false);
|
||||
const [pendingCheckout, setPendingCheckout] = React.useState<PendingCheckout | null>(() => loadPendingCheckout());
|
||||
const [checkoutStatus, setCheckoutStatus] = React.useState<string | null>(null);
|
||||
const [checkoutStatusReason, setCheckoutStatusReason] = React.useState<string | null>(null);
|
||||
const [checkoutActionUrl, setCheckoutActionUrl] = React.useState<string | null>(null);
|
||||
const lastCheckoutStatusRef = React.useRef<string | null>(null);
|
||||
const packagesRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const invoicesRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const supportEmail = 'support@fotospiel.de';
|
||||
@@ -95,6 +109,11 @@ export default function MobileBillingPage() {
|
||||
}
|
||||
}, [portalBusy, t]);
|
||||
|
||||
const persistPendingCheckout = React.useCallback((next: PendingCheckout | null) => {
|
||||
setPendingCheckout(next);
|
||||
storePendingCheckout(next);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
@@ -108,6 +127,115 @@ export default function MobileBillingPage() {
|
||||
}
|
||||
}, [location.hash, loading]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!location.search) {
|
||||
return;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
const checkout = params.get('checkout');
|
||||
const packageId = params.get('package_id');
|
||||
if (!checkout) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (checkout === 'success') {
|
||||
const packageIdNumber = packageId ? Number(packageId) : null;
|
||||
const existingSessionId = pendingCheckout?.checkoutSessionId ?? null;
|
||||
const pendingEntry = {
|
||||
packageId: Number.isFinite(packageIdNumber) ? packageIdNumber : null,
|
||||
checkoutSessionId: existingSessionId,
|
||||
startedAt: Date.now(),
|
||||
};
|
||||
persistPendingCheckout(pendingEntry);
|
||||
toast.success(t('billing.checkoutSuccess', 'Checkout completed. Your package will activate shortly.'));
|
||||
} else if (checkout === 'cancel') {
|
||||
persistPendingCheckout(null);
|
||||
toast(t('billing.checkoutCancelled', 'Checkout was cancelled.'));
|
||||
}
|
||||
|
||||
params.delete('checkout');
|
||||
params.delete('package_id');
|
||||
navigate(
|
||||
{
|
||||
pathname: location.pathname,
|
||||
search: params.toString(),
|
||||
hash: location.hash,
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}, [location.hash, location.pathname, location.search, navigate, pendingCheckout?.checkoutSessionId, persistPendingCheckout, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!pendingCheckout) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldClearPendingCheckout(pendingCheckout, activePackage?.package_id ?? null)) {
|
||||
persistPendingCheckout(null);
|
||||
}
|
||||
}, [activePackage?.package_id, pendingCheckout, persistPendingCheckout]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!pendingCheckout?.checkoutSessionId) {
|
||||
setCheckoutStatus(null);
|
||||
setCheckoutStatusReason(null);
|
||||
setCheckoutActionUrl(null);
|
||||
lastCheckoutStatusRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
let active = true;
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const result = await getTenantPackageCheckoutStatus(pendingCheckout.checkoutSessionId as string);
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
setCheckoutStatus(result.status);
|
||||
setCheckoutStatusReason(result.reason ?? null);
|
||||
setCheckoutActionUrl(typeof result.checkout_url === 'string' ? result.checkout_url : null);
|
||||
|
||||
const lastStatus = lastCheckoutStatusRef.current;
|
||||
lastCheckoutStatusRef.current = result.status;
|
||||
|
||||
if (result.status === 'completed') {
|
||||
persistPendingCheckout(null);
|
||||
if (lastStatus !== 'completed') {
|
||||
toast.success(t('billing.checkoutActivated', 'Your package is now active.'));
|
||||
}
|
||||
await load();
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.status === 'failed' || result.status === 'cancelled') {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void poll();
|
||||
intervalId = setInterval(poll, CHECKOUT_POLL_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
};
|
||||
}, [load, pendingCheckout?.checkoutSessionId, persistPendingCheckout, t]);
|
||||
|
||||
return (
|
||||
<MobileShell
|
||||
activeTab="profile"
|
||||
@@ -127,6 +255,109 @@ export default function MobileBillingPage() {
|
||||
<CTAButton label={t('billing.actions.refresh', 'Refresh')} tone="ghost" onPress={load} />
|
||||
</MobileCard>
|
||||
) : null}
|
||||
{pendingCheckout && (checkoutStatus === 'failed' || checkoutStatus === 'cancelled') ? (
|
||||
<MobileCard borderColor={danger} backgroundColor="$red1" space="$2">
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
<YStack space="$0.5" flex={1}>
|
||||
<Text fontSize="$sm" fontWeight="800" color={danger}>
|
||||
{t('billing.checkoutFailedTitle', 'Checkout failed')}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t(
|
||||
'billing.checkoutFailedBody',
|
||||
'The payment did not complete. You can try again or contact support.'
|
||||
)}
|
||||
</Text>
|
||||
{checkoutStatusReason ? (
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t(`billing.checkoutFailureReasons.${checkoutStatusReason}`, checkoutStatusReason)}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
<PillBadge tone="danger">
|
||||
{t('billing.checkoutFailedBadge', 'Failed')}
|
||||
</PillBadge>
|
||||
</XStack>
|
||||
<XStack space="$2">
|
||||
<CTAButton
|
||||
label={t('billing.checkoutFailedRetry', 'Try again')}
|
||||
onPress={() => navigate(adminPath('/mobile/billing/shop'))}
|
||||
fullWidth={false}
|
||||
/>
|
||||
<CTAButton
|
||||
label={t('billing.checkoutFailedDismiss', 'Dismiss')}
|
||||
tone="ghost"
|
||||
onPress={() => persistPendingCheckout(null)}
|
||||
fullWidth={false}
|
||||
/>
|
||||
</XStack>
|
||||
</MobileCard>
|
||||
) : null}
|
||||
{pendingCheckout && checkoutStatus === 'requires_customer_action' ? (
|
||||
<MobileCard borderColor={accentSoft} backgroundColor={accentSoft} space="$2">
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
<YStack space="$0.5" flex={1}>
|
||||
<Text fontSize="$sm" fontWeight="800" color={textStrong}>
|
||||
{t('billing.checkoutActionTitle', 'Action required')}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('billing.checkoutActionBody', 'Complete your payment to activate the package.')}
|
||||
</Text>
|
||||
</YStack>
|
||||
<PillBadge tone="warning">
|
||||
{t('billing.checkoutActionBadge', 'Action needed')}
|
||||
</PillBadge>
|
||||
</XStack>
|
||||
<XStack space="$2">
|
||||
<CTAButton
|
||||
label={t('billing.checkoutActionButton', 'Continue checkout')}
|
||||
onPress={() => {
|
||||
if (checkoutActionUrl && typeof window !== 'undefined') {
|
||||
window.open(checkoutActionUrl, '_blank', 'noopener');
|
||||
return;
|
||||
}
|
||||
navigate(adminPath('/mobile/billing/shop'));
|
||||
}}
|
||||
fullWidth={false}
|
||||
/>
|
||||
<CTAButton
|
||||
label={t('billing.checkoutFailedDismiss', 'Dismiss')}
|
||||
tone="ghost"
|
||||
onPress={() => persistPendingCheckout(null)}
|
||||
fullWidth={false}
|
||||
/>
|
||||
</XStack>
|
||||
</MobileCard>
|
||||
) : null}
|
||||
{pendingCheckout && checkoutStatus !== 'failed' && checkoutStatus !== 'cancelled' && checkoutStatus !== 'requires_customer_action' ? (
|
||||
<MobileCard borderColor={accentSoft} backgroundColor={accentSoft} space="$2">
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
<YStack space="$0.5" flex={1}>
|
||||
<Text fontSize="$sm" fontWeight="800" color={textStrong}>
|
||||
{t('billing.checkoutPendingTitle', 'Activating your package')}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t(
|
||||
'billing.checkoutPendingBody',
|
||||
'This can take a few minutes. We will update this screen once the package is active.'
|
||||
)}
|
||||
</Text>
|
||||
</YStack>
|
||||
<PillBadge tone="warning">
|
||||
{t('billing.checkoutPendingBadge', 'Pending')}
|
||||
</PillBadge>
|
||||
</XStack>
|
||||
<XStack space="$2">
|
||||
<CTAButton label={t('billing.checkoutPendingRefresh', 'Refresh')} onPress={load} fullWidth={false} />
|
||||
<CTAButton
|
||||
label={t('billing.checkoutPendingDismiss', 'Dismiss')}
|
||||
tone="ghost"
|
||||
onPress={() => persistPendingCheckout(null)}
|
||||
fullWidth={false}
|
||||
/>
|
||||
</XStack>
|
||||
</MobileCard>
|
||||
) : null}
|
||||
|
||||
<MobileCard space="$2" ref={packagesRef as any}>
|
||||
<XStack alignItems="center" space="$2">
|
||||
@@ -535,4 +766,4 @@ function formatDate(value: string | null | undefined): string {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '—';
|
||||
return date.toLocaleDateString(undefined, { day: '2-digit', month: 'short', year: 'numeric' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import React from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Bell, CheckCircle2, Download, Image as ImageIcon, ListTodo, MessageCircle, QrCode, Settings, ShieldCheck, Smartphone, Users, Sparkles, TrendingUp } from 'lucide-react';
|
||||
import { Bell, CalendarDays, Camera, CheckCircle2, ChevronDown, Download, Image as ImageIcon, Layout, ListTodo, MapPin, Megaphone, MessageCircle, Pencil, QrCode, Settings, ShieldCheck, Smartphone, Sparkles, TrendingUp, Tv, Users } from 'lucide-react';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||
import { MobileShell, renderEventLocation } from './components/MobileShell';
|
||||
import { MobileShell } from './components/MobileShell';
|
||||
import { MobileCard, CTAButton, KpiTile, ActionTile, PillBadge, SkeletonCard } from './components/Primitives';
|
||||
import { MobileSheet } from './components/Sheet';
|
||||
import { adminPath, ADMIN_WELCOME_BASE_PATH } from '../constants';
|
||||
@@ -21,6 +21,7 @@ import { collectPackageFeatures, formatPackageLimit, getPackageFeatureLabel, get
|
||||
import { trackOnboarding } from '../api';
|
||||
import { useAuth } from '../auth/context';
|
||||
import { ADMIN_ACTION_COLORS, ADMIN_MOTION, useAdminTheme } from './theme';
|
||||
import { isPastEvent } from './eventDate';
|
||||
|
||||
type DeviceSetupProps = {
|
||||
installPrompt: ReturnType<typeof useInstallPrompt>;
|
||||
@@ -32,6 +33,7 @@ type DeviceSetupProps = {
|
||||
export default function MobileDashboardPage() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { slug: slugParam } = useParams<{ slug?: string }>();
|
||||
const { t, i18n } = useTranslation('management');
|
||||
const { events, activeEvent, hasEvents, hasMultipleEvents, isLoading, selectEvent } = useEventContext();
|
||||
const { status } = useAuth();
|
||||
@@ -42,11 +44,12 @@ export default function MobileDashboardPage() {
|
||||
const [tourStep, setTourStep] = React.useState(0);
|
||||
const [summaryOpen, setSummaryOpen] = React.useState(false);
|
||||
const [summarySeenOverride, setSummarySeenOverride] = React.useState<number | null>(null);
|
||||
const [eventSwitcherOpen, setEventSwitcherOpen] = React.useState(false);
|
||||
const onboardingTrackedRef = React.useRef(false);
|
||||
const installPrompt = useInstallPrompt();
|
||||
const pushState = useAdminPushSubscription();
|
||||
const devicePermissions = useDevicePermissions();
|
||||
const { textStrong, muted, border, surface, accentSoft, primary } = useAdminTheme();
|
||||
const { textStrong, muted, accentSoft, primary } = useAdminTheme();
|
||||
const text = textStrong;
|
||||
const accentText = primary;
|
||||
|
||||
@@ -84,6 +87,14 @@ export default function MobileDashboardPage() {
|
||||
const tourTargetSlug = activeEvent?.slug ?? effectiveEvents[0]?.slug ?? null;
|
||||
const tourStepKeys = React.useMemo(() => resolveTourStepKeys(effectiveHasEvents), [effectiveHasEvents]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!slugParam || slugParam === activeEvent?.slug) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectEvent(slugParam);
|
||||
}, [activeEvent?.slug, selectEvent, slugParam]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (status !== 'authenticated' || onboardingTrackedRef.current) {
|
||||
return;
|
||||
@@ -424,7 +435,7 @@ export default function MobileDashboardPage() {
|
||||
onOpen={() => setSummaryOpen(true)}
|
||||
/>
|
||||
) : null}
|
||||
<EventPickerList events={effectiveEvents} locale={locale} text={text} muted={muted} border={border} />
|
||||
<EventPickerList events={effectiveEvents} locale={locale} navigateOnSelect={false} />
|
||||
{tourSheet}
|
||||
{packageSummarySheet}
|
||||
</MobileShell>
|
||||
@@ -434,8 +445,7 @@ export default function MobileDashboardPage() {
|
||||
return (
|
||||
<MobileShell
|
||||
activeTab="home"
|
||||
title={resolveEventDisplayName(activeEvent ?? undefined)}
|
||||
subtitle={formatEventDate(activeEvent?.event_date, locale) ?? undefined}
|
||||
title={t('mobileDashboard.title', 'Dashboard')}
|
||||
>
|
||||
{showPackageSummaryBanner ? (
|
||||
<PackageSummaryBanner
|
||||
@@ -443,28 +453,18 @@ export default function MobileDashboardPage() {
|
||||
onOpen={() => setSummaryOpen(true)}
|
||||
/>
|
||||
) : null}
|
||||
<DeviceSetupCard
|
||||
installPrompt={installPrompt}
|
||||
pushState={pushState}
|
||||
devicePermissions={devicePermissions}
|
||||
onOpenSettings={() => navigate(adminPath('/mobile/settings'))}
|
||||
/>
|
||||
<FeaturedActions
|
||||
tasksEnabled={tasksEnabled}
|
||||
onReviewPhotos={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/photos`))}
|
||||
onManageTasks={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/tasks`))}
|
||||
onShowQr={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/qr`))}
|
||||
/>
|
||||
|
||||
<SecondaryGrid
|
||||
<EventHeaderCard
|
||||
event={activeEvent}
|
||||
onGuests={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/members`))}
|
||||
onPrint={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/qr`))}
|
||||
onInvites={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/members`))}
|
||||
onSettings={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}`))}
|
||||
onAnalytics={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/analytics`))}
|
||||
locale={locale}
|
||||
canSwitch={effectiveMultiple}
|
||||
onSwitch={() => setEventSwitcherOpen(true)}
|
||||
onEdit={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/edit`))}
|
||||
/>
|
||||
<EventManagementGrid
|
||||
event={activeEvent}
|
||||
tasksEnabled={tasksEnabled}
|
||||
onNavigate={(path) => navigate(path)}
|
||||
/>
|
||||
|
||||
<KpiStrip
|
||||
event={activeEvent}
|
||||
stats={stats}
|
||||
@@ -474,8 +474,20 @@ export default function MobileDashboardPage() {
|
||||
/>
|
||||
|
||||
<AlertsAndHints event={activeEvent} stats={stats} tasksEnabled={tasksEnabled} />
|
||||
<DeviceSetupCard
|
||||
installPrompt={installPrompt}
|
||||
pushState={pushState}
|
||||
devicePermissions={devicePermissions}
|
||||
onOpenSettings={() => navigate(adminPath('/mobile/settings'))}
|
||||
/>
|
||||
{tourSheet}
|
||||
{packageSummarySheet}
|
||||
<EventSwitcherSheet
|
||||
open={eventSwitcherOpen}
|
||||
onClose={() => setEventSwitcherOpen(false)}
|
||||
events={effectiveEvents}
|
||||
locale={locale}
|
||||
/>
|
||||
</MobileShell>
|
||||
);
|
||||
}
|
||||
@@ -976,8 +988,20 @@ function OnboardingEmptyState({ installPrompt, pushState, devicePermissions, onO
|
||||
);
|
||||
}
|
||||
|
||||
function EventPickerList({ events, locale, text, muted, border }: { events: TenantEvent[]; locale: string; text: string; muted: string; border: string }) {
|
||||
function EventPickerList({
|
||||
events,
|
||||
locale,
|
||||
onPick,
|
||||
navigateOnSelect = true,
|
||||
}: {
|
||||
events: TenantEvent[];
|
||||
locale: string;
|
||||
onPick?: (event: TenantEvent) => void;
|
||||
navigateOnSelect?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation('management');
|
||||
const { textStrong, muted, border } = useAdminTheme();
|
||||
const text = textStrong;
|
||||
const { selectEvent } = useEventContext();
|
||||
const navigate = useNavigate();
|
||||
const [localEvents, setLocalEvents] = React.useState<TenantEvent[]>(events);
|
||||
@@ -1008,7 +1032,8 @@ function EventPickerList({ events, locale, text, muted, border }: { events: Tena
|
||||
key={event.slug}
|
||||
onPress={() => {
|
||||
selectEvent(event.slug ?? null);
|
||||
if (event.slug) {
|
||||
onPick?.(event);
|
||||
if (navigateOnSelect && event.slug) {
|
||||
navigate(adminPath(`/mobile/events/${event.slug}`));
|
||||
}
|
||||
}}
|
||||
@@ -1036,140 +1061,232 @@ function EventPickerList({ events, locale, text, muted, border }: { events: Tena
|
||||
);
|
||||
}
|
||||
|
||||
function FeaturedActions({
|
||||
tasksEnabled,
|
||||
onReviewPhotos,
|
||||
onManageTasks,
|
||||
onShowQr,
|
||||
function EventSwitcherSheet({
|
||||
open,
|
||||
onClose,
|
||||
events,
|
||||
locale,
|
||||
}: {
|
||||
tasksEnabled: boolean;
|
||||
onReviewPhotos: () => void;
|
||||
onManageTasks: () => void;
|
||||
onShowQr: () => void;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
events: TenantEvent[];
|
||||
locale: string;
|
||||
}) {
|
||||
const { t } = useTranslation('management');
|
||||
const { textStrong, muted, subtle } = useAdminTheme();
|
||||
const text = textStrong;
|
||||
const cards = [
|
||||
{
|
||||
key: 'photos',
|
||||
label: t('mobileDashboard.photosLabel', 'Review photos'),
|
||||
desc: t('mobileDashboard.photosDesc', 'Moderate uploads and highlights'),
|
||||
icon: ImageIcon,
|
||||
color: ADMIN_ACTION_COLORS.images,
|
||||
action: onReviewPhotos,
|
||||
},
|
||||
{
|
||||
key: 'tasks',
|
||||
label: t('mobileDashboard.tasksLabel', 'Manage tasks & challenges'),
|
||||
desc: tasksEnabled
|
||||
? t('mobileDashboard.tasksDesc', 'Assign and track progress')
|
||||
: t('mobileDashboard.tasksDisabledDesc', 'Guests do not see tasks (task mode off)'),
|
||||
icon: ListTodo,
|
||||
color: ADMIN_ACTION_COLORS.tasks,
|
||||
action: onManageTasks,
|
||||
},
|
||||
{
|
||||
key: 'qr',
|
||||
label: t('mobileDashboard.qrLabel', 'Show / share QR code'),
|
||||
desc: t('mobileDashboard.qrDesc', 'Posters, cards, and links'),
|
||||
icon: QrCode,
|
||||
color: ADMIN_ACTION_COLORS.qr,
|
||||
action: onShowQr,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<YStack space="$2">
|
||||
{cards.map((card) => (
|
||||
<Pressable key={card.key} onPress={card.action}>
|
||||
<MobileCard borderColor={`${card.color}44`} backgroundColor={`${card.color}0f`} space="$2.5">
|
||||
<XStack alignItems="center" space="$3">
|
||||
<XStack width={44} height={44} borderRadius={14} backgroundColor={card.color} alignItems="center" justifyContent="center">
|
||||
<card.icon size={20} color="white" />
|
||||
</XStack>
|
||||
<YStack space="$1" flex={1}>
|
||||
<Text fontSize="$md" fontWeight="800" color={text}>
|
||||
{card.label}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{card.desc}
|
||||
</Text>
|
||||
</YStack>
|
||||
<Text fontSize="$xl" color={subtle}>
|
||||
˃
|
||||
</Text>
|
||||
</XStack>
|
||||
</MobileCard>
|
||||
</Pressable>
|
||||
))}
|
||||
</YStack>
|
||||
<MobileSheet open={open} title={t('mobileDashboard.pickEvent', 'Select an event')} onClose={onClose}>
|
||||
<EventPickerList events={events} locale={locale} navigateOnSelect={false} onPick={onClose} />
|
||||
</MobileSheet>
|
||||
);
|
||||
}
|
||||
|
||||
function SecondaryGrid({
|
||||
function resolveLocation(event: TenantEvent | null, t: (key: string, fallback: string) => string): string {
|
||||
if (!event) return t('events.detail.locationPlaceholder', 'Location');
|
||||
const settings = (event.settings ?? {}) as Record<string, unknown>;
|
||||
const candidate =
|
||||
(settings.location as string | undefined) ??
|
||||
(settings.address as string | undefined) ??
|
||||
(settings.city as string | undefined);
|
||||
if (candidate && candidate.trim()) {
|
||||
return candidate;
|
||||
}
|
||||
return t('events.detail.locationPlaceholder', 'Location');
|
||||
}
|
||||
|
||||
function EventHeaderCard({
|
||||
event,
|
||||
onGuests,
|
||||
onPrint,
|
||||
onInvites,
|
||||
onSettings,
|
||||
onAnalytics,
|
||||
locale,
|
||||
canSwitch,
|
||||
onSwitch,
|
||||
onEdit,
|
||||
}: {
|
||||
event: TenantEvent | null;
|
||||
onGuests: () => void;
|
||||
onPrint: () => void;
|
||||
onInvites: () => void;
|
||||
onSettings: () => void;
|
||||
onAnalytics: () => void;
|
||||
locale: string;
|
||||
canSwitch: boolean;
|
||||
onSwitch: () => void;
|
||||
onEdit: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation('management');
|
||||
const { textStrong, muted, border, surface, accentSoft, primary } = useAdminTheme();
|
||||
const text = textStrong;
|
||||
|
||||
if (!event) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dateLabel = formatEventDate(event.event_date, locale) ?? t('events.detail.dateTbd', 'Date tbd');
|
||||
const locationLabel = resolveLocation(event, t);
|
||||
|
||||
return (
|
||||
<MobileCard space="$3" borderColor={border} backgroundColor={surface} position="relative">
|
||||
<XStack alignItems="center" justifyContent="space-between" space="$2">
|
||||
{canSwitch ? (
|
||||
<Pressable onPress={onSwitch} aria-label={t('mobileDashboard.pickEvent', 'Select an event')}>
|
||||
<XStack alignItems="center" space="$2">
|
||||
<Text fontSize="$lg" fontWeight="800" color={textStrong}>
|
||||
{resolveEventDisplayName(event)}
|
||||
</Text>
|
||||
<ChevronDown size={16} color={muted} />
|
||||
</XStack>
|
||||
</Pressable>
|
||||
) : (
|
||||
<Text fontSize="$lg" fontWeight="800" color={textStrong}>
|
||||
{resolveEventDisplayName(event)}
|
||||
</Text>
|
||||
)}
|
||||
<PillBadge tone={event.status === 'published' ? 'success' : 'warning'}>
|
||||
{event.status === 'published'
|
||||
? t('events.status.published', 'Live')
|
||||
: t('events.status.draft', 'Draft')}
|
||||
</PillBadge>
|
||||
</XStack>
|
||||
|
||||
<XStack alignItems="center" space="$2">
|
||||
<CalendarDays size={16} color={muted} />
|
||||
<Text fontSize="$sm" color={muted}>
|
||||
{dateLabel}
|
||||
</Text>
|
||||
<MapPin size={16} color={muted} />
|
||||
<Text fontSize="$sm" color={muted}>
|
||||
{locationLabel}
|
||||
</Text>
|
||||
</XStack>
|
||||
|
||||
<Pressable
|
||||
aria-label={t('mobileEvents.edit', 'Edit event')}
|
||||
onPress={onEdit}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 16,
|
||||
top: 16,
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
backgroundColor: accentSoft,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 6px 16px rgba(0,0,0,0.12)',
|
||||
}}
|
||||
>
|
||||
<Pencil size={18} color={primary} />
|
||||
</Pressable>
|
||||
</MobileCard>
|
||||
);
|
||||
}
|
||||
|
||||
function EventManagementGrid({
|
||||
event,
|
||||
tasksEnabled,
|
||||
onNavigate,
|
||||
}: {
|
||||
event: TenantEvent | null;
|
||||
tasksEnabled: boolean;
|
||||
onNavigate: (path: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation('management');
|
||||
const { textStrong } = useAdminTheme();
|
||||
const slug = event?.slug ?? null;
|
||||
const brandingAllowed = isBrandingAllowed(event ?? null);
|
||||
|
||||
if (!event) {
|
||||
return null;
|
||||
}
|
||||
const tiles = [
|
||||
{
|
||||
icon: Pencil,
|
||||
label: t('mobileDashboard.shortcutSettings', 'Event settings'),
|
||||
color: ADMIN_ACTION_COLORS.settings,
|
||||
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/edit`)) : undefined,
|
||||
disabled: !slug,
|
||||
},
|
||||
{
|
||||
icon: Sparkles,
|
||||
label: tasksEnabled
|
||||
? t('events.quick.tasks', 'Tasks & Checklists')
|
||||
: `${t('events.quick.tasks', 'Tasks & Checklists')} (${t('common:states.disabled', 'Disabled')})`,
|
||||
color: ADMIN_ACTION_COLORS.tasks,
|
||||
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/tasks`)) : undefined,
|
||||
disabled: !tasksEnabled || !slug,
|
||||
},
|
||||
{
|
||||
icon: QrCode,
|
||||
label: t('events.quick.qr', 'QR Code Layouts'),
|
||||
color: ADMIN_ACTION_COLORS.qr,
|
||||
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/qr`)) : undefined,
|
||||
disabled: !slug,
|
||||
},
|
||||
{
|
||||
icon: ImageIcon,
|
||||
label: t('events.quick.images', 'Image Management'),
|
||||
color: ADMIN_ACTION_COLORS.images,
|
||||
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/photos`)) : undefined,
|
||||
disabled: !slug,
|
||||
},
|
||||
{
|
||||
icon: Tv,
|
||||
label: t('events.quick.liveShow', 'Live Show queue'),
|
||||
color: ADMIN_ACTION_COLORS.images,
|
||||
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/live-show`)) : undefined,
|
||||
disabled: !slug,
|
||||
},
|
||||
{
|
||||
icon: Settings,
|
||||
label: t('events.quick.liveShowSettings', 'Live Show settings'),
|
||||
color: ADMIN_ACTION_COLORS.images,
|
||||
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/live-show/settings`)) : undefined,
|
||||
disabled: !slug,
|
||||
},
|
||||
{
|
||||
icon: Users,
|
||||
label: t('mobileDashboard.shortcutGuests', 'Guest management'),
|
||||
label: t('events.quick.guests', 'Guest Management'),
|
||||
color: ADMIN_ACTION_COLORS.guests,
|
||||
action: onGuests,
|
||||
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/members`)) : undefined,
|
||||
disabled: !slug,
|
||||
},
|
||||
{
|
||||
icon: Megaphone,
|
||||
label: t('events.quick.guestMessages', 'Guest messages'),
|
||||
color: ADMIN_ACTION_COLORS.guestMessages,
|
||||
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/guest-notifications`)) : undefined,
|
||||
disabled: !slug,
|
||||
},
|
||||
{
|
||||
icon: Layout,
|
||||
label: t('events.quick.branding', 'Branding & Theme'),
|
||||
color: ADMIN_ACTION_COLORS.branding,
|
||||
onPress: slug && brandingAllowed ? () => onNavigate(adminPath(`/mobile/events/${slug}/branding`)) : undefined,
|
||||
disabled: !brandingAllowed || !slug,
|
||||
},
|
||||
{
|
||||
icon: Camera,
|
||||
label: t('events.quick.photobooth', 'Photobooth'),
|
||||
color: ADMIN_ACTION_COLORS.photobooth,
|
||||
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/photobooth`)) : undefined,
|
||||
disabled: !slug,
|
||||
},
|
||||
{
|
||||
icon: TrendingUp,
|
||||
label: t('mobileDashboard.shortcutAnalytics', 'Analytics'),
|
||||
color: ADMIN_ACTION_COLORS.analytics,
|
||||
action: onAnalytics,
|
||||
},
|
||||
{
|
||||
icon: QrCode,
|
||||
label: t('mobileDashboard.shortcutPrints', 'Print & poster downloads'),
|
||||
color: ADMIN_ACTION_COLORS.qr,
|
||||
action: onPrint,
|
||||
},
|
||||
{
|
||||
icon: Sparkles,
|
||||
label: t('mobileDashboard.shortcutInvites', 'Team / helper invites'),
|
||||
color: ADMIN_ACTION_COLORS.invites,
|
||||
action: onInvites,
|
||||
},
|
||||
{
|
||||
icon: Settings,
|
||||
label: t('mobileDashboard.shortcutSettings', 'Event settings'),
|
||||
color: ADMIN_ACTION_COLORS.settings,
|
||||
action: onSettings,
|
||||
},
|
||||
{
|
||||
icon: Sparkles,
|
||||
label: t('mobileDashboard.shortcutBranding', 'Branding & moderation'),
|
||||
color: ADMIN_ACTION_COLORS.branding,
|
||||
action: brandingAllowed ? onSettings : undefined,
|
||||
disabled: !brandingAllowed,
|
||||
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/analytics`)) : undefined,
|
||||
disabled: !slug,
|
||||
},
|
||||
];
|
||||
|
||||
if (event && isPastEvent(event.event_date)) {
|
||||
tiles.push({
|
||||
icon: Sparkles,
|
||||
label: t('events.quick.recap', 'Recap & Archive'),
|
||||
color: ADMIN_ACTION_COLORS.recap,
|
||||
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/recap`)) : undefined,
|
||||
disabled: !slug,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<YStack space="$2" marginTop="$2">
|
||||
<Text fontSize="$sm" fontWeight="800" color={text}>
|
||||
{t('mobileDashboard.shortcutsTitle', 'Shortcuts')}
|
||||
<YStack space="$2">
|
||||
<Text fontSize="$sm" fontWeight="800" color={textStrong}>
|
||||
{t('events.detail.managementTitle', 'Event management')}
|
||||
</Text>
|
||||
<XStack flexWrap="wrap" space="$2">
|
||||
{tiles.map((tile, index) => (
|
||||
@@ -1178,22 +1295,12 @@ function SecondaryGrid({
|
||||
icon={tile.icon}
|
||||
label={tile.label}
|
||||
color={tile.color}
|
||||
onPress={tile.action}
|
||||
onPress={tile.onPress}
|
||||
disabled={tile.disabled}
|
||||
delayMs={index * ADMIN_MOTION.tileStaggerMs}
|
||||
/>
|
||||
))}
|
||||
</XStack>
|
||||
{event ? (
|
||||
<MobileCard backgroundColor={surface} borderColor={border} space="$1.5">
|
||||
<Text fontSize="$sm" fontWeight="700" color={text}>
|
||||
{resolveEventDisplayName(event)}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{renderEventLocation(event)}
|
||||
</Text>
|
||||
</MobileCard>
|
||||
) : null}
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,25 +2,24 @@ import React from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { BarChart2, TrendingUp, Users, ListTodo, Lock, Trophy, Calendar } from 'lucide-react';
|
||||
import { TrendingUp, Users, ListTodo, Lock, Trophy } from 'lucide-react';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { de, enGB } from 'date-fns/locale';
|
||||
|
||||
import { MobileShell } from './components/MobileShell';
|
||||
import { MobileCard, CTAButton, SkeletonCard } from './components/Primitives';
|
||||
import { MobileCard, CTAButton, KpiTile, SkeletonCard } from './components/Primitives';
|
||||
import { getEventAnalytics, EventAnalytics } from '../api';
|
||||
import { ApiError } from '../lib/apiError';
|
||||
import { useAdminTheme } from './theme';
|
||||
import { resolveMaxCount, resolveTimelineHours } from './lib/analytics';
|
||||
import { adminPath } from '../constants';
|
||||
import { useEventContext } from '../context/EventContext';
|
||||
|
||||
export default function MobileEventAnalyticsPage() {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const { t, i18n } = useTranslation('management');
|
||||
const navigate = useNavigate();
|
||||
const { activeEvent } = useEventContext();
|
||||
const { textStrong, muted, border, surface, primary, accentSoft } = useAdminTheme();
|
||||
|
||||
const dateLocale = i18n.language.startsWith('de') ? de : enGB;
|
||||
@@ -99,18 +98,47 @@ export default function MobileEventAnalyticsPage() {
|
||||
const hasTimeline = timeline.length > 0;
|
||||
const hasContributors = contributors.length > 0;
|
||||
const hasTasks = tasks.length > 0;
|
||||
const fallbackHours = 12;
|
||||
const rawTimelineHours = resolveTimelineHours(timeline.map((point) => point.timestamp), fallbackHours);
|
||||
const timeframeHours = Math.min(rawTimelineHours, fallbackHours);
|
||||
const isTimeframeCapped = rawTimelineHours > fallbackHours;
|
||||
|
||||
// Prepare chart data
|
||||
const maxCount = Math.max(...timeline.map((p) => p.count), 1);
|
||||
const maxTimelineCount = resolveMaxCount(timeline.map((point) => point.count));
|
||||
const maxTaskCount = resolveMaxCount(tasks.map((task) => task.count));
|
||||
const totalUploads = timeline.reduce((total, point) => total + point.count, 0);
|
||||
const totalLikes = contributors.reduce((total, contributor) => total + contributor.likes, 0);
|
||||
const totalContributors = contributors.length;
|
||||
|
||||
return (
|
||||
<MobileShell
|
||||
title={t('analytics.title', 'Analytics')}
|
||||
subtitle={activeEvent?.name as string}
|
||||
activeTab="home"
|
||||
onBack={() => navigate(-1)}
|
||||
>
|
||||
<YStack space="$4">
|
||||
<YStack space="$2">
|
||||
<Text fontSize="$sm" fontWeight="800" color={textStrong}>
|
||||
{t('analytics.kpiTitle', 'Event snapshot')}
|
||||
</Text>
|
||||
<XStack space="$2" flexWrap="wrap">
|
||||
<KpiTile
|
||||
icon={TrendingUp}
|
||||
label={t('analytics.kpiUploads', 'Uploads')}
|
||||
value={totalUploads}
|
||||
/>
|
||||
<KpiTile
|
||||
icon={Users}
|
||||
label={t('analytics.kpiContributors', 'Contributors')}
|
||||
value={totalContributors}
|
||||
/>
|
||||
<KpiTile
|
||||
icon={Trophy}
|
||||
label={t('analytics.kpiLikes', 'Likes')}
|
||||
value={totalLikes}
|
||||
/>
|
||||
</XStack>
|
||||
</YStack>
|
||||
{/* Activity Timeline */}
|
||||
<MobileCard space="$3" borderColor={border} backgroundColor={surface}>
|
||||
<XStack alignItems="center" space="$2">
|
||||
@@ -119,12 +147,22 @@ export default function MobileEventAnalyticsPage() {
|
||||
{t('analytics.activityTitle', 'Activity Timeline')}
|
||||
</Text>
|
||||
</XStack>
|
||||
<YStack space="$0.5">
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('analytics.timeframe', 'Last {{hours}} hours', { hours: timeframeHours })}
|
||||
</Text>
|
||||
{isTimeframeCapped ? (
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('analytics.timeframeHint', 'Older activity hidden')}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
|
||||
{hasTimeline ? (
|
||||
<YStack height={180} justifyContent="flex-end" space="$2">
|
||||
<XStack alignItems="flex-end" justifyContent="space-between" height={150} gap="$1">
|
||||
{timeline.map((point, index) => {
|
||||
const heightPercent = (point.count / maxCount) * 100;
|
||||
const heightPercent = (point.count / maxTimelineCount) * 100;
|
||||
const date = parseISO(point.timestamp);
|
||||
// Show label every 3rd point or if few points
|
||||
const showLabel = timeline.length < 8 || index % 3 === 0;
|
||||
@@ -141,7 +179,7 @@ export default function MobileEventAnalyticsPage() {
|
||||
/>
|
||||
{showLabel && (
|
||||
<Text fontSize={10} color={muted} numberOfLines={1}>
|
||||
{format(date, 'HH:mm')}
|
||||
{format(date, 'HH:mm', { locale: dateLocale })}
|
||||
</Text>
|
||||
)}
|
||||
</YStack>
|
||||
@@ -153,7 +191,11 @@ export default function MobileEventAnalyticsPage() {
|
||||
</Text>
|
||||
</YStack>
|
||||
) : (
|
||||
<EmptyState message={t('analytics.noActivity', 'No uploads yet')} />
|
||||
<EmptyState
|
||||
message={t('analytics.noActivity', 'No uploads yet')}
|
||||
actionLabel={t('analytics.emptyActionShareQr', 'Share your QR code')}
|
||||
onAction={() => slug && navigate(adminPath(`/mobile/events/${slug}/qr`))}
|
||||
/>
|
||||
)}
|
||||
</MobileCard>
|
||||
|
||||
@@ -199,7 +241,11 @@ export default function MobileEventAnalyticsPage() {
|
||||
))}
|
||||
</YStack>
|
||||
) : (
|
||||
<EmptyState message={t('analytics.noContributors', 'No contributors yet')} />
|
||||
<EmptyState
|
||||
message={t('analytics.noContributors', 'No contributors yet')}
|
||||
actionLabel={t('analytics.emptyActionInvite', 'Invite guests')}
|
||||
onAction={() => slug && navigate(adminPath(`/mobile/events/${slug}/members`))}
|
||||
/>
|
||||
)}
|
||||
</MobileCard>
|
||||
|
||||
@@ -215,7 +261,6 @@ export default function MobileEventAnalyticsPage() {
|
||||
{hasTasks ? (
|
||||
<YStack space="$3">
|
||||
{tasks.map((task) => {
|
||||
const maxTaskCount = Math.max(...tasks.map(t => t.count), 1);
|
||||
const percent = (task.count / maxTaskCount) * 100;
|
||||
return (
|
||||
<YStack key={task.task_id} space="$1">
|
||||
@@ -240,7 +285,11 @@ export default function MobileEventAnalyticsPage() {
|
||||
})}
|
||||
</YStack>
|
||||
) : (
|
||||
<EmptyState message={t('analytics.noTasks', 'No task activity yet')} />
|
||||
<EmptyState
|
||||
message={t('analytics.noTasks', 'No task activity yet')}
|
||||
actionLabel={t('analytics.emptyActionOpenTasks', 'Open tasks')}
|
||||
onAction={() => slug && navigate(adminPath(`/mobile/events/${slug}/tasks`))}
|
||||
/>
|
||||
)}
|
||||
</MobileCard>
|
||||
</YStack>
|
||||
@@ -248,13 +297,24 @@ export default function MobileEventAnalyticsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ message }: { message: string }) {
|
||||
function EmptyState({
|
||||
message,
|
||||
actionLabel,
|
||||
onAction,
|
||||
}: {
|
||||
message: string;
|
||||
actionLabel?: string;
|
||||
onAction?: () => void;
|
||||
}) {
|
||||
const { muted } = useAdminTheme();
|
||||
return (
|
||||
<YStack padding="$4" alignItems="center" justifyContent="center">
|
||||
<YStack padding="$4" alignItems="center" justifyContent="center" space="$2">
|
||||
<Text fontSize="$sm" color={muted}>
|
||||
{message}
|
||||
</Text>
|
||||
{actionLabel && onAction ? (
|
||||
<CTAButton label={actionLabel} tone="ghost" fullWidth={false} onPress={onAction} />
|
||||
) : null}
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import { MobileShell } from './components/MobileShell';
|
||||
import { MobileCard, CTAButton } from './components/Primitives';
|
||||
import { MobileField, MobileInput, MobileSelect, MobileTextArea } from './components/FormControls';
|
||||
import { LegalConsentSheet } from './components/LegalConsentSheet';
|
||||
import { createEvent, getEvent, updateEvent, getEventTypes, TenantEvent, TenantEventType, trackOnboarding } from '../api';
|
||||
import { createEvent, getEvent, updateEvent, getEventTypes, getPackages, Package, TenantEvent, TenantEventType, trackOnboarding } from '../api';
|
||||
import { resolveEventSlugAfterUpdate } from './eventFormNavigation';
|
||||
import { adminPath } from '../constants';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
@@ -18,6 +18,7 @@ import toast from 'react-hot-toast';
|
||||
import { useBackNavigation } from './hooks/useBackNavigation';
|
||||
import { useAdminTheme } from './theme';
|
||||
import { withAlpha } from './components/colors';
|
||||
import { useAuth } from '../auth/context';
|
||||
|
||||
type FormState = {
|
||||
name: string;
|
||||
@@ -28,6 +29,7 @@ type FormState = {
|
||||
published: boolean;
|
||||
autoApproveUploads: boolean;
|
||||
tasksEnabled: boolean;
|
||||
packageId: number | null;
|
||||
};
|
||||
|
||||
export default function MobileEventFormPage() {
|
||||
@@ -36,7 +38,9 @@ export default function MobileEventFormPage() {
|
||||
const isEdit = Boolean(slug);
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation(['management', 'common']);
|
||||
const { user } = useAuth();
|
||||
const { text, muted, subtle, danger, border, surface, primary } = useAdminTheme();
|
||||
const isSuperAdmin = user?.role === 'super_admin' || user?.role === 'superadmin';
|
||||
|
||||
const [form, setForm] = React.useState<FormState>({
|
||||
name: '',
|
||||
@@ -47,9 +51,12 @@ export default function MobileEventFormPage() {
|
||||
published: false,
|
||||
autoApproveUploads: true,
|
||||
tasksEnabled: true,
|
||||
packageId: null,
|
||||
});
|
||||
const [eventTypes, setEventTypes] = React.useState<TenantEventType[]>([]);
|
||||
const [typesLoading, setTypesLoading] = React.useState(false);
|
||||
const [packages, setPackages] = React.useState<Package[]>([]);
|
||||
const [packagesLoading, setPackagesLoading] = React.useState(false);
|
||||
const [loading, setLoading] = React.useState(isEdit);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const [consentOpen, setConsentOpen] = React.useState(false);
|
||||
@@ -76,6 +83,7 @@ export default function MobileEventFormPage() {
|
||||
tasksEnabled:
|
||||
(data.settings?.engagement_mode as string | undefined) !== 'photo_only' &&
|
||||
(data.engagement_mode as string | undefined) !== 'photo_only',
|
||||
packageId: null,
|
||||
});
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
@@ -106,6 +114,31 @@ export default function MobileEventFormPage() {
|
||||
})();
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isSuperAdmin || isEdit) {
|
||||
return;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
setPackagesLoading(true);
|
||||
try {
|
||||
const data = await getPackages('endcustomer');
|
||||
setPackages(data);
|
||||
setForm((prev) => {
|
||||
if (prev.packageId) {
|
||||
return prev;
|
||||
}
|
||||
const preferred = data.find((pkg) => pkg.id === 3) ?? data[0] ?? null;
|
||||
return { ...prev, packageId: preferred?.id ?? null };
|
||||
});
|
||||
} catch {
|
||||
setPackages([]);
|
||||
} finally {
|
||||
setPackagesLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [isSuperAdmin, isEdit]);
|
||||
|
||||
async function handleSubmit() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
@@ -131,6 +164,7 @@ export default function MobileEventFormPage() {
|
||||
event_type_id: form.eventTypeId ?? undefined,
|
||||
event_date: form.date || undefined,
|
||||
status: form.published ? 'published' : 'draft',
|
||||
package_id: isSuperAdmin ? form.packageId ?? undefined : undefined,
|
||||
settings: {
|
||||
location: form.location,
|
||||
guest_upload_visibility: form.autoApproveUploads ? 'immediate' : 'review',
|
||||
@@ -153,6 +187,7 @@ export default function MobileEventFormPage() {
|
||||
event_type_id: form.eventTypeId ?? undefined,
|
||||
event_date: form.date || undefined,
|
||||
status: form.published ? 'published' : 'draft',
|
||||
package_id: isSuperAdmin ? form.packageId ?? undefined : undefined,
|
||||
settings: {
|
||||
location: form.location,
|
||||
guest_upload_visibility: form.autoApproveUploads ? 'immediate' : 'review',
|
||||
@@ -223,6 +258,31 @@ export default function MobileEventFormPage() {
|
||||
/>
|
||||
</MobileField>
|
||||
|
||||
{isSuperAdmin && !isEdit ? (
|
||||
<MobileField label={t('eventForm.fields.package.label', 'Package')}>
|
||||
{packagesLoading ? (
|
||||
<Text fontSize="$sm" color={muted}>{t('eventForm.fields.package.loading', 'Loading packages…')}</Text>
|
||||
) : packages.length === 0 ? (
|
||||
<Text fontSize="$sm" color={muted}>{t('eventForm.fields.package.empty', 'No packages available yet.')}</Text>
|
||||
) : (
|
||||
<MobileSelect
|
||||
value={form.packageId ?? ''}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, packageId: Number(e.target.value) }))}
|
||||
>
|
||||
<option value="">{t('eventForm.fields.package.placeholder', 'Select package')}</option>
|
||||
{packages.map((pkg) => (
|
||||
<option key={pkg.id} value={pkg.id}>
|
||||
{pkg.name || `#${pkg.id}`}
|
||||
</option>
|
||||
))}
|
||||
</MobileSelect>
|
||||
)}
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('eventForm.fields.package.help', 'This controls the event’s premium limits.')}
|
||||
</Text>
|
||||
</MobileField>
|
||||
) : null}
|
||||
|
||||
<MobileField label={t('eventForm.fields.date.label', 'Date & time')}>
|
||||
<XStack alignItems="center" space="$2">
|
||||
<NativeDateTimeInput
|
||||
|
||||
@@ -12,7 +12,7 @@ import { MobileField, MobileInput, MobileSelect } from './components/FormControl
|
||||
import { getEvent, getLiveShowLink, rotateLiveShowLink, updateEvent, LiveShowLink, LiveShowSettings, TenantEvent } from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import { getApiErrorMessage } from '../lib/apiError';
|
||||
import { formatEventDate, resolveEventDisplayName } from '../lib/events';
|
||||
import { resolveEventDisplayName } from '../lib/events';
|
||||
import { adminPath } from '../constants';
|
||||
import { useBackNavigation } from './hooks/useBackNavigation';
|
||||
import { useAdminTheme } from './theme';
|
||||
@@ -262,8 +262,7 @@ export default function MobileEventLiveShowSettingsPage() {
|
||||
return (
|
||||
<MobileShell
|
||||
activeTab="home"
|
||||
title={event ? resolveEventDisplayName(event) : t('liveShowSettings.title', 'Live Show settings')}
|
||||
subtitle={event?.event_date ? formatEventDate(event.event_date, locale) ?? undefined : undefined}
|
||||
title={t('liveShowSettings.title', 'Live Show settings')}
|
||||
onBack={back}
|
||||
headerActions={
|
||||
<HeaderActionButton onPress={() => load()} ariaLabel={t('common.refresh', 'Refresh')}>
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
} from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import { getApiErrorMessage } from '../lib/apiError';
|
||||
import { formatEventDate, resolveEventDisplayName } from '../lib/events';
|
||||
import { formatEventDate } from '../lib/events';
|
||||
import toast from 'react-hot-toast';
|
||||
import { adminPath } from '../constants';
|
||||
import { useBackNavigation } from './hooks/useBackNavigation';
|
||||
@@ -146,9 +146,7 @@ export default function MobileEventPhotoboothPage() {
|
||||
: t('photobooth.credentials.heading', 'FTP (Classic)');
|
||||
|
||||
const isActive = Boolean(status?.enabled);
|
||||
const title = event ? resolveEventDisplayName(event) : t('management.header.appName', 'Event Admin');
|
||||
const subtitle =
|
||||
event?.event_date ? formatEventDate(event.event_date, locale) : t('header.selectEvent', 'Select an event to continue');
|
||||
const title = t('photobooth.title', 'Photobooth');
|
||||
|
||||
const handleToggle = (checked: boolean) => {
|
||||
if (!slug || updating) return;
|
||||
@@ -163,7 +161,6 @@ export default function MobileEventPhotoboothPage() {
|
||||
<MobileShell
|
||||
activeTab="home"
|
||||
title={title}
|
||||
subtitle={subtitle ?? undefined}
|
||||
onBack={back}
|
||||
headerActions={
|
||||
<HeaderActionButton onPress={() => load()} ariaLabel={t('common.refresh', 'Refresh')}>
|
||||
|
||||
@@ -152,7 +152,6 @@ export default function MobileEventRecapPage() {
|
||||
<MobileShell
|
||||
activeTab="home"
|
||||
title={t('events.recap.title', 'Event Recap')}
|
||||
subtitle={resolveName(event.name)}
|
||||
onBack={back}
|
||||
>
|
||||
<YStack space="$4">
|
||||
@@ -392,4 +391,4 @@ function formatDate(iso?: string | null): string {
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
return date.toLocaleDateString(undefined, { day: '2-digit', month: 'short', year: 'numeric' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -691,6 +691,7 @@ export default function MobileNotificationsPage() {
|
||||
}
|
||||
}}
|
||||
title={selectedNotification?.title ?? t('mobileNotifications.title', 'Notifications')}
|
||||
snapPoints={[94]}
|
||||
footer={
|
||||
selectedNotification && !selectedNotification.is_read ? (
|
||||
<CTAButton label={t('notificationLogs.markRead', 'Mark as read')} onPress={() => markSelectedRead()} />
|
||||
@@ -705,7 +706,7 @@ export default function MobileNotificationsPage() {
|
||||
<Text fontSize="$sm" color={muted}>
|
||||
{selectedNotification.body}
|
||||
</Text>
|
||||
<XStack space="$2">
|
||||
<XStack space="$2" flexWrap="wrap" style={{ rowGap: 8 }}>
|
||||
<PillBadge tone={selectedNotification.tone === 'warning' ? 'warning' : 'muted'}>
|
||||
{selectedNotification.scope}
|
||||
</PillBadge>
|
||||
|
||||
@@ -1,25 +1,31 @@
|
||||
import React from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Check, ChevronRight, ShieldCheck, ShoppingBag, Sparkles, Star } from 'lucide-react';
|
||||
import { Check, ChevronRight, ShieldCheck, Sparkles, X } from 'lucide-react';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Checkbox } from '@tamagui/checkbox';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
import { MobileShell } from './components/MobileShell';
|
||||
import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Primitives';
|
||||
import { useAdminTheme } from './theme';
|
||||
import { getPackages, createTenantPaddleCheckout, Package, getTenantPackagesOverview, TenantPackageSummary } from '../api';
|
||||
import { getApiErrorMessage } from '../lib/apiError';
|
||||
import { getPackages, Package, getTenantPackagesOverview, TenantPackageSummary } from '../api';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
buildPackageComparisonRows,
|
||||
classifyPackageChange,
|
||||
getEnabledPackageFeatures,
|
||||
selectRecommendedPackageId,
|
||||
} from './lib/packageShop';
|
||||
import { usePackageCheckout } from './hooks/usePackageCheckout';
|
||||
|
||||
export default function MobilePackageShopPage() {
|
||||
const { t } = useTranslation('management');
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { textStrong, muted, border, primary, surface, accentSoft, warningText } = useAdminTheme();
|
||||
const { textStrong, muted, border, primary, accentSoft } = useAdminTheme();
|
||||
const [selectedPackage, setSelectedPackage] = React.useState<Package | null>(null);
|
||||
const [viewMode, setViewMode] = React.useState<'cards' | 'compare'>('cards');
|
||||
|
||||
// Extract recommended feature from URL
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
@@ -57,19 +63,36 @@ export default function MobilePackageShopPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const activePackageId = inventory?.activePackage?.package_id ?? null;
|
||||
const activeCatalogPackage = (catalog ?? []).find((pkg) => pkg.id === activePackageId) ?? null;
|
||||
const recommendedPackageId = selectRecommendedPackageId(catalog ?? [], recommendedFeature, activeCatalogPackage);
|
||||
|
||||
// Merge and sort packages
|
||||
const sortedPackages = [...(catalog || [])].sort((a, b) => {
|
||||
// 1. Recommended feature first
|
||||
const aHasFeature = recommendedFeature && a.features?.[recommendedFeature];
|
||||
const bHasFeature = recommendedFeature && b.features?.[recommendedFeature];
|
||||
if (aHasFeature && !bHasFeature) return -1;
|
||||
if (!aHasFeature && bHasFeature) return 1;
|
||||
if (recommendedPackageId) {
|
||||
if (a.id === recommendedPackageId && b.id !== recommendedPackageId) return -1;
|
||||
if (b.id === recommendedPackageId && a.id !== recommendedPackageId) return 1;
|
||||
}
|
||||
|
||||
// 2. Inventory status (Owned packages later if they are fully used, but usually we want to show active stuff)
|
||||
// Actually, let's keep price sorting as secondary
|
||||
return a.price - b.price;
|
||||
});
|
||||
|
||||
const packageEntries = sortedPackages.map((pkg) => {
|
||||
const owned = inventory?.packages?.find((entry) => entry.package_id === pkg.id);
|
||||
const isActive = inventory?.activePackage?.package_id === pkg.id;
|
||||
const isRecommended = recommendedPackageId ? pkg.id === recommendedPackageId : false;
|
||||
const { isUpgrade, isDowngrade } = classifyPackageChange(pkg, activeCatalogPackage);
|
||||
|
||||
return {
|
||||
pkg,
|
||||
owned,
|
||||
isActive,
|
||||
isRecommended,
|
||||
isUpgrade,
|
||||
isDowngrade,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<MobileShell title={t('shop.title', 'Upgrade Package')} onBack={() => navigate(-1)} activeTab="profile">
|
||||
<YStack space="$4">
|
||||
@@ -93,23 +116,45 @@ export default function MobilePackageShopPage() {
|
||||
</Text>
|
||||
</YStack>
|
||||
|
||||
<YStack space="$3">
|
||||
{sortedPackages.map((pkg) => {
|
||||
const owned = inventory?.packages?.find(p => p.package_id === pkg.id);
|
||||
const isActive = inventory?.activePackage?.package_id === pkg.id;
|
||||
const isRecommended = recommendedFeature && pkg.features?.[recommendedFeature];
|
||||
{packageEntries.length > 1 ? (
|
||||
<XStack space="$2" paddingHorizontal="$2">
|
||||
<CTAButton
|
||||
label={t('shop.compare.toggleCards', 'Cards')}
|
||||
tone={viewMode === 'cards' ? 'primary' : 'ghost'}
|
||||
fullWidth={false}
|
||||
onPress={() => setViewMode('cards')}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<CTAButton
|
||||
label={t('shop.compare.toggleCompare', 'Compare')}
|
||||
tone={viewMode === 'compare' ? 'primary' : 'ghost'}
|
||||
fullWidth={false}
|
||||
onPress={() => setViewMode('compare')}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</XStack>
|
||||
) : null}
|
||||
|
||||
return (
|
||||
<YStack space="$3">
|
||||
{viewMode === 'compare' ? (
|
||||
<PackageShopCompareView
|
||||
entries={packageEntries}
|
||||
onSelect={(pkg) => setSelectedPackage(pkg)}
|
||||
/>
|
||||
) : (
|
||||
packageEntries.map((entry) => (
|
||||
<PackageShopCard
|
||||
key={pkg.id}
|
||||
pkg={pkg}
|
||||
owned={owned}
|
||||
isActive={isActive}
|
||||
isRecommended={isRecommended}
|
||||
onSelect={() => setSelectedPackage(pkg)}
|
||||
key={entry.pkg.id}
|
||||
pkg={entry.pkg}
|
||||
owned={entry.owned}
|
||||
isActive={entry.isActive}
|
||||
isRecommended={entry.isRecommended}
|
||||
isUpgrade={entry.isUpgrade}
|
||||
isDowngrade={entry.isDowngrade}
|
||||
onSelect={() => setSelectedPackage(entry.pkg)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
))
|
||||
)}
|
||||
</YStack>
|
||||
</YStack>
|
||||
</MobileShell>
|
||||
@@ -121,34 +166,34 @@ function PackageShopCard({
|
||||
owned,
|
||||
isActive,
|
||||
isRecommended,
|
||||
isUpgrade,
|
||||
isDowngrade,
|
||||
onSelect
|
||||
}: {
|
||||
pkg: Package;
|
||||
owned?: TenantPackageSummary;
|
||||
isActive?: boolean;
|
||||
isRecommended?: any;
|
||||
isUpgrade?: boolean;
|
||||
isDowngrade?: boolean;
|
||||
onSelect: () => void
|
||||
}) {
|
||||
const { textStrong, muted, border, primary, accentSoft } = useAdminTheme();
|
||||
const { t } = useTranslation('management');
|
||||
|
||||
const hasRemainingEvents = owned && (owned.remaining_events === null || owned.remaining_events > 0);
|
||||
const statusLabel = isActive
|
||||
? t('shop.status.active', 'Active Plan')
|
||||
: owned
|
||||
? (owned.remaining_events !== null
|
||||
? t('shop.status.remaining', '{{count}} Events left', { count: owned.remaining_events })
|
||||
: t('shop.status.owned', 'Purchased'))
|
||||
: null;
|
||||
const statusLabel = getPackageStatusLabel({ t, isActive, owned });
|
||||
const isSubdued = Boolean((isDowngrade || !isUpgrade) && !isActive);
|
||||
const canSelect = canSelectPackage(isUpgrade, isActive);
|
||||
|
||||
return (
|
||||
<MobileCard
|
||||
onPress={onSelect}
|
||||
onPress={canSelect ? onSelect : undefined}
|
||||
borderColor={isRecommended ? primary : (isActive ? '$green8' : border)}
|
||||
borderWidth={isRecommended || isActive ? 2 : 1}
|
||||
space="$3"
|
||||
pressStyle={{ backgroundColor: accentSoft }}
|
||||
pressStyle={canSelect ? { backgroundColor: accentSoft } : undefined}
|
||||
backgroundColor={isActive ? '$green1' : undefined}
|
||||
style={{ opacity: isSubdued ? 0.6 : 1 }}
|
||||
>
|
||||
<XStack justifyContent="space-between" alignItems="flex-start">
|
||||
<YStack space="$1">
|
||||
@@ -157,6 +202,8 @@ function PackageShopCard({
|
||||
{pkg.name}
|
||||
</Text>
|
||||
{isRecommended && <PillBadge tone="warning">{t('shop.badges.recommended', 'Recommended')}</PillBadge>}
|
||||
{isUpgrade && !isActive ? <PillBadge tone="success">{t('shop.badges.upgrade', 'Upgrade')}</PillBadge> : null}
|
||||
{isDowngrade && !isActive ? <PillBadge tone="muted">{t('shop.badges.downgrade', 'Downgrade')}</PillBadge> : null}
|
||||
{isActive && <PillBadge tone="success">{t('shop.badges.active', 'Active')}</PillBadge>}
|
||||
</XStack>
|
||||
|
||||
@@ -187,19 +234,25 @@ function PackageShopCard({
|
||||
) : null}
|
||||
|
||||
{/* Render specific feature if it was requested */}
|
||||
{Object.entries(pkg.features || {})
|
||||
.filter(([key, val]) => val === true && (!pkg.max_photos || key !== 'photos'))
|
||||
.slice(0, 3)
|
||||
.map(([key]) => (
|
||||
<FeatureRow key={key} label={t(`shop.features.${key}`, key)} />
|
||||
))
|
||||
}
|
||||
{getEnabledPackageFeatures(pkg)
|
||||
.filter((key) => !pkg.max_photos || key !== 'photos')
|
||||
.slice(0, 3)
|
||||
.map((key) => (
|
||||
<FeatureRow key={key} label={t(`shop.features.${key}`, key)} />
|
||||
))}
|
||||
</YStack>
|
||||
|
||||
<CTAButton
|
||||
label={isActive ? t('shop.manage', 'Manage Plan') : t('shop.select', 'Select')}
|
||||
onPress={onSelect}
|
||||
tone={isActive ? 'ghost' : 'primary'}
|
||||
label={
|
||||
isActive
|
||||
? t('shop.manage', 'Manage Plan')
|
||||
: isUpgrade
|
||||
? t('shop.select', 'Select')
|
||||
: t('shop.selectDisabled', 'Not available')
|
||||
}
|
||||
onPress={canSelect ? onSelect : undefined}
|
||||
tone={isActive || !isUpgrade ? 'ghost' : 'primary'}
|
||||
disabled={!canSelect}
|
||||
/>
|
||||
</MobileCard>
|
||||
);
|
||||
@@ -215,28 +268,224 @@ function FeatureRow({ label }: { label: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
type PackageEntry = {
|
||||
pkg: Package;
|
||||
owned?: TenantPackageSummary;
|
||||
isActive: boolean;
|
||||
isRecommended: boolean;
|
||||
isUpgrade: boolean;
|
||||
isDowngrade: boolean;
|
||||
};
|
||||
|
||||
function PackageShopCompareView({
|
||||
entries,
|
||||
onSelect,
|
||||
}: {
|
||||
entries: PackageEntry[];
|
||||
onSelect: (pkg: Package) => void;
|
||||
}) {
|
||||
const { t } = useTranslation('management');
|
||||
const { textStrong, muted, border, primary, accentSoft } = useAdminTheme();
|
||||
const comparisonRows = buildPackageComparisonRows(entries.map((entry) => entry.pkg));
|
||||
const labelWidth = 140;
|
||||
const columnWidth = 150;
|
||||
|
||||
const rows = [
|
||||
{ id: 'meta.plan', type: 'meta' as const, label: t('shop.compare.headers.plan', 'Plan') },
|
||||
{ id: 'meta.price', type: 'meta' as const, label: t('shop.compare.headers.price', 'Price') },
|
||||
...comparisonRows,
|
||||
];
|
||||
|
||||
const renderRowLabel = (row: typeof rows[number]) => {
|
||||
if (row.type === 'meta') {
|
||||
return row.label;
|
||||
}
|
||||
|
||||
if (row.type === 'limit') {
|
||||
if (row.limitKey === 'max_photos') {
|
||||
return t('shop.compare.rows.photos', 'Photos');
|
||||
}
|
||||
if (row.limitKey === 'max_guests') {
|
||||
return t('shop.compare.rows.guests', 'Guests');
|
||||
}
|
||||
return t('shop.compare.rows.days', 'Gallery days');
|
||||
}
|
||||
|
||||
return t(`shop.features.${row.featureKey}`, row.featureKey);
|
||||
};
|
||||
|
||||
const formatLimitValue = (value: number | null) => {
|
||||
if (value === null) {
|
||||
return t('shop.compare.values.unlimited', 'Unlimited');
|
||||
}
|
||||
return new Intl.NumberFormat().format(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<MobileCard space="$3" borderColor={border}>
|
||||
<YStack space="$1">
|
||||
<Text fontSize="$md" fontWeight="700" color={textStrong}>
|
||||
{t('shop.compare.title', 'Compare plans')}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('shop.compare.helper', 'Swipe to compare packages side by side.')}
|
||||
</Text>
|
||||
</YStack>
|
||||
|
||||
<XStack style={{ overflowX: 'auto' }}>
|
||||
<YStack space="$1.5" minWidth={labelWidth + columnWidth * entries.length}>
|
||||
{rows.map((row) => (
|
||||
<XStack key={row.id} borderBottomWidth={1} borderColor={border}>
|
||||
<YStack
|
||||
width={labelWidth}
|
||||
paddingVertical="$2"
|
||||
paddingRight="$3"
|
||||
justifyContent="center"
|
||||
>
|
||||
<Text fontSize="$xs" fontWeight="700" color={muted}>
|
||||
{renderRowLabel(row)}
|
||||
</Text>
|
||||
</YStack>
|
||||
{entries.map((entry) => {
|
||||
const cellBackground = entry.isRecommended ? accentSoft : entry.isActive ? '$green1' : undefined;
|
||||
let content: React.ReactNode = null;
|
||||
|
||||
if (row.type === 'meta') {
|
||||
if (row.id === 'meta.plan') {
|
||||
const statusLabel = getPackageStatusLabel({ t, isActive: entry.isActive, owned: entry.owned });
|
||||
content = (
|
||||
<YStack space="$1">
|
||||
<Text fontSize="$sm" fontWeight="800" color={textStrong}>
|
||||
{entry.pkg.name}
|
||||
</Text>
|
||||
<XStack space="$1.5" flexWrap="wrap">
|
||||
{entry.isRecommended ? (
|
||||
<PillBadge tone="warning">{t('shop.badges.recommended', 'Recommended')}</PillBadge>
|
||||
) : null}
|
||||
{entry.isUpgrade && !entry.isActive ? (
|
||||
<PillBadge tone="success">{t('shop.badges.upgrade', 'Upgrade')}</PillBadge>
|
||||
) : null}
|
||||
{entry.isDowngrade && !entry.isActive ? (
|
||||
<PillBadge tone="muted">{t('shop.badges.downgrade', 'Downgrade')}</PillBadge>
|
||||
) : null}
|
||||
{entry.isActive ? <PillBadge tone="success">{t('shop.badges.active', 'Active')}</PillBadge> : null}
|
||||
</XStack>
|
||||
{statusLabel ? (
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{statusLabel}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
);
|
||||
} else if (row.id === 'meta.price') {
|
||||
content = (
|
||||
<Text fontSize="$sm" fontWeight="700" color={primary}>
|
||||
{new Intl.NumberFormat(undefined, { style: 'currency', currency: 'EUR' }).format(entry.pkg.price)}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
} else if (row.type === 'limit') {
|
||||
const value = entry.pkg[row.limitKey] ?? null;
|
||||
content = (
|
||||
<Text fontSize="$sm" fontWeight="600" color={textStrong}>
|
||||
{formatLimitValue(value)}
|
||||
</Text>
|
||||
);
|
||||
} else if (row.type === 'feature') {
|
||||
const enabled = getEnabledPackageFeatures(entry.pkg).includes(row.featureKey);
|
||||
content = (
|
||||
<XStack alignItems="center" space="$1.5">
|
||||
{enabled ? (
|
||||
<Check size={16} color={primary} />
|
||||
) : (
|
||||
<X size={14} color={muted} />
|
||||
)}
|
||||
<Text fontSize="$sm" color={enabled ? textStrong : muted}>
|
||||
{enabled ? t('shop.compare.values.included', 'Included') : t('shop.compare.values.notIncluded', 'Not included')}
|
||||
</Text>
|
||||
</XStack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<YStack
|
||||
key={`${row.id}-${entry.pkg.id}`}
|
||||
width={columnWidth}
|
||||
paddingVertical="$2"
|
||||
paddingHorizontal="$2"
|
||||
justifyContent="center"
|
||||
backgroundColor={cellBackground}
|
||||
>
|
||||
{content}
|
||||
</YStack>
|
||||
);
|
||||
})}
|
||||
</XStack>
|
||||
))}
|
||||
<XStack paddingTop="$2">
|
||||
<YStack width={labelWidth} />
|
||||
{entries.map((entry) => {
|
||||
const canSelect = canSelectPackage(entry.isUpgrade, entry.isActive);
|
||||
const label = entry.isActive
|
||||
? t('shop.manage', 'Manage Plan')
|
||||
: entry.isUpgrade
|
||||
? t('shop.select', 'Select')
|
||||
: t('shop.selectDisabled', 'Not available');
|
||||
|
||||
return (
|
||||
<YStack key={`cta-${entry.pkg.id}`} width={columnWidth} paddingHorizontal="$2">
|
||||
<CTAButton
|
||||
label={label}
|
||||
onPress={canSelect ? () => onSelect(entry.pkg) : undefined}
|
||||
disabled={!canSelect}
|
||||
tone={entry.isActive || entry.isDowngrade ? 'ghost' : 'primary'}
|
||||
/>
|
||||
</YStack>
|
||||
);
|
||||
})}
|
||||
</XStack>
|
||||
</YStack>
|
||||
</XStack>
|
||||
</MobileCard>
|
||||
);
|
||||
}
|
||||
|
||||
function getPackageStatusLabel({
|
||||
t,
|
||||
isActive,
|
||||
owned,
|
||||
}: {
|
||||
t: (key: string, fallback?: string, options?: Record<string, unknown>) => string;
|
||||
isActive?: boolean;
|
||||
owned?: TenantPackageSummary;
|
||||
}): string | null {
|
||||
if (isActive) {
|
||||
return t('shop.status.active', 'Active Plan');
|
||||
}
|
||||
if (owned) {
|
||||
return owned.remaining_events !== null
|
||||
? t('shop.status.remaining', '{{count}} Events left', { count: owned.remaining_events })
|
||||
: t('shop.status.owned', 'Purchased');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function canSelectPackage(isUpgrade?: boolean, isActive?: boolean): boolean {
|
||||
return Boolean(isActive || isUpgrade);
|
||||
}
|
||||
|
||||
function CheckoutConfirmation({ pkg, onCancel }: { pkg: Package; onCancel: () => void }) {
|
||||
const { t } = useTranslation('management');
|
||||
const { textStrong, muted, border, primary, danger } = useAdminTheme();
|
||||
const { textStrong, muted, border, primary } = useAdminTheme();
|
||||
const [agbAccepted, setAgbAccepted] = React.useState(false);
|
||||
const [withdrawalAccepted, setWithdrawalAccepted] = React.useState(false);
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const { busy, startCheckout } = usePackageCheckout();
|
||||
|
||||
const canProceed = agbAccepted && withdrawalAccepted;
|
||||
|
||||
const handleCheckout = async () => {
|
||||
if (!canProceed || busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const { checkout_url } = await createTenantPaddleCheckout(pkg.id, {
|
||||
success_url: window.location.href,
|
||||
return_url: window.location.href,
|
||||
});
|
||||
window.location.href = checkout_url;
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('shop.errors.checkout', 'Checkout failed')));
|
||||
setBusy(false);
|
||||
}
|
||||
await startCheckout(pkg.id);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -19,6 +19,7 @@ vi.mock('../../api', () => ({
|
||||
getEvent: vi.fn(),
|
||||
updateEvent: vi.fn(),
|
||||
getEventTypes: vi.fn().mockResolvedValue([]),
|
||||
getPackages: vi.fn().mockResolvedValue([]),
|
||||
trackOnboarding: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -81,6 +82,10 @@ vi.mock('../theme', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../auth/context', () => ({
|
||||
useAuth: () => ({ user: { role: 'tenant_admin' } }),
|
||||
}));
|
||||
|
||||
import { getEventTypes } from '../../api';
|
||||
import MobileEventFormPage from '../EventFormPage';
|
||||
|
||||
|
||||
33
resources/js/admin/mobile/__tests__/analytics.test.ts
Normal file
33
resources/js/admin/mobile/__tests__/analytics.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { resolveMaxCount, resolveTimelineHours } from '../lib/analytics';
|
||||
|
||||
describe('resolveMaxCount', () => {
|
||||
it('defaults to 1 for empty input', () => {
|
||||
expect(resolveMaxCount([])).toBe(1);
|
||||
});
|
||||
|
||||
it('returns the highest count', () => {
|
||||
expect(resolveMaxCount([2, 5, 3])).toBe(5);
|
||||
});
|
||||
|
||||
it('never returns less than 1', () => {
|
||||
expect(resolveMaxCount([0])).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveTimelineHours', () => {
|
||||
it('uses fallback when data is missing', () => {
|
||||
expect(resolveTimelineHours([], 12)).toBe(12);
|
||||
});
|
||||
|
||||
it('calculates rounded hours from timestamps', () => {
|
||||
const start = new Date('2024-01-01T10:00:00Z').toISOString();
|
||||
const end = new Date('2024-01-01T21:00:00Z').toISOString();
|
||||
expect(resolveTimelineHours([start, end], 12)).toBe(11);
|
||||
});
|
||||
|
||||
it('never returns less than 1', () => {
|
||||
const start = new Date('2024-01-01T10:00:00Z').toISOString();
|
||||
expect(resolveTimelineHours([start, start], 12)).toBe(1);
|
||||
});
|
||||
});
|
||||
42
resources/js/admin/mobile/__tests__/billingCheckout.test.ts
Normal file
42
resources/js/admin/mobile/__tests__/billingCheckout.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
CHECKOUT_STORAGE_KEY,
|
||||
PENDING_CHECKOUT_TTL_MS,
|
||||
isCheckoutExpired,
|
||||
loadPendingCheckout,
|
||||
shouldClearPendingCheckout,
|
||||
storePendingCheckout,
|
||||
} from '../lib/billingCheckout';
|
||||
|
||||
describe('billingCheckout helpers', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
});
|
||||
it('detects expired pending checkout', () => {
|
||||
const pending = { packageId: 12, startedAt: 0 };
|
||||
expect(isCheckoutExpired(pending, PENDING_CHECKOUT_TTL_MS + 1)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps pending checkout when active package differs', () => {
|
||||
const pending = { packageId: 12, startedAt: Date.now() };
|
||||
expect(shouldClearPendingCheckout(pending, 18, pending.startedAt)).toBe(false);
|
||||
});
|
||||
|
||||
it('clears pending checkout when active package matches', () => {
|
||||
const now = Date.now();
|
||||
const pending = { packageId: 12, startedAt: now };
|
||||
expect(shouldClearPendingCheckout(pending, 12, now)).toBe(true);
|
||||
});
|
||||
|
||||
it('stores and loads pending checkout from session storage', () => {
|
||||
const pending = { packageId: 7, checkoutSessionId: 'sess_123', startedAt: Date.now() };
|
||||
storePendingCheckout(pending);
|
||||
expect(loadPendingCheckout(pending.startedAt)).toEqual(pending);
|
||||
});
|
||||
|
||||
it('clears pending checkout storage', () => {
|
||||
storePendingCheckout({ packageId: 7, checkoutSessionId: 'sess_123', startedAt: Date.now() });
|
||||
storePendingCheckout(null);
|
||||
expect(sessionStorage.getItem(CHECKOUT_STORAGE_KEY)).toBeNull();
|
||||
});
|
||||
});
|
||||
83
resources/js/admin/mobile/__tests__/packageShop.test.ts
Normal file
83
resources/js/admin/mobile/__tests__/packageShop.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildPackageComparisonRows,
|
||||
classifyPackageChange,
|
||||
getEnabledPackageFeatures,
|
||||
selectRecommendedPackageId,
|
||||
} from '../lib/packageShop';
|
||||
|
||||
describe('classifyPackageChange', () => {
|
||||
const active = {
|
||||
id: 1,
|
||||
price: 200,
|
||||
max_photos: 100,
|
||||
max_guests: 50,
|
||||
gallery_days: 30,
|
||||
features: { advanced_analytics: false },
|
||||
} as any;
|
||||
|
||||
it('returns neutral when no active package', () => {
|
||||
expect(classifyPackageChange(active, null)).toEqual({ isUpgrade: false, isDowngrade: false });
|
||||
});
|
||||
|
||||
it('marks upgrade when candidate adds features', () => {
|
||||
const candidate = { ...active, id: 2, price: 150, features: { advanced_analytics: true } } as any;
|
||||
expect(classifyPackageChange(candidate, active)).toEqual({ isUpgrade: true, isDowngrade: false });
|
||||
});
|
||||
|
||||
it('marks downgrade when candidate removes features or limits', () => {
|
||||
const candidate = { ...active, id: 3, max_photos: 50, features: { advanced_analytics: false } } as any;
|
||||
expect(classifyPackageChange(candidate, active)).toEqual({ isUpgrade: false, isDowngrade: true });
|
||||
});
|
||||
|
||||
it('treats mixed changes as downgrade', () => {
|
||||
const candidate = { ...active, id: 4, max_photos: 200, gallery_days: 10, features: { advanced_analytics: false } } as any;
|
||||
expect(classifyPackageChange(candidate, active)).toEqual({ isUpgrade: false, isDowngrade: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectRecommendedPackageId', () => {
|
||||
const packages = [
|
||||
{ id: 1, price: 100, features: { advanced_analytics: false } },
|
||||
{ id: 2, price: 150, features: { advanced_analytics: true } },
|
||||
{ id: 3, price: 200, features: { advanced_analytics: true } },
|
||||
] as any;
|
||||
|
||||
it('returns null when no feature is requested', () => {
|
||||
expect(selectRecommendedPackageId(packages, null, 100)).toBeNull();
|
||||
});
|
||||
|
||||
it('selects the cheapest upgrade with the feature', () => {
|
||||
const active = { id: 10, price: 120, max_photos: 100, max_guests: 50, gallery_days: 30, features: {} } as any;
|
||||
expect(selectRecommendedPackageId(packages, 'advanced_analytics', active)).toBe(2);
|
||||
});
|
||||
|
||||
it('falls back to cheapest feature package if no upgrades exist', () => {
|
||||
const active = { id: 10, price: 250, max_photos: 999, max_guests: 999, gallery_days: 365, features: { advanced_analytics: true } } as any;
|
||||
expect(selectRecommendedPackageId(packages, 'advanced_analytics', active)).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildPackageComparisonRows', () => {
|
||||
it('includes limit rows and enabled feature rows', () => {
|
||||
const rows = buildPackageComparisonRows([
|
||||
{ features: { advanced_analytics: true, custom_branding: false } },
|
||||
{ features: { custom_branding: true, watermark_removal: true } },
|
||||
] as any);
|
||||
|
||||
expect(rows.map((row) => row.id)).toEqual([
|
||||
'limit.max_photos',
|
||||
'limit.max_guests',
|
||||
'limit.gallery_days',
|
||||
'feature.advanced_analytics',
|
||||
'feature.custom_branding',
|
||||
'feature.watermark_removal',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEnabledPackageFeatures', () => {
|
||||
it('accepts array payloads', () => {
|
||||
expect(getEnabledPackageFeatures({ features: ['custom_branding', ''] } as any)).toEqual(['custom_branding']);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { Suspense } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { ChevronDown, ChevronLeft, Bell, QrCode } from 'lucide-react';
|
||||
import { ChevronLeft, Bell, QrCode } from 'lucide-react';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||
@@ -9,11 +9,10 @@ import { useEventContext } from '../../context/EventContext';
|
||||
import { BottomNav, NavKey } from './BottomNav';
|
||||
import { useMobileNav } from '../hooks/useMobileNav';
|
||||
import { adminPath } from '../../constants';
|
||||
import { MobileSheet } from './Sheet';
|
||||
import { MobileCard, PillBadge, CTAButton } from './Primitives';
|
||||
import { MobileCard, CTAButton } from './Primitives';
|
||||
import { useNotificationsBadge } from '../hooks/useNotificationsBadge';
|
||||
import { useOnlineStatus } from '../hooks/useOnlineStatus';
|
||||
import { formatEventDate, resolveEventDisplayName } from '../../lib/events';
|
||||
import { resolveEventDisplayName } from '../../lib/events';
|
||||
import { TenantEvent, getEvents } from '../../api';
|
||||
import { withAlpha } from './colors';
|
||||
import { setTabHistory } from '../lib/tabHistory';
|
||||
@@ -31,11 +30,11 @@ type MobileShellProps = {
|
||||
};
|
||||
|
||||
export function MobileShell({ title, subtitle, children, activeTab, onBack, headerActions }: MobileShellProps) {
|
||||
const { events, activeEvent, hasMultipleEvents, hasEvents, selectEvent } = useEventContext();
|
||||
const { events, activeEvent, selectEvent } = useEventContext();
|
||||
const { go } = useMobileNav(activeEvent?.slug, activeTab);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { t, i18n } = useTranslation('mobile');
|
||||
const { t } = useTranslation('mobile');
|
||||
const { count: notificationCount } = useNotificationsBadge();
|
||||
const online = useOnlineStatus();
|
||||
const { background, surface, border, text, muted, warningBg, warningText, primary, danger, shadow } = useAdminTheme();
|
||||
@@ -45,16 +44,13 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
|
||||
const textColor = text;
|
||||
const mutedText = muted;
|
||||
const headerSurface = withAlpha(surfaceColor, 0.94);
|
||||
const [pickerOpen, setPickerOpen] = React.useState(false);
|
||||
const [fallbackEvents, setFallbackEvents] = React.useState<TenantEvent[]>([]);
|
||||
const [loadingEvents, setLoadingEvents] = React.useState(false);
|
||||
const [attemptedFetch, setAttemptedFetch] = React.useState(false);
|
||||
const [queuedPhotoCount, setQueuedPhotoCount] = React.useState(0);
|
||||
const [isCompactHeader, setIsCompactHeader] = React.useState(false);
|
||||
|
||||
const locale = i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE';
|
||||
const effectiveEvents = events.length ? events : fallbackEvents;
|
||||
const effectiveHasMultiple = hasMultipleEvents || effectiveEvents.length > 1;
|
||||
const effectiveHasEvents = hasEvents || effectiveEvents.length > 0;
|
||||
const effectiveActive = activeEvent ?? (effectiveEvents.length === 1 ? effectiveEvents[0] : null);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -74,16 +70,6 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
|
||||
.finally(() => setLoadingEvents(false));
|
||||
}, [events.length, loadingEvents, attemptedFetch, activeEvent, selectEvent]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!pickerOpen) return;
|
||||
if (effectiveEvents.length) return;
|
||||
setLoadingEvents(true);
|
||||
getEvents({ force: true })
|
||||
.then((list) => setFallbackEvents(list ?? []))
|
||||
.catch(() => setFallbackEvents([]))
|
||||
.finally(() => setLoadingEvents(false));
|
||||
}, [pickerOpen, effectiveEvents.length]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const path = `${location.pathname}${location.search}${location.hash}`;
|
||||
|
||||
@@ -114,17 +100,104 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
|
||||
};
|
||||
}, [refreshQueuedActions]);
|
||||
|
||||
const eventTitle = title ?? (effectiveActive ? resolveEventDisplayName(effectiveActive) : t('header.appName', 'Event Admin'));
|
||||
const subtitleText =
|
||||
subtitle ??
|
||||
(effectiveActive?.event_date
|
||||
? formatEventDate(effectiveActive.event_date, locale) ?? ''
|
||||
: effectiveHasEvents
|
||||
? t('header.selectEvent', 'Select an event to continue')
|
||||
: t('header.empty', 'Create your first event to get started'));
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined' || !window.matchMedia) {
|
||||
return;
|
||||
}
|
||||
const query = window.matchMedia('(max-width: 520px)');
|
||||
const handleChange = (event: MediaQueryListEvent) => {
|
||||
setIsCompactHeader(event.matches);
|
||||
};
|
||||
setIsCompactHeader(query.matches);
|
||||
query.addEventListener?.('change', handleChange);
|
||||
return () => {
|
||||
query.removeEventListener?.('change', handleChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const showEventSwitcher = effectiveHasMultiple;
|
||||
const pageTitle = title ?? t('header.appName', 'Event Admin');
|
||||
const eventContext = !isCompactHeader && effectiveActive ? resolveEventDisplayName(effectiveActive) : null;
|
||||
const subtitleText = subtitle ?? eventContext ?? '';
|
||||
const showQr = Boolean(effectiveActive?.slug);
|
||||
const headerBackButton = onBack ? (
|
||||
<HeaderActionButton onPress={onBack} ariaLabel={t('actions.back', 'Back')}>
|
||||
<XStack alignItems="center" space="$1.5">
|
||||
<ChevronLeft size={28} color={primary} strokeWidth={2.5} />
|
||||
</XStack>
|
||||
</HeaderActionButton>
|
||||
) : (
|
||||
<XStack width={28} />
|
||||
);
|
||||
const headerTitle = (
|
||||
<XStack alignItems="center" space="$1" flex={1} minWidth={0} justifyContent="flex-end">
|
||||
<YStack alignItems="flex-end" maxWidth="100%">
|
||||
<Text fontSize="$lg" fontWeight="800" fontFamily="$display" color={textColor} textAlign="right" numberOfLines={1}>
|
||||
{pageTitle}
|
||||
</Text>
|
||||
{subtitleText ? (
|
||||
<Text fontSize="$xs" color={mutedText} textAlign="right" numberOfLines={1} fontFamily="$body">
|
||||
{subtitleText}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
</XStack>
|
||||
);
|
||||
const headerActionsRow = (
|
||||
<XStack alignItems="center" space="$2">
|
||||
<HeaderActionButton
|
||||
onPress={() => navigate(adminPath('/mobile/notifications'))}
|
||||
ariaLabel={t('mobile.notifications', 'Notifications')}
|
||||
>
|
||||
<XStack
|
||||
width={34}
|
||||
height={34}
|
||||
borderRadius={12}
|
||||
backgroundColor={surfaceColor}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
position="relative"
|
||||
>
|
||||
<Bell size={16} color={textColor} />
|
||||
{notificationCount > 0 ? (
|
||||
<YStack
|
||||
position="absolute"
|
||||
top={-4}
|
||||
right={-4}
|
||||
minWidth={18}
|
||||
height={18}
|
||||
paddingHorizontal={6}
|
||||
borderRadius={999}
|
||||
backgroundColor={danger}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<Text fontSize={10} color="white" fontWeight="700">
|
||||
{notificationCount > 9 ? '9+' : notificationCount}
|
||||
</Text>
|
||||
</YStack>
|
||||
) : null}
|
||||
</XStack>
|
||||
</HeaderActionButton>
|
||||
{showQr ? (
|
||||
<HeaderActionButton
|
||||
onPress={() => navigate(adminPath(`/mobile/events/${effectiveActive?.slug}/qr`))}
|
||||
ariaLabel={t('header.quickQr', 'Quick QR')}
|
||||
>
|
||||
<XStack
|
||||
width={34}
|
||||
height={34}
|
||||
borderRadius={12}
|
||||
backgroundColor={primary}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<QrCode size={16} color="white" />
|
||||
</XStack>
|
||||
</HeaderActionButton>
|
||||
) : null}
|
||||
{headerActions ?? null}
|
||||
</XStack>
|
||||
);
|
||||
|
||||
return (
|
||||
<YStack backgroundColor={backgroundColor} minHeight="100vh" alignItems="center">
|
||||
@@ -150,96 +223,27 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
|
||||
WebkitBackdropFilter: 'blur(12px)',
|
||||
}}
|
||||
>
|
||||
<XStack alignItems="center" justifyContent="space-between" minHeight={48} space="$3">
|
||||
{onBack ? (
|
||||
<HeaderActionButton onPress={onBack} ariaLabel={t('actions.back', 'Back')}>
|
||||
<XStack alignItems="center" space="$1.5">
|
||||
<ChevronLeft size={28} color={primary} strokeWidth={2.5} />
|
||||
{isCompactHeader ? (
|
||||
<YStack space="$2">
|
||||
<XStack alignItems="center" justifyContent="space-between" minHeight={48} space="$3">
|
||||
{headerBackButton}
|
||||
<XStack flex={1} minWidth={0} justifyContent="flex-end">
|
||||
{headerTitle}
|
||||
</XStack>
|
||||
</HeaderActionButton>
|
||||
) : (
|
||||
<XStack width={28} />
|
||||
)}
|
||||
|
||||
<XStack alignItems="center" space="$2.5" flex={1} justifyContent="flex-end">
|
||||
<XStack alignItems="center" space="$1" maxWidth="55%">
|
||||
<Pressable
|
||||
disabled={!showEventSwitcher}
|
||||
onPress={() => setPickerOpen(true)}
|
||||
style={{ alignItems: 'flex-end' }}
|
||||
>
|
||||
<Text fontSize="$lg" fontWeight="800" fontFamily="$display" color={textColor} textAlign="right" numberOfLines={1}>
|
||||
{eventTitle}
|
||||
</Text>
|
||||
{subtitleText ? (
|
||||
<Text fontSize="$xs" color={mutedText} textAlign="right" numberOfLines={1} fontFamily="$body">
|
||||
{subtitleText}
|
||||
</Text>
|
||||
) : null}
|
||||
</Pressable>
|
||||
{showEventSwitcher ? <ChevronDown size={14} color={textColor} /> : null}
|
||||
</XStack>
|
||||
|
||||
<XStack alignItems="center" space="$2">
|
||||
<HeaderActionButton
|
||||
onPress={() => navigate(adminPath('/mobile/notifications'))}
|
||||
ariaLabel={t('mobile.notifications', 'Notifications')}
|
||||
>
|
||||
<XStack
|
||||
width={34}
|
||||
height={34}
|
||||
borderRadius={12}
|
||||
backgroundColor={surfaceColor}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
position="relative"
|
||||
>
|
||||
<Bell size={16} color={textColor} />
|
||||
{notificationCount > 0 ? (
|
||||
<YStack
|
||||
position="absolute"
|
||||
top={-4}
|
||||
right={-4}
|
||||
minWidth={18}
|
||||
height={18}
|
||||
paddingHorizontal={6}
|
||||
borderRadius={999}
|
||||
backgroundColor={danger}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<Text fontSize={10} color="white" fontWeight="700">
|
||||
{notificationCount > 9 ? '9+' : notificationCount}
|
||||
</Text>
|
||||
</YStack>
|
||||
) : null}
|
||||
</XStack>
|
||||
</HeaderActionButton>
|
||||
{showQr ? (
|
||||
<HeaderActionButton
|
||||
onPress={() => navigate(adminPath(`/mobile/events/${effectiveActive?.slug}/qr`))}
|
||||
ariaLabel={t('header.quickQr', 'Quick QR')}
|
||||
>
|
||||
<XStack
|
||||
height={34}
|
||||
paddingHorizontal="$3"
|
||||
borderRadius={12}
|
||||
backgroundColor={primary}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
space="$1.5"
|
||||
>
|
||||
<QrCode size={16} color="white" />
|
||||
<Text fontSize="$xs" fontWeight="800" color="white">
|
||||
{t('header.quickQr', 'Quick QR')}
|
||||
</Text>
|
||||
</XStack>
|
||||
</HeaderActionButton>
|
||||
) : null}
|
||||
{headerActions ?? null}
|
||||
<XStack alignItems="center" justifyContent="flex-end">
|
||||
{headerActionsRow}
|
||||
</XStack>
|
||||
</YStack>
|
||||
) : (
|
||||
<XStack alignItems="center" justifyContent="space-between" minHeight={48} space="$3">
|
||||
{headerBackButton}
|
||||
<XStack alignItems="center" space="$2.5" flex={1} justifyContent="flex-end" minWidth={0}>
|
||||
{headerTitle}
|
||||
{headerActionsRow}
|
||||
</XStack>
|
||||
</XStack>
|
||||
</XStack>
|
||||
)}
|
||||
</YStack>
|
||||
|
||||
<YStack
|
||||
@@ -290,75 +294,6 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
|
||||
|
||||
<BottomNav active={activeTab} onNavigate={go} />
|
||||
|
||||
<MobileSheet
|
||||
open={pickerOpen}
|
||||
onClose={() => setPickerOpen(false)}
|
||||
title={t('header.eventSwitcher', 'Choose an event')}
|
||||
footer={null}
|
||||
bottomOffsetPx={110}
|
||||
>
|
||||
<YStack space="$2">
|
||||
{effectiveEvents.length === 0 ? (
|
||||
<MobileCard alignItems="flex-start" space="$2">
|
||||
<Text fontSize="$sm" color={textColor} fontWeight="700">
|
||||
{t('header.noEventsTitle', 'Create your first event')}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color={mutedText}>
|
||||
{t('header.noEventsBody', 'Start an event to access tasks, uploads, QR posters and more.')}
|
||||
</Text>
|
||||
<Pressable onPress={() => navigate(adminPath('/mobile/events/new'))}>
|
||||
<XStack alignItems="center" space="$2">
|
||||
<Text fontSize="$sm" color={primary} fontWeight="700">
|
||||
{t('header.createEvent', 'Create event')}
|
||||
</Text>
|
||||
</XStack>
|
||||
</Pressable>
|
||||
</MobileCard>
|
||||
) : (
|
||||
effectiveEvents.map((event) => (
|
||||
<Pressable
|
||||
key={event.slug}
|
||||
onPress={() => {
|
||||
const targetSlug = event.slug ?? null;
|
||||
selectEvent(targetSlug);
|
||||
setPickerOpen(false);
|
||||
if (targetSlug) {
|
||||
navigate(adminPath(`/mobile/events/${targetSlug}`));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<XStack alignItems="center" justifyContent="space-between" paddingVertical="$2">
|
||||
<YStack space="$0.5">
|
||||
<Text fontSize="$sm" fontWeight="700" color={textColor}>
|
||||
{resolveEventDisplayName(event)}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color={mutedText}>
|
||||
{formatEventDate(event.event_date, locale) ?? t('header.noDate', 'Date tbd')}
|
||||
</Text>
|
||||
</YStack>
|
||||
<PillBadge tone={event.slug === activeEvent?.slug ? 'success' : 'muted'}>
|
||||
{event.slug === activeEvent?.slug
|
||||
? t('header.active', 'Active')
|
||||
: (event.status ?? '—')}
|
||||
</PillBadge>
|
||||
</XStack>
|
||||
</Pressable>
|
||||
))
|
||||
)}
|
||||
{activeEvent ? (
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
selectEvent(null);
|
||||
setPickerOpen(false);
|
||||
}}
|
||||
>
|
||||
<Text fontSize="$xs" color={mutedText} textAlign="center">
|
||||
{t('header.clearSelection', 'Clear selection')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</YStack>
|
||||
</MobileSheet>
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ describe('MobileSheet', () => {
|
||||
const onClose = vi.fn();
|
||||
|
||||
render(
|
||||
<MobileSheet open title="Test Sheet" onClose={onClose}>
|
||||
<MobileSheet open title="Test Sheet" onClose={onClose} snapPoints={[94]} contentSpacing="$2" padding="$3" paddingBottom="$6">
|
||||
<div>Body</div>
|
||||
</MobileSheet>,
|
||||
);
|
||||
|
||||
@@ -12,11 +12,26 @@ type SheetProps = {
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
snapPoints?: number[];
|
||||
contentSpacing?: string;
|
||||
padding?: string;
|
||||
paddingBottom?: string;
|
||||
/** Optional bottom offset so content sits above the bottom nav/safe-area. */
|
||||
bottomOffsetPx?: number;
|
||||
};
|
||||
|
||||
export function MobileSheet({ open, title, onClose, children, footer, bottomOffsetPx = 88 }: SheetProps) {
|
||||
export function MobileSheet({
|
||||
open,
|
||||
title,
|
||||
onClose,
|
||||
children,
|
||||
footer,
|
||||
snapPoints = [82],
|
||||
contentSpacing = '$3',
|
||||
padding = '$4',
|
||||
paddingBottom = '$7',
|
||||
bottomOffsetPx = 88,
|
||||
}: SheetProps) {
|
||||
const { t } = useTranslation('mobile');
|
||||
const { surface, textStrong, muted, overlay, shadow, border } = useAdminTheme();
|
||||
const bottomOffset = `max(env(safe-area-inset-bottom, 0px), ${bottomOffsetPx}px)`;
|
||||
@@ -33,7 +48,7 @@ export function MobileSheet({ open, title, onClose, children, footer, bottomOffs
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
snapPoints={[82]}
|
||||
snapPoints={snapPoints}
|
||||
snapPointsMode="percent"
|
||||
dismissOnOverlayPress
|
||||
dismissOnSnapToBottom
|
||||
@@ -48,8 +63,8 @@ export function MobileSheet({ open, title, onClose, children, footer, bottomOffs
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
backgroundColor: surface,
|
||||
padding: '$4',
|
||||
paddingBottom: '$7',
|
||||
padding,
|
||||
paddingBottom,
|
||||
shadowColor: shadow,
|
||||
shadowOpacity: 0.12,
|
||||
shadowRadius: 18,
|
||||
@@ -62,7 +77,7 @@ export function MobileSheet({ open, title, onClose, children, footer, bottomOffs
|
||||
showsVerticalScrollIndicator={false}
|
||||
{...({ contentContainerStyle: { paddingBottom: 6 } } as any)}
|
||||
>
|
||||
<YStack space="$3">
|
||||
<YStack space={contentSpacing}>
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||
{title}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import React from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string, fallback?: string) => fallback ?? key, i18n: { language: 'en-GB' } }),
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/core', () => ({
|
||||
useTheme: () => ({
|
||||
background: { val: '#FFF8F5' },
|
||||
surface: { val: '#ffffff' },
|
||||
borderColor: { val: '#e5e7eb' },
|
||||
color: { val: '#1f2937' },
|
||||
gray: { val: '#6b7280' },
|
||||
red10: { val: '#b91c1c' },
|
||||
shadowColor: { val: 'rgba(0,0,0,0.12)' },
|
||||
primary: { val: '#FF5A5F' },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/stacks', () => ({
|
||||
YStack: ({ children, ...props }: { children: React.ReactNode }) => <div {...props}>{children}</div>,
|
||||
XStack: ({ children, ...props }: { children: React.ReactNode }) => <div {...props}>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/text', () => ({
|
||||
SizableText: ({ children, ...props }: { children: React.ReactNode }) => <span {...props}>{children}</span>,
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/react-native-web-lite', () => ({
|
||||
Pressable: ({ children, onPress, ...props }: { children: React.ReactNode; onPress?: () => void }) => (
|
||||
<button type="button" onClick={onPress} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../BottomNav', () => ({
|
||||
BottomNav: () => <div data-testid="bottom-nav" />,
|
||||
NavKey: {},
|
||||
}));
|
||||
|
||||
vi.mock('../../../context/EventContext', () => ({
|
||||
useEventContext: () => ({
|
||||
events: [],
|
||||
activeEvent: { slug: 'event-1', name: 'Test Event', event_date: '2024-01-01', status: 'active', settings: {} },
|
||||
hasMultipleEvents: false,
|
||||
hasEvents: true,
|
||||
selectEvent: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useMobileNav', () => ({
|
||||
useMobileNav: () => ({ go: vi.fn(), slug: 'event-1' }),
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useNotificationsBadge', () => ({
|
||||
useNotificationsBadge: () => ({ count: 0 }),
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useOnlineStatus', () => ({
|
||||
useOnlineStatus: () => true,
|
||||
}));
|
||||
|
||||
vi.mock('../../../api', () => ({
|
||||
getEvents: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/tabHistory', () => ({
|
||||
setTabHistory: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/photoModerationQueue', () => ({
|
||||
loadPhotoQueue: vi.fn(() => []),
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/queueStatus', () => ({
|
||||
countQueuedPhotoActions: vi.fn(() => 0),
|
||||
}));
|
||||
|
||||
vi.mock('../../theme', () => ({
|
||||
useAdminTheme: () => ({
|
||||
background: '#FFF8F5',
|
||||
surface: '#ffffff',
|
||||
border: '#e5e7eb',
|
||||
text: '#1f2937',
|
||||
muted: '#6b7280',
|
||||
warningBg: '#fff7ed',
|
||||
warningText: '#92400e',
|
||||
primary: '#FF5A5F',
|
||||
danger: '#b91c1c',
|
||||
shadow: 'rgba(0,0,0,0.12)',
|
||||
}),
|
||||
}));
|
||||
|
||||
import { MobileShell } from '../MobileShell';
|
||||
|
||||
describe('MobileShell', () => {
|
||||
beforeEach(() => {
|
||||
window.matchMedia = vi.fn().mockReturnValue({
|
||||
matches: false,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
it('renders quick QR as icon-only button', async () => {
|
||||
await act(async () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<MobileShell activeTab="home">
|
||||
<div>Body</div>
|
||||
</MobileShell>
|
||||
</MemoryRouter>
|
||||
);
|
||||
});
|
||||
|
||||
expect(screen.getByLabelText('Quick QR')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Quick QR')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the event context on compact headers', async () => {
|
||||
window.matchMedia = vi.fn().mockReturnValue({
|
||||
matches: true,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<MobileShell activeTab="home">
|
||||
<div>Body</div>
|
||||
</MobileShell>
|
||||
</MemoryRouter>
|
||||
);
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Test Event')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
59
resources/js/admin/mobile/hooks/usePackageCheckout.ts
Normal file
59
resources/js/admin/mobile/hooks/usePackageCheckout.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
import { createTenantPaddleCheckout } from '../../api';
|
||||
import { adminPath } from '../../constants';
|
||||
import { getApiErrorMessage } from '../../lib/apiError';
|
||||
import { storePendingCheckout } from '../lib/billingCheckout';
|
||||
|
||||
export function usePackageCheckout(): {
|
||||
busy: boolean;
|
||||
startCheckout: (packageId: number) => Promise<void>;
|
||||
} {
|
||||
const { t } = useTranslation('management');
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
|
||||
const startCheckout = React.useCallback(
|
||||
async (packageId: number) => {
|
||||
if (busy) {
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
if (typeof window === 'undefined') {
|
||||
throw new Error('Checkout is only available in the browser.');
|
||||
}
|
||||
|
||||
const billingUrl = new URL(adminPath('/mobile/billing'), window.location.origin);
|
||||
const successUrl = new URL(billingUrl);
|
||||
successUrl.searchParams.set('checkout', 'success');
|
||||
successUrl.searchParams.set('package_id', String(packageId));
|
||||
const cancelUrl = new URL(billingUrl);
|
||||
cancelUrl.searchParams.set('checkout', 'cancel');
|
||||
cancelUrl.searchParams.set('package_id', String(packageId));
|
||||
|
||||
const { checkout_url, checkout_session_id } = await createTenantPaddleCheckout(packageId, {
|
||||
success_url: successUrl.toString(),
|
||||
return_url: cancelUrl.toString(),
|
||||
});
|
||||
|
||||
if (checkout_session_id) {
|
||||
storePendingCheckout({
|
||||
packageId,
|
||||
checkoutSessionId: checkout_session_id,
|
||||
startedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
window.location.href = checkout_url;
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('shop.errors.checkout', 'Checkout failed')));
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[busy, t],
|
||||
);
|
||||
|
||||
return { busy, startCheckout };
|
||||
}
|
||||
28
resources/js/admin/mobile/lib/analytics.ts
Normal file
28
resources/js/admin/mobile/lib/analytics.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export function resolveMaxCount(values: number[]): number {
|
||||
if (!Array.isArray(values) || values.length === 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return Math.max(...values, 1);
|
||||
}
|
||||
|
||||
export function resolveTimelineHours(timestamps: string[], fallbackHours = 12): number {
|
||||
if (!Array.isArray(timestamps) || timestamps.length < 2) {
|
||||
return fallbackHours;
|
||||
}
|
||||
|
||||
const times = timestamps
|
||||
.map((value) => new Date(value).getTime())
|
||||
.filter((value) => Number.isFinite(value));
|
||||
|
||||
if (times.length < 2) {
|
||||
return fallbackHours;
|
||||
}
|
||||
|
||||
const min = Math.min(...times);
|
||||
const max = Math.max(...times);
|
||||
const diff = Math.max(0, max - min);
|
||||
const hours = diff / (1000 * 60 * 60);
|
||||
|
||||
return Math.max(1, Math.round(hours));
|
||||
}
|
||||
82
resources/js/admin/mobile/lib/billingCheckout.ts
Normal file
82
resources/js/admin/mobile/lib/billingCheckout.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
export type PendingCheckout = {
|
||||
packageId: number | null;
|
||||
checkoutSessionId?: string | null;
|
||||
startedAt: number;
|
||||
};
|
||||
|
||||
export const PENDING_CHECKOUT_TTL_MS = 1000 * 60 * 30;
|
||||
export const CHECKOUT_STORAGE_KEY = 'admin.billing.checkout.pending.v1';
|
||||
|
||||
export function isCheckoutExpired(
|
||||
pending: PendingCheckout,
|
||||
now = Date.now(),
|
||||
ttl = PENDING_CHECKOUT_TTL_MS,
|
||||
): boolean {
|
||||
return now - pending.startedAt > ttl;
|
||||
}
|
||||
|
||||
export function loadPendingCheckout(
|
||||
now = Date.now(),
|
||||
ttl = PENDING_CHECKOUT_TTL_MS,
|
||||
): PendingCheckout | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(CHECKOUT_STORAGE_KEY);
|
||||
if (! raw) {
|
||||
return null;
|
||||
}
|
||||
const parsed = JSON.parse(raw) as PendingCheckout;
|
||||
if (typeof parsed?.startedAt !== 'number') {
|
||||
return null;
|
||||
}
|
||||
const packageId =
|
||||
typeof parsed.packageId === 'number' && Number.isFinite(parsed.packageId)
|
||||
? parsed.packageId
|
||||
: null;
|
||||
const checkoutSessionId = typeof parsed.checkoutSessionId === 'string' ? parsed.checkoutSessionId : null;
|
||||
if (now - parsed.startedAt > ttl) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
packageId,
|
||||
checkoutSessionId,
|
||||
startedAt: parsed.startedAt,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function storePendingCheckout(next: PendingCheckout | null): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (! next) {
|
||||
window.sessionStorage.removeItem(CHECKOUT_STORAGE_KEY);
|
||||
} else {
|
||||
window.sessionStorage.setItem(CHECKOUT_STORAGE_KEY, JSON.stringify(next));
|
||||
}
|
||||
} catch {
|
||||
// Ignore storage errors.
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldClearPendingCheckout(
|
||||
pending: PendingCheckout,
|
||||
activePackageId: number | null,
|
||||
now = Date.now(),
|
||||
ttl = PENDING_CHECKOUT_TTL_MS,
|
||||
): boolean {
|
||||
if (isCheckoutExpired(pending, now, ttl)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pending.packageId && activePackageId && pending.packageId === activePackageId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
146
resources/js/admin/mobile/lib/packageShop.ts
Normal file
146
resources/js/admin/mobile/lib/packageShop.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import type { Package } from '../../api';
|
||||
|
||||
type PackageChange = {
|
||||
isUpgrade: boolean;
|
||||
isDowngrade: boolean;
|
||||
};
|
||||
|
||||
export type PackageComparisonRow =
|
||||
| {
|
||||
id: string;
|
||||
type: 'limit';
|
||||
limitKey: 'max_photos' | 'max_guests' | 'gallery_days';
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
type: 'feature';
|
||||
featureKey: string;
|
||||
};
|
||||
|
||||
function normalizePackageFeatures(pkg: Package | null): string[] {
|
||||
if (!pkg?.features) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (Array.isArray(pkg.features)) {
|
||||
return pkg.features.filter((feature): feature is string => typeof feature === 'string' && feature.trim().length > 0);
|
||||
}
|
||||
|
||||
if (typeof pkg.features === 'object') {
|
||||
return Object.entries(pkg.features)
|
||||
.filter(([, enabled]) => enabled)
|
||||
.map(([key]) => key);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export function getEnabledPackageFeatures(pkg: Package): string[] {
|
||||
return normalizePackageFeatures(pkg);
|
||||
}
|
||||
|
||||
function collectFeatures(pkg: Package | null): Set<string> {
|
||||
return new Set(normalizePackageFeatures(pkg));
|
||||
}
|
||||
|
||||
function compareLimit(candidate: number | null, active: number | null): number {
|
||||
if (active === null) {
|
||||
return candidate === null ? 0 : -1;
|
||||
}
|
||||
|
||||
if (candidate === null) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (candidate > active) return 1;
|
||||
if (candidate < active) return -1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function classifyPackageChange(pkg: Package, active: Package | null): PackageChange {
|
||||
if (!active) {
|
||||
return { isUpgrade: false, isDowngrade: false };
|
||||
}
|
||||
|
||||
const activeFeatures = collectFeatures(active);
|
||||
const candidateFeatures = collectFeatures(pkg);
|
||||
|
||||
const hasFeatureUpgrade = Array.from(candidateFeatures).some((feature) => !activeFeatures.has(feature));
|
||||
const hasFeatureDowngrade = Array.from(activeFeatures).some((feature) => !candidateFeatures.has(feature));
|
||||
|
||||
const limitKeys: Array<keyof Package> = ['max_photos', 'max_guests', 'gallery_days'];
|
||||
let hasLimitUpgrade = false;
|
||||
let hasLimitDowngrade = false;
|
||||
|
||||
limitKeys.forEach((key) => {
|
||||
const candidateLimit = pkg[key] ?? null;
|
||||
const activeLimit = active[key] ?? null;
|
||||
const delta = compareLimit(candidateLimit, activeLimit);
|
||||
if (delta > 0) {
|
||||
hasLimitUpgrade = true;
|
||||
} else if (delta < 0) {
|
||||
hasLimitDowngrade = true;
|
||||
}
|
||||
});
|
||||
|
||||
const hasUpgrade = hasFeatureUpgrade || hasLimitUpgrade;
|
||||
const hasDowngrade = hasFeatureDowngrade || hasLimitDowngrade;
|
||||
|
||||
if (hasUpgrade && !hasDowngrade) {
|
||||
return { isUpgrade: true, isDowngrade: false };
|
||||
}
|
||||
|
||||
if (hasDowngrade) {
|
||||
return { isUpgrade: false, isDowngrade: true };
|
||||
}
|
||||
|
||||
return { isUpgrade: false, isDowngrade: false };
|
||||
}
|
||||
|
||||
export function selectRecommendedPackageId(
|
||||
packages: Package[],
|
||||
feature: string | null,
|
||||
activePackage: Package | null
|
||||
): number | null {
|
||||
if (!feature) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidates = packages.filter((pkg) => normalizePackageFeatures(pkg).includes(feature));
|
||||
if (candidates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const upgrades = candidates.filter((pkg) => classifyPackageChange(pkg, activePackage).isUpgrade);
|
||||
const pool = upgrades.length ? upgrades : candidates;
|
||||
const sorted = [...pool].sort((a, b) => a.price - b.price);
|
||||
|
||||
return sorted[0]?.id ?? null;
|
||||
}
|
||||
|
||||
export function buildPackageComparisonRows(packages: Package[]): PackageComparisonRow[] {
|
||||
const limitRows: PackageComparisonRow[] = [
|
||||
{ id: 'limit.max_photos', type: 'limit', limitKey: 'max_photos' },
|
||||
{ id: 'limit.max_guests', type: 'limit', limitKey: 'max_guests' },
|
||||
{ id: 'limit.gallery_days', type: 'limit', limitKey: 'gallery_days' },
|
||||
];
|
||||
|
||||
const featureKeys = new Set<string>();
|
||||
packages.forEach((pkg) => {
|
||||
normalizePackageFeatures(pkg).forEach((key) => {
|
||||
if (key !== 'photos') {
|
||||
featureKeys.add(key);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const featureRows = Array.from(featureKeys)
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.map((featureKey) => ({
|
||||
id: `feature.${featureKey}`,
|
||||
type: 'feature' as const,
|
||||
featureKey,
|
||||
}));
|
||||
|
||||
return [...limitRows, ...featureRows];
|
||||
}
|
||||
@@ -15,7 +15,8 @@ const t = (key: string, options?: Record<string, unknown> | string) => {
|
||||
return template
|
||||
.replace('{{used}}', String(options?.used ?? '{{used}}'))
|
||||
.replace('{{limit}}', String(options?.limit ?? '{{limit}}'))
|
||||
.replace('{{remaining}}', String(options?.remaining ?? '{{remaining}}'));
|
||||
.replace('{{remaining}}', String(options?.remaining ?? '{{remaining}}'))
|
||||
.replace('{{count}}', String(options?.count ?? '{{count}}'));
|
||||
};
|
||||
|
||||
describe('packageSummary helpers', () => {
|
||||
@@ -53,6 +54,12 @@ describe('packageSummary helpers', () => {
|
||||
expect(result[0].value).toBe('30 of 120 remaining');
|
||||
});
|
||||
|
||||
it('falls back to remaining count when remaining exceeds limit', () => {
|
||||
const result = getPackageLimitEntries({ max_photos: 120, remaining_photos: 180 }, t);
|
||||
|
||||
expect(result[0].value).toBe('Remaining 180');
|
||||
});
|
||||
|
||||
it('formats event usage copy', () => {
|
||||
const result = formatEventUsage(3, 10, t);
|
||||
|
||||
|
||||
@@ -138,6 +138,12 @@ const formatLimitWithRemaining = (limit: number | null, remaining: number | null
|
||||
|
||||
if (remaining !== null && remaining >= 0) {
|
||||
const normalizedRemaining = Number.isFinite(remaining) ? Math.max(0, Math.round(remaining)) : remaining;
|
||||
if (normalizedRemaining > limit) {
|
||||
return t('mobileBilling.usage.remaining', {
|
||||
count: normalizedRemaining,
|
||||
defaultValue: 'Remaining {{count}}',
|
||||
});
|
||||
}
|
||||
return t('mobileBilling.usage.remainingOf', {
|
||||
remaining: normalizedRemaining,
|
||||
limit,
|
||||
|
||||
@@ -12,7 +12,6 @@ export function prefetchMobileRoutes() {
|
||||
schedule(() => {
|
||||
void import('./DashboardPage');
|
||||
void import('./EventsPage');
|
||||
void import('./EventDetailPage');
|
||||
void import('./EventPhotosPage');
|
||||
void import('./EventTasksPage');
|
||||
void import('./NotificationsPage');
|
||||
|
||||
@@ -20,7 +20,6 @@ const AuthCallbackPage = React.lazy(() => import('./mobile/AuthCallbackPage'));
|
||||
const LoginStartPage = React.lazy(() => import('./mobile/LoginStartPage'));
|
||||
const LogoutPage = React.lazy(() => import('./mobile/LogoutPage'));
|
||||
const MobileEventsPage = React.lazy(() => import('./mobile/EventsPage'));
|
||||
const MobileEventDetailPage = React.lazy(() => import('./mobile/EventDetailPage'));
|
||||
const MobileEventPhotoboothPage = React.lazy(() => import('./mobile/EventPhotoboothPage'));
|
||||
const MobileBrandingPage = React.lazy(() => import('./mobile/BrandingPage'));
|
||||
const MobileEventFormPage = React.lazy(() => import('./mobile/EventFormPage'));
|
||||
@@ -195,7 +194,7 @@ export const router = createBrowserRouter([
|
||||
{ path: 'events/:slug/guest-notifications', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}/guest-notifications`} /> },
|
||||
{ path: 'events/:slug/toolkit', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}`} /> },
|
||||
{ path: 'mobile/events', element: <MobileEventsPage /> },
|
||||
{ path: 'mobile/events/:slug', element: <MobileEventDetailPage /> },
|
||||
{ path: 'mobile/events/:slug', element: <MobileDashboardPage /> },
|
||||
{ path: 'mobile/events/:slug/branding', element: <RequireAdminAccess><MobileBrandingPage /></RequireAdminAccess> },
|
||||
{ path: 'mobile/events/new', element: <RequireAdminAccess><MobileEventFormPage /></RequireAdminAccess> },
|
||||
{ path: 'mobile/events/:slug/edit', element: <RequireAdminAccess><MobileEventFormPage /></RequireAdminAccess> },
|
||||
|
||||
@@ -27,7 +27,6 @@ import { SettingsSheet } from './settings-sheet';
|
||||
import { useTranslation, type TranslateFn } from '../i18n/useTranslation';
|
||||
import { DEFAULT_EVENT_BRANDING, useOptionalEventBranding } from '../context/EventBrandingContext';
|
||||
import { useOptionalNotificationCenter, type NotificationCenterValue } from '../context/NotificationCenterContext';
|
||||
import { useGuestTaskProgress, TASK_BADGE_TARGET } from '../hooks/useGuestTaskProgress';
|
||||
import { usePushSubscription } from '../hooks/usePushSubscription';
|
||||
import { getContrastingTextColor, relativeLuminance } from '../lib/color';
|
||||
import { isTaskModeEnabled } from '../lib/engagement';
|
||||
@@ -151,7 +150,6 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
|
||||
const { event, status } = useEventData();
|
||||
const notificationCenter = useOptionalNotificationCenter();
|
||||
const [notificationsOpen, setNotificationsOpen] = React.useState(false);
|
||||
const taskProgress = useGuestTaskProgress(eventToken);
|
||||
const tasksEnabled = isTaskModeEnabled(event);
|
||||
const panelRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const notificationButtonRef = React.useRef<HTMLButtonElement | null>(null);
|
||||
@@ -258,7 +256,6 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
|
||||
onToggle={() => setNotificationsOpen((prev) => !prev)}
|
||||
panelRef={panelRef}
|
||||
buttonRef={notificationButtonRef}
|
||||
taskProgress={tasksEnabled && taskProgress?.hydrated ? taskProgress : undefined}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
@@ -285,18 +282,14 @@ type NotificationButtonProps = {
|
||||
onToggle: () => void;
|
||||
panelRef: React.RefObject<HTMLDivElement | null>;
|
||||
buttonRef: React.RefObject<HTMLButtonElement | null>;
|
||||
taskProgress?: ReturnType<typeof useGuestTaskProgress>;
|
||||
t: TranslateFn;
|
||||
};
|
||||
|
||||
type PushState = ReturnType<typeof usePushSubscription>;
|
||||
|
||||
function NotificationButton({ center, eventToken, open, onToggle, panelRef, buttonRef, taskProgress, t }: NotificationButtonProps) {
|
||||
const badgeCount = center.unreadCount + center.pendingCount + center.queueCount;
|
||||
const progressRatio = taskProgress
|
||||
? Math.min(1, taskProgress.completedCount / TASK_BADGE_TARGET)
|
||||
: 0;
|
||||
const [activeTab, setActiveTab] = React.useState<'unread' | 'all' | 'status'>(center.unreadCount > 0 ? 'unread' : 'all');
|
||||
function NotificationButton({ center, eventToken, open, onToggle, panelRef, buttonRef, t }: NotificationButtonProps) {
|
||||
const badgeCount = center.unreadCount;
|
||||
const [activeTab, setActiveTab] = React.useState<'unread' | 'all' | 'uploads'>(center.unreadCount > 0 ? 'unread' : 'all');
|
||||
const [scopeFilter, setScopeFilter] = React.useState<'all' | 'tips' | 'general'>('all');
|
||||
const pushState = usePushSubscription(eventToken);
|
||||
|
||||
@@ -321,7 +314,7 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, butt
|
||||
case 'unread':
|
||||
base = unreadNotifications;
|
||||
break;
|
||||
case 'status':
|
||||
case 'uploads':
|
||||
base = uploadNotifications;
|
||||
break;
|
||||
default:
|
||||
@@ -331,7 +324,7 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, butt
|
||||
}, [activeTab, center.notifications, unreadNotifications, uploadNotifications]);
|
||||
|
||||
const scopedNotifications = React.useMemo(() => {
|
||||
if (scopeFilter === 'all') {
|
||||
if (activeTab === 'uploads' || scopeFilter === 'all') {
|
||||
return filteredNotifications;
|
||||
}
|
||||
return filteredNotifications.filter((item) => {
|
||||
@@ -365,10 +358,10 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, butt
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-900">{t('header.notifications.title', 'Benachrichtigungen')}</p>
|
||||
<p className="text-sm font-semibold text-slate-900">{t('header.notifications.title', 'Updates')}</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{center.unreadCount > 0
|
||||
? t('header.notifications.unread', { defaultValue: '{{count}} neu', count: center.unreadCount })
|
||||
? t('header.notifications.unread', { defaultValue: '{count} neu', count: center.unreadCount })
|
||||
: t('header.notifications.allRead', 'Alles gelesen')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -384,67 +377,43 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, butt
|
||||
</div>
|
||||
<NotificationTabs
|
||||
tabs={[
|
||||
{ key: 'unread', label: t('header.notifications.tabUnread', 'Neu'), badge: unreadNotifications.length },
|
||||
{ key: 'status', label: t('header.notifications.tabStatus', 'Uploads/Status'), badge: uploadNotifications.length },
|
||||
{ key: 'all', label: t('header.notifications.tabAll', 'Alle'), badge: center.notifications.length },
|
||||
{ key: 'unread', label: t('header.notifications.tabUnread', 'Nachrichten'), badge: unreadNotifications.length },
|
||||
{ key: 'uploads', label: t('header.notifications.tabUploads', 'Uploads'), badge: uploadNotifications.length },
|
||||
{ key: 'all', label: t('header.notifications.tabAll', 'Alle Updates'), badge: center.notifications.length },
|
||||
]}
|
||||
activeTab={activeTab}
|
||||
onTabChange={(next) => setActiveTab(next as typeof activeTab)}
|
||||
/>
|
||||
<div className="mt-3">
|
||||
<div className="flex gap-2 overflow-x-auto text-xs whitespace-nowrap pb-1">
|
||||
{(
|
||||
[
|
||||
{ key: 'all', label: t('header.notifications.scope.all', 'Alle') },
|
||||
{ key: 'tips', label: t('header.notifications.scope.tips', 'Tipps & Achievements') },
|
||||
{ key: 'general', label: t('header.notifications.scope.general', 'Allgemein') },
|
||||
] as const
|
||||
).map((option) => (
|
||||
<button
|
||||
key={option.key}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setScopeFilter(option.key);
|
||||
center.setFilters({ scope: option.key });
|
||||
}}
|
||||
className={`rounded-full border px-3 py-1 font-semibold transition ${
|
||||
scopeFilter === option.key
|
||||
? 'border-pink-200 bg-pink-50 text-pink-700'
|
||||
: 'border-slate-200 bg-white text-slate-600 hover:border-pink-200 hover:text-pink-700'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
{activeTab !== 'uploads' && (
|
||||
<div className="mt-3">
|
||||
<div className="flex gap-2 overflow-x-auto text-xs whitespace-nowrap pb-1">
|
||||
{(
|
||||
[
|
||||
{ key: 'all', label: t('header.notifications.scope.all', 'Alle') },
|
||||
{ key: 'tips', label: t('header.notifications.scope.tips', 'Tipps & Achievements') },
|
||||
{ key: 'general', label: t('header.notifications.scope.general', 'Allgemein') },
|
||||
] as const
|
||||
).map((option) => (
|
||||
<button
|
||||
key={option.key}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setScopeFilter(option.key);
|
||||
center.setFilters({ scope: option.key });
|
||||
}}
|
||||
className={`rounded-full border px-3 py-1 font-semibold transition ${
|
||||
scopeFilter === option.key
|
||||
? 'border-pink-200 bg-pink-50 text-pink-700'
|
||||
: 'border-slate-200 bg-white text-slate-600 hover:border-pink-200 hover:text-pink-700'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 max-h-80 space-y-2 overflow-y-auto pr-1">
|
||||
{center.loading ? (
|
||||
<NotificationSkeleton />
|
||||
) : scopedNotifications.length === 0 ? (
|
||||
<NotificationEmptyState
|
||||
t={t}
|
||||
message={
|
||||
activeTab === 'unread'
|
||||
? t('header.notifications.emptyUnread', 'Du bist auf dem neuesten Stand!')
|
||||
: activeTab === 'status'
|
||||
? t('header.notifications.emptyStatus', 'Keine Upload-Hinweise oder Wartungen aktiv.')
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
scopedNotifications.map((item) => (
|
||||
<NotificationListItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
onMarkRead={() => center.markAsRead(item.id)}
|
||||
onDismiss={() => center.dismiss(item.id)}
|
||||
t={t}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
{activeTab === 'status' && (
|
||||
)}
|
||||
{activeTab === 'uploads' && (center.pendingCount > 0 || center.queueCount > 0) && (
|
||||
<div className="mt-3 space-y-2">
|
||||
{center.pendingCount > 0 && (
|
||||
<div className="flex items-center justify-between rounded-xl bg-amber-50/90 px-3 py-2 text-xs text-amber-900">
|
||||
@@ -478,30 +447,32 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, butt
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{taskProgress && (
|
||||
<div className="mt-3 rounded-2xl border border-slate-200 bg-slate-50/90 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.3em] text-slate-400">{t('header.notifications.badgeLabel', 'Badge-Fortschritt')}</p>
|
||||
<p className="text-lg font-semibold text-slate-900">
|
||||
{taskProgress.completedCount}/{TASK_BADGE_TARGET}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
to={`/e/${encodeURIComponent(eventToken)}/tasks`}
|
||||
className="rounded-full border border-slate-200 px-3 py-1 text-xs font-semibold text-pink-600 transition hover:border-pink-300"
|
||||
>
|
||||
{t('header.notifications.tasksCta', 'Weiter')}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-3 h-1.5 w-full rounded-full bg-slate-100">
|
||||
<div
|
||||
className="h-full rounded-full bg-pink-500"
|
||||
style={{ width: `${progressRatio * 100}%` }}
|
||||
<div className="mt-3 max-h-80 space-y-2 overflow-y-auto pr-1">
|
||||
{center.loading ? (
|
||||
<NotificationSkeleton />
|
||||
) : scopedNotifications.length === 0 ? (
|
||||
<NotificationEmptyState
|
||||
t={t}
|
||||
message={
|
||||
activeTab === 'unread'
|
||||
? t('header.notifications.emptyUnread', 'Du bist auf dem neuesten Stand!')
|
||||
: activeTab === 'uploads'
|
||||
? t('header.notifications.emptyStatus', 'Keine Upload-Hinweise oder Wartungen aktiv.')
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
scopedNotifications.map((item) => (
|
||||
<NotificationListItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
onMarkRead={() => center.markAsRead(item.id)}
|
||||
onDismiss={() => center.dismiss(item.id)}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<NotificationStatusBar
|
||||
lastFetchedAt={center.lastFetchedAt}
|
||||
isOffline={center.isOffline}
|
||||
|
||||
@@ -38,7 +38,6 @@ vi.mock('../../context/NotificationCenterContext', () => ({
|
||||
queueItems: [],
|
||||
queueCount: 0,
|
||||
pendingCount: 0,
|
||||
totalCount: 0,
|
||||
loading: false,
|
||||
pendingLoading: false,
|
||||
refresh: vi.fn(),
|
||||
@@ -97,10 +96,10 @@ describe('Header notifications toggle', () => {
|
||||
const bellButton = screen.getByLabelText('Benachrichtigungen anzeigen');
|
||||
fireEvent.click(bellButton);
|
||||
|
||||
expect(screen.getByText('Benachrichtigungen')).toBeInTheDocument();
|
||||
expect(screen.getByText('Updates')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(bellButton);
|
||||
|
||||
expect(screen.queryByText('Benachrichtigungen')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Updates')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,7 +16,6 @@ export type NotificationCenterValue = {
|
||||
queueItems: QueueItem[];
|
||||
queueCount: number;
|
||||
pendingCount: number;
|
||||
totalCount: number;
|
||||
loading: boolean;
|
||||
pendingLoading: boolean;
|
||||
refresh: () => Promise<void>;
|
||||
@@ -264,11 +263,9 @@ export function NotificationCenterProvider({ eventToken, children }: { eventToke
|
||||
}, [loadNotifications, refreshQueue, loadPendingUploads]);
|
||||
|
||||
const loading = loadingNotifications || queueLoading || pendingLoading;
|
||||
const totalCount = unreadCount + queueCount + pendingCount;
|
||||
|
||||
React.useEffect(() => {
|
||||
void updateAppBadge(totalCount);
|
||||
}, [totalCount]);
|
||||
void updateAppBadge(unreadCount);
|
||||
}, [unreadCount]);
|
||||
|
||||
const value: NotificationCenterValue = {
|
||||
notifications,
|
||||
@@ -276,7 +273,6 @@ export function NotificationCenterProvider({ eventToken, children }: { eventToke
|
||||
queueItems: items,
|
||||
queueCount,
|
||||
pendingCount,
|
||||
totalCount,
|
||||
loading,
|
||||
pendingLoading,
|
||||
refresh,
|
||||
|
||||
@@ -42,7 +42,13 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
||||
},
|
||||
helpGallery: 'Hilfe zu Galerie & Teilen',
|
||||
notifications: {
|
||||
tabStatus: 'Upload-Status',
|
||||
title: 'Updates',
|
||||
unread: '{count} neu',
|
||||
allRead: 'Alles gelesen',
|
||||
tabUnread: 'Nachrichten',
|
||||
tabUploads: 'Uploads',
|
||||
tabAll: 'Alle Updates',
|
||||
emptyStatus: 'Keine Upload-Hinweise oder Wartungen aktiv.',
|
||||
},
|
||||
},
|
||||
liveShowPlayer: {
|
||||
@@ -774,7 +780,13 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
||||
},
|
||||
helpGallery: 'Help: Gallery & sharing',
|
||||
notifications: {
|
||||
tabStatus: 'Upload status',
|
||||
title: 'Updates',
|
||||
unread: '{count} new',
|
||||
allRead: 'All read',
|
||||
tabUnread: 'Messages',
|
||||
tabUploads: 'Uploads',
|
||||
tabAll: 'All updates',
|
||||
emptyStatus: 'No upload status or maintenance active.',
|
||||
},
|
||||
},
|
||||
liveShowPlayer: {
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Http\Controllers\Api\LegalController;
|
||||
use App\Http\Controllers\Api\LiveShowController;
|
||||
use App\Http\Controllers\Api\Marketing\CouponPreviewController;
|
||||
use App\Http\Controllers\Api\PackageController;
|
||||
use App\Http\Controllers\Api\PhotoboothConnectController;
|
||||
use App\Http\Controllers\Api\SparkboothUploadController;
|
||||
use App\Http\Controllers\Api\Tenant\AdminPushSubscriptionController;
|
||||
use App\Http\Controllers\Api\Tenant\DashboardController;
|
||||
@@ -24,6 +25,7 @@ use App\Http\Controllers\Api\Tenant\LiveShowLinkController;
|
||||
use App\Http\Controllers\Api\Tenant\LiveShowPhotoController;
|
||||
use App\Http\Controllers\Api\Tenant\NotificationLogController;
|
||||
use App\Http\Controllers\Api\Tenant\OnboardingController;
|
||||
use App\Http\Controllers\Api\Tenant\PhotoboothConnectCodeController;
|
||||
use App\Http\Controllers\Api\Tenant\PhotoboothController;
|
||||
use App\Http\Controllers\Api\Tenant\PhotoController;
|
||||
use App\Http\Controllers\Api\Tenant\ProfileController;
|
||||
@@ -88,7 +90,7 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
||||
});
|
||||
});
|
||||
|
||||
Route::middleware('throttle:100,1')->group(function () {
|
||||
Route::middleware('throttle:guest-api')->group(function () {
|
||||
Route::get('/help', [HelpController::class, 'index'])->name('help.index');
|
||||
Route::get('/help/{slug}', [HelpController::class, 'show'])->name('help.show');
|
||||
Route::get('/legal/{slug}', [LegalController::class, 'show'])->name('legal.show');
|
||||
@@ -153,6 +155,9 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
||||
|
||||
Route::post('/photobooth/sparkbooth/upload', [SparkboothUploadController::class, 'store'])
|
||||
->name('photobooth.sparkbooth.upload');
|
||||
Route::post('/photobooth/connect', [PhotoboothConnectController::class, 'store'])
|
||||
->middleware('throttle:photobooth-connect')
|
||||
->name('photobooth.connect');
|
||||
|
||||
Route::get('/tenant/events/{event:slug}/photos/{photo}/{variant}/asset', [PhotoController::class, 'asset'])
|
||||
->whereNumber('photo')
|
||||
@@ -263,6 +268,8 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
||||
Route::post('/enable', [PhotoboothController::class, 'enable'])->name('tenant.events.photobooth.enable');
|
||||
Route::post('/rotate', [PhotoboothController::class, 'rotate'])->name('tenant.events.photobooth.rotate');
|
||||
Route::post('/disable', [PhotoboothController::class, 'disable'])->name('tenant.events.photobooth.disable');
|
||||
Route::post('/connect-codes', [PhotoboothConnectCodeController::class, 'store'])
|
||||
->name('tenant.events.photobooth.connect-codes.store');
|
||||
});
|
||||
|
||||
Route::get('members', [EventMemberController::class, 'index'])
|
||||
@@ -353,6 +360,8 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
||||
Route::post('/complete', [PackageController::class, 'completePurchase'])->name('packages.complete');
|
||||
Route::post('/free', [PackageController::class, 'assignFree'])->name('packages.free');
|
||||
Route::post('/paddle-checkout', [PackageController::class, 'createPaddleCheckout'])->name('packages.paddle-checkout');
|
||||
Route::get('/checkout-session/{session}/status', [PackageController::class, 'checkoutSessionStatus'])
|
||||
->name('packages.checkout-session.status');
|
||||
});
|
||||
|
||||
Route::get('addons/catalog', [EventAddonCatalogController::class, 'index'])
|
||||
|
||||
@@ -11,6 +11,7 @@ use App\Models\TenantPackage;
|
||||
use App\Services\EventJoinTokenService;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\Feature\Tenant\TenantTestCase;
|
||||
|
||||
@@ -77,6 +78,55 @@ class EventControllerTest extends TenantTestCase
|
||||
->assertJsonPath('error.code', 'event_limit_missing');
|
||||
}
|
||||
|
||||
public function test_superadmin_can_create_event_without_tenant_package(): void
|
||||
{
|
||||
$tenant = $this->tenant;
|
||||
$eventType = EventType::factory()->create();
|
||||
$package = Package::factory()->create([
|
||||
'type' => 'endcustomer',
|
||||
'slug' => 'pro',
|
||||
'max_photos' => 100,
|
||||
]);
|
||||
|
||||
$superadmin = \App\Models\User::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'role' => 'superadmin',
|
||||
'password' => Hash::make('password'),
|
||||
'email_verified_at' => now(),
|
||||
]);
|
||||
|
||||
$login = $this->postJson('/api/v1/tenant-auth/login', [
|
||||
'login' => $superadmin->email,
|
||||
'password' => 'password',
|
||||
]);
|
||||
|
||||
$login->assertOk();
|
||||
$token = (string) $login->json('token');
|
||||
|
||||
$response = $this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson('/api/v1/tenant/events', [
|
||||
'name' => 'Owner Event',
|
||||
'slug' => 'owner-event',
|
||||
'event_date' => Carbon::now()->addDays(10)->toDateString(),
|
||||
'event_type_id' => $eventType->id,
|
||||
'package_id' => $package->id,
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
|
||||
$event = Event::latest()->first();
|
||||
|
||||
$this->assertDatabaseHas('events', [
|
||||
'tenant_id' => $tenant->id,
|
||||
'slug' => 'owner-event',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('event_packages', [
|
||||
'event_id' => $event->id,
|
||||
'package_id' => $package->id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_create_event_requires_waiver_for_endcustomer_package(): void
|
||||
{
|
||||
$tenant = $this->tenant;
|
||||
|
||||
100
tests/Feature/Photobooth/PhotoboothConnectCodeTest.php
Normal file
100
tests/Feature/Photobooth/PhotoboothConnectCodeTest.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Photobooth;
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Models\EventPhotoboothSetting;
|
||||
use App\Models\PhotoboothConnectCode;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\Feature\Tenant\TenantTestCase;
|
||||
|
||||
class PhotoboothConnectCodeTest extends TenantTestCase
|
||||
{
|
||||
#[Test]
|
||||
public function it_creates_a_connect_code_for_sparkbooth(): void
|
||||
{
|
||||
$event = Event::factory()->for($this->tenant)->create([
|
||||
'slug' => 'connect-code-event',
|
||||
]);
|
||||
|
||||
EventPhotoboothSetting::factory()
|
||||
->for($event)
|
||||
->activeSparkbooth()
|
||||
->create([
|
||||
'username' => 'pbconnect',
|
||||
'password' => 'SECRET12',
|
||||
]);
|
||||
|
||||
$response = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/photobooth/connect-codes");
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('data.code', fn ($value) => is_string($value) && strlen($value) === 6)
|
||||
->assertJsonPath('data.expires_at', fn ($value) => is_string($value) && $value !== '');
|
||||
|
||||
$this->assertDatabaseCount('photobooth_connect_codes', 1);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_redeems_a_connect_code_and_returns_upload_credentials(): void
|
||||
{
|
||||
$event = Event::factory()->for($this->tenant)->create([
|
||||
'slug' => 'connect-code-redeem',
|
||||
]);
|
||||
|
||||
EventPhotoboothSetting::factory()
|
||||
->for($event)
|
||||
->activeSparkbooth()
|
||||
->create([
|
||||
'username' => 'pbconnect',
|
||||
'password' => 'SECRET12',
|
||||
]);
|
||||
|
||||
$codeResponse = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/photobooth/connect-codes");
|
||||
$codeResponse->assertOk();
|
||||
|
||||
$code = (string) $codeResponse->json('data.code');
|
||||
|
||||
$redeem = $this->postJson('/api/v1/photobooth/connect', [
|
||||
'code' => $code,
|
||||
]);
|
||||
|
||||
$redeem->assertOk()
|
||||
->assertJsonPath('data.upload_url', fn ($value) => is_string($value) && $value !== '')
|
||||
->assertJsonPath('data.username', 'pbconnect')
|
||||
->assertJsonPath('data.password', 'SECRET12');
|
||||
|
||||
$this->assertDatabaseHas('photobooth_connect_codes', [
|
||||
'event_id' => $event->id,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_rejects_expired_connect_codes(): void
|
||||
{
|
||||
$event = Event::factory()->for($this->tenant)->create([
|
||||
'slug' => 'connect-code-expired',
|
||||
]);
|
||||
|
||||
EventPhotoboothSetting::factory()
|
||||
->for($event)
|
||||
->activeSparkbooth()
|
||||
->create([
|
||||
'username' => 'pbconnect',
|
||||
'password' => 'SECRET12',
|
||||
]);
|
||||
|
||||
$code = '123456';
|
||||
|
||||
PhotoboothConnectCode::query()->create([
|
||||
'event_id' => $event->id,
|
||||
'code_hash' => hash('sha256', $code),
|
||||
'expires_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$response = $this->postJson('/api/v1/photobooth/connect', [
|
||||
'code' => $code,
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
}
|
||||
}
|
||||
26
tests/Feature/Tenant/PhotoModerationControllerTest.php
Normal file
26
tests/Feature/Tenant/PhotoModerationControllerTest.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Tenant;
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Models\Photo;
|
||||
|
||||
class PhotoModerationControllerTest extends TenantTestCase
|
||||
{
|
||||
public function test_tenant_admin_can_approve_photo(): void
|
||||
{
|
||||
$event = Event::factory()->for($this->tenant)->create([
|
||||
'slug' => 'moderation-event',
|
||||
]);
|
||||
$photo = Photo::factory()->for($event)->create([
|
||||
'status' => 'pending',
|
||||
]);
|
||||
|
||||
$response = $this->authenticatedRequest('PATCH', "/api/v1/tenant/events/{$event->slug}/photos/{$photo->id}", [
|
||||
'status' => 'approved',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$this->assertSame('approved', $photo->refresh()->status);
|
||||
}
|
||||
}
|
||||
46
tests/Feature/Tenant/TenantCheckoutSessionStatusTest.php
Normal file
46
tests/Feature/Tenant/TenantCheckoutSessionStatusTest.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Tenant;
|
||||
|
||||
use App\Models\CheckoutSession;
|
||||
use App\Models\Package;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class TenantCheckoutSessionStatusTest extends TenantTestCase
|
||||
{
|
||||
public function test_tenant_can_fetch_checkout_session_status(): void
|
||||
{
|
||||
$package = Package::factory()->create([
|
||||
'price' => 129,
|
||||
]);
|
||||
|
||||
$session = CheckoutSession::create([
|
||||
'id' => (string) Str::uuid(),
|
||||
'user_id' => $this->tenantUser->id,
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'package_id' => $package->id,
|
||||
'status' => CheckoutSession::STATUS_FAILED,
|
||||
'provider' => CheckoutSession::PROVIDER_PADDLE,
|
||||
'provider_metadata' => [
|
||||
'paddle_checkout_url' => 'https://checkout.paddle.test/checkout/123',
|
||||
],
|
||||
'status_history' => [
|
||||
[
|
||||
'status' => CheckoutSession::STATUS_FAILED,
|
||||
'reason' => 'paddle_failed',
|
||||
'at' => now()->toIso8601String(),
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$response = $this->authenticatedRequest(
|
||||
'GET',
|
||||
"/api/v1/tenant/packages/checkout-session/{$session->id}/status"
|
||||
);
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('status', CheckoutSession::STATUS_FAILED)
|
||||
->assertJsonPath('reason', 'paddle_failed')
|
||||
->assertJsonPath('checkout_url', 'https://checkout.paddle.test/checkout/123');
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,10 @@ class TenantPaddleCheckoutTest extends TenantTestCase
|
||||
return $tenant->is($this->tenant)
|
||||
&& $payloadPackage->is($package)
|
||||
&& array_key_exists('success_url', $payload)
|
||||
&& array_key_exists('return_url', $payload);
|
||||
&& array_key_exists('return_url', $payload)
|
||||
&& array_key_exists('metadata', $payload)
|
||||
&& is_array($payload['metadata'])
|
||||
&& ! empty($payload['metadata']['checkout_session_id']);
|
||||
})
|
||||
->andReturn([
|
||||
'checkout_url' => 'https://checkout.paddle.test/checkout/123',
|
||||
@@ -42,7 +45,8 @@ class TenantPaddleCheckoutTest extends TenantTestCase
|
||||
]);
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('checkout_url', 'https://checkout.paddle.test/checkout/123');
|
||||
->assertJsonPath('checkout_url', 'https://checkout.paddle.test/checkout/123')
|
||||
->assertJsonStructure(['checkout_session_id']);
|
||||
}
|
||||
|
||||
public function test_paddle_checkout_requires_paddle_price_id(): void
|
||||
|
||||
67
tests/Unit/RateLimitConfigTest.php
Normal file
67
tests/Unit/RateLimitConfigTest.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Models\GuestPolicySetting;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Tests\TestCase;
|
||||
|
||||
class RateLimitConfigTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_tenant_api_rate_limiter_allows_higher_throughput(): void
|
||||
{
|
||||
$request = Request::create('/api/v1/tenant/events', 'GET', [], [], [], [
|
||||
'REMOTE_ADDR' => '10.0.0.1',
|
||||
]);
|
||||
$request->attributes->set('tenant_id', 42);
|
||||
|
||||
$limiter = RateLimiter::limiter('tenant-api');
|
||||
|
||||
$this->assertNotNull($limiter);
|
||||
|
||||
$limit = $limiter($request);
|
||||
|
||||
$this->assertInstanceOf(Limit::class, $limit);
|
||||
$this->assertSame(600, $limit->maxAttempts);
|
||||
}
|
||||
|
||||
public function test_guest_api_rate_limiter_allows_higher_throughput(): void
|
||||
{
|
||||
$request = Request::create('/api/v1/events/sample', 'GET', [], [], [], [
|
||||
'REMOTE_ADDR' => '10.0.0.2',
|
||||
]);
|
||||
|
||||
$limiter = RateLimiter::limiter('guest-api');
|
||||
|
||||
$this->assertNotNull($limiter);
|
||||
|
||||
$limit = $limiter($request);
|
||||
|
||||
$this->assertInstanceOf(Limit::class, $limit);
|
||||
$this->assertSame(300, $limit->maxAttempts);
|
||||
}
|
||||
|
||||
public function test_guest_policy_defaults_follow_join_token_limits(): void
|
||||
{
|
||||
$accessLimit = 300;
|
||||
$downloadLimit = 120;
|
||||
|
||||
config([
|
||||
'join_tokens.access_limit' => $accessLimit,
|
||||
'join_tokens.download_limit' => $downloadLimit,
|
||||
]);
|
||||
|
||||
GuestPolicySetting::query()->delete();
|
||||
GuestPolicySetting::flushCache();
|
||||
|
||||
$settings = GuestPolicySetting::current();
|
||||
|
||||
$this->assertSame($accessLimit, $settings->join_token_access_limit);
|
||||
$this->assertSame($downloadLimit, $settings->join_token_download_limit);
|
||||
}
|
||||
}
|
||||
144
tests/Unit/SendPhotoUploadedNotificationTest.php
Normal file
144
tests/Unit/SendPhotoUploadedNotificationTest.php
Normal file
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Enums\GuestNotificationType;
|
||||
use App\Events\GuestPhotoUploaded;
|
||||
use App\Listeners\GuestNotifications\SendPhotoUploadedNotification;
|
||||
use App\Models\Event;
|
||||
use App\Models\GuestNotification;
|
||||
use App\Models\GuestNotificationReceipt;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Tests\TestCase;
|
||||
|
||||
class SendPhotoUploadedNotificationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_it_dedupes_recent_photo_activity_notifications(): void
|
||||
{
|
||||
Carbon::setTestNow('2026-01-12 13:48:01');
|
||||
|
||||
$event = Event::factory()->create();
|
||||
$listener = $this->app->make(SendPhotoUploadedNotification::class);
|
||||
|
||||
GuestNotification::factory()->create([
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'event_id' => $event->id,
|
||||
'type' => GuestNotificationType::PHOTO_ACTIVITY,
|
||||
'title' => 'Fotospiel-Test hat gerade ein Foto gemacht 🎉',
|
||||
'payload' => [
|
||||
'photo_id' => 123,
|
||||
'photo_ids' => [123],
|
||||
'count' => 1,
|
||||
],
|
||||
'created_at' => now()->subSeconds(5),
|
||||
'updated_at' => now()->subSeconds(5),
|
||||
]);
|
||||
|
||||
$listener->handle(new GuestPhotoUploaded(
|
||||
$event,
|
||||
123,
|
||||
'device-123',
|
||||
'Fotospiel-Test'
|
||||
));
|
||||
|
||||
$notification = GuestNotification::query()
|
||||
->where('event_id', $event->id)
|
||||
->where('type', GuestNotificationType::PHOTO_ACTIVITY)
|
||||
->first();
|
||||
|
||||
$this->assertSame(1, GuestNotification::query()
|
||||
->where('event_id', $event->id)
|
||||
->where('type', GuestNotificationType::PHOTO_ACTIVITY)
|
||||
->count());
|
||||
$this->assertSame(1, (int) ($notification?->payload['count'] ?? 0));
|
||||
}
|
||||
|
||||
public function test_it_groups_recent_photo_activity_notifications(): void
|
||||
{
|
||||
Carbon::setTestNow('2026-01-12 13:48:01');
|
||||
|
||||
$event = Event::factory()->create();
|
||||
$listener = $this->app->make(SendPhotoUploadedNotification::class);
|
||||
|
||||
GuestNotification::factory()->create([
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'event_id' => $event->id,
|
||||
'type' => GuestNotificationType::PHOTO_ACTIVITY,
|
||||
'title' => 'Fotospiel-Test hat gerade ein Foto gemacht 🎉',
|
||||
'payload' => [
|
||||
'photo_id' => 122,
|
||||
'photo_ids' => [122],
|
||||
'count' => 1,
|
||||
],
|
||||
'created_at' => now()->subMinutes(5),
|
||||
'updated_at' => now()->subMinutes(5),
|
||||
]);
|
||||
|
||||
$listener->handle(new GuestPhotoUploaded(
|
||||
$event,
|
||||
123,
|
||||
'device-123',
|
||||
'Fotospiel-Test'
|
||||
));
|
||||
|
||||
$this->assertSame(1, GuestNotification::query()
|
||||
->where('event_id', $event->id)
|
||||
->where('type', GuestNotificationType::PHOTO_ACTIVITY)
|
||||
->count());
|
||||
|
||||
$notification = GuestNotification::query()
|
||||
->where('event_id', $event->id)
|
||||
->where('type', GuestNotificationType::PHOTO_ACTIVITY)
|
||||
->first();
|
||||
|
||||
$this->assertSame('Es gibt 2 neue Fotos!', $notification?->title);
|
||||
$this->assertSame(2, (int) ($notification?->payload['count'] ?? 0));
|
||||
|
||||
$this->assertSame(1, GuestNotificationReceipt::query()
|
||||
->where('guest_identifier', 'device-123')
|
||||
->where('status', 'read')
|
||||
->count());
|
||||
}
|
||||
|
||||
public function test_it_creates_notification_outside_group_window(): void
|
||||
{
|
||||
Carbon::setTestNow('2026-01-12 13:48:01');
|
||||
|
||||
$event = Event::factory()->create();
|
||||
$listener = $this->app->make(SendPhotoUploadedNotification::class);
|
||||
|
||||
GuestNotification::factory()->create([
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'event_id' => $event->id,
|
||||
'type' => GuestNotificationType::PHOTO_ACTIVITY,
|
||||
'title' => 'Fotospiel-Test hat gerade ein Foto gemacht 🎉',
|
||||
'payload' => [
|
||||
'photo_id' => 122,
|
||||
'photo_ids' => [122],
|
||||
'count' => 1,
|
||||
],
|
||||
'created_at' => now()->subMinutes(20),
|
||||
'updated_at' => now()->subMinutes(20),
|
||||
]);
|
||||
|
||||
$listener->handle(new GuestPhotoUploaded(
|
||||
$event,
|
||||
123,
|
||||
'device-123',
|
||||
'Fotospiel-Test'
|
||||
));
|
||||
|
||||
$this->assertSame(2, GuestNotification::query()
|
||||
->where('event_id', $event->id)
|
||||
->where('type', GuestNotificationType::PHOTO_ACTIVITY)
|
||||
->count());
|
||||
|
||||
$this->assertSame(1, GuestNotificationReceipt::query()
|
||||
->where('guest_identifier', 'device-123')
|
||||
->where('status', 'read')
|
||||
->count());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user