From 08d80661577862b4787d55ac4832e5e0667e730e Mon Sep 17 00:00:00 2001 From: bunker-admin Date: Fri, 6 Mar 2026 14:33:33 -0700 Subject: [PATCH] Add ticketed events, Jitsi meeting integration, social features, and calendar system - Ticketed events: full CRUD, ticket tiers (free/paid/donation), Stripe checkout, QR-based check-in scanner, public event pages, ticket confirmation emails - Event formats: IN_PERSON/ONLINE/HYBRID with auto Jitsi meeting room lifecycle, ticket-gated meeting access, moderator JWT tokens, feature-flag guarded - Social engagement: challenges with scoring/leaderboards, referral tracking, volunteer spotlight, impact stories, campaign celebrations, wall of fame - Social calendar: personal calendar layers, shared calendar items with recurrence, scheduling polls, mobile day view - MCP server: events tool pack with full admin CRUD + meeting token generation - Unified calendar: eventFormat-aware tags, online event indicators - Updated docs site, pangolin configs, and various admin UI improvements Bunker Admin --- SOCIAL_CALENDAR_PLAN.md | 556 +++ admin/package-lock.json | 6 + admin/package.json | 1 + admin/src/App.tsx | 110 + admin/src/components/AppLayout.tsx | 16 +- admin/src/components/FeatureGate.tsx | 4 +- admin/src/components/PublicLayout.tsx | 1 + admin/src/components/VolunteerFooterNav.tsx | 10 +- .../components/calendar/CalendarItemModal.tsx | 479 +++ .../calendar/CalendarLayerPanel.tsx | 435 +++ .../src/components/calendar/MobileDayView.tsx | 424 +++ .../calendar/PersonalCalendarView.tsx | 173 + .../components/calendar/RecurrenceEditor.tsx | 237 ++ .../components/calendar/UnifiedCalendar.tsx | 53 +- .../components/social/CampaignCelebration.tsx | 63 + admin/src/components/social/ChallengeCard.tsx | 114 + .../social/ChallengeLeaderboard.tsx | 82 + admin/src/components/social/FeedCard.tsx | 20 +- .../src/components/social/ImpactStoryCard.tsx | 74 + .../src/components/social/InviteCodeCard.tsx | 131 + .../components/social/PublicLeaderboard.tsx | 118 + admin/src/components/social/SpotlightCard.tsx | 93 + admin/src/components/social/TeamJoinCard.tsx | 133 + admin/src/pages/CampaignsPage.tsx | 38 +- admin/src/pages/DashboardPage.tsx | 39 +- admin/src/pages/LocationsPage.tsx | 29 +- admin/src/pages/LoginPage.tsx | 22 +- admin/src/pages/PangolinPage.tsx | 938 ++--- admin/src/pages/SchedulingCalendarPage.tsx | 55 + admin/src/pages/SettingsPage.tsx | 11 +- admin/src/pages/UsersPage.tsx | 28 +- admin/src/pages/events/CheckInScannerPage.tsx | 308 ++ admin/src/pages/events/EventDetailPage.tsx | 295 ++ admin/src/pages/events/TicketedEventsPage.tsx | 513 +++ .../src/pages/influence/ImpactStoriesPage.tsx | 347 ++ admin/src/pages/public/CampaignPage.tsx | 4 + admin/src/pages/public/EventsPage.tsx | 4 + .../pages/public/TicketConfirmationPage.tsx | 199 ++ .../pages/public/TicketedEventDetailPage.tsx | 435 +++ admin/src/pages/public/WallOfFamePage.tsx | 247 ++ .../src/pages/social/ChallengesAdminPage.tsx | 297 ++ admin/src/pages/social/ReferralAdminPage.tsx | 148 + admin/src/pages/social/SpotlightAdminPage.tsx | 372 ++ .../src/pages/volunteer/AchievementsPage.tsx | 46 +- .../pages/volunteer/ChallengeDetailPage.tsx | 202 ++ admin/src/pages/volunteer/ChallengesPage.tsx | 124 + admin/src/pages/volunteer/MyCalendarPage.tsx | 441 +++ admin/src/pages/volunteer/MyTicketsPage.tsx | 159 + admin/src/pages/volunteer/ReferralsPage.tsx | 217 ++ admin/src/stores/auth.store.ts | 5 +- admin/src/types/api.ts | 220 +- admin/src/vite-env.d.ts | 15 + .../migration.sql | 249 ++ .../migration.sql | 176 + .../migration.sql | 12 + .../migration.sql | 258 ++ .../migration.sql | 11 + api/prisma/schema.prisma | 694 +++- api/src/modules/auth/auth.schemas.ts | 1 + api/src/modules/auth/auth.service.ts | 9 + api/src/modules/calendar/calendar.routes.ts | 152 + api/src/modules/calendar/calendar.schemas.ts | 80 + api/src/modules/calendar/calendar.service.ts | 781 +++++ .../calendar/shared-calendar.schemas.ts | 61 + .../events/unified-calendar.service.ts | 86 +- .../campaign-emails.service.ts | 10 + api/src/modules/pangolin/pangolin.routes.ts | 765 ++-- api/src/modules/payments/webhook.service.ts | 124 + api/src/modules/settings/settings.schemas.ts | 3 + .../modules/social/achievements.service.ts | 107 + api/src/modules/social/challenge.routes.ts | 177 + api/src/modules/social/challenge.schemas.ts | 37 + api/src/modules/social/challenge.service.ts | 347 ++ api/src/modules/social/feed.service.ts | 133 +- .../modules/social/impact-stories.routes.ts | 102 + .../modules/social/impact-stories.schemas.ts | 32 + .../modules/social/impact-stories.service.ts | 308 ++ .../modules/social/notification.service.ts | 6 + api/src/modules/social/privacy.service.ts | 1 + api/src/modules/social/referral.routes.ts | 96 + api/src/modules/social/referral.schemas.ts | 20 + api/src/modules/social/referral.service.ts | 209 ++ api/src/modules/social/social.routes.ts | 8 + api/src/modules/social/spotlight.routes.ts | 190 + api/src/modules/social/spotlight.schemas.ts | 27 + api/src/modules/social/spotlight.service.ts | 245 ++ .../modules/ticketed-events/checkin.routes.ts | 67 + .../ticketed-events/ticket-email.service.ts | 148 + .../ticketed-events-admin.routes.ts | 279 ++ .../ticketed-events-public.routes.ts | 261 ++ .../ticketed-events.schemas.ts | 117 + .../ticketed-events.service.ts | 829 +++++ .../ticketed-events/tickets.service.ts | 373 ++ api/src/server.ts | 15 + api/src/services/challenge-scoring.service.ts | 167 + api/src/services/env-writer.service.ts | 82 + api/src/services/pangolin.client.ts | 471 +-- api/upgrade/trigger.json | 5 + configs/pangolin/resources.yml | 41 +- mcp-server/src/server.ts | 4 +- .../src/tools/composite/daily-briefing.ts | 12 +- mcp-server/src/tools/packs/events.ts | 295 ++ .../social/docs/admin/services/crowdsec.png | Bin 0 -> 83783 bytes mkdocs/.cache/plugin/social/manifest.json | 1 + .../repo-data/admin-changemaker.lite.json | 4 +- .../repo-data/anthropics-claude-code.json | 10 +- .../assets/repo-data/coder-code-server.json | 10 +- .../repo-data/gethomepage-homepage.json | 10 +- .../docs/assets/repo-data/go-gitea-gitea.json | 10 +- .../docs/assets/repo-data/knadh-listmonk.json | 10 +- .../docs/assets/repo-data/lyqht-mini-qr.json | 8 +- mkdocs/docs/assets/repo-data/n8n-io-n8n.json | 10 +- .../docs/assets/repo-data/nocodb-nocodb.json | 10 +- .../docs/assets/repo-data/ollama-ollama.json | 10 +- .../repo-data/squidfunk-mkdocs-material.json | 8 +- mkdocs/docs/docs/admin/services/crowdsec.md | 167 + mkdocs/docs/docs/admin/services/index.md | 1 + mkdocs/docs/docs/admin/services/tunnel.md | 11 + mkdocs/docs/overrides/lander.html | 4 + mkdocs/docs/overrides/main.html | 4 +- mkdocs/mkdocs.yml | 1 + mkdocs/site/404.html | 14 +- .../social/docs/admin/broadcast/sms.png | Bin 75860 -> 77276 bytes .../social/docs/admin/services/crowdsec.png | Bin 0 -> 83783 bytes mkdocs/site/assets/js/scheduling-poll.js | 189 + .../repo-data/admin-changemaker.lite.json | 4 +- .../repo-data/anthropics-claude-code.json | 10 +- .../assets/repo-data/coder-code-server.json | 10 +- .../repo-data/gethomepage-homepage.json | 10 +- .../site/assets/repo-data/go-gitea-gitea.json | 10 +- .../site/assets/repo-data/knadh-listmonk.json | 10 +- .../site/assets/repo-data/lyqht-mini-qr.json | 8 +- mkdocs/site/assets/repo-data/n8n-io-n8n.json | 10 +- .../site/assets/repo-data/nocodb-nocodb.json | 10 +- .../site/assets/repo-data/ollama-ollama.json | 10 +- .../repo-data/squidfunk-mkdocs-material.json | 8 +- mkdocs/site/blog/index.html | 14 +- mkdocs/site/comments/callback/index.html | 14 +- .../docs/admin/advocacy/campaigns/index.html | 16 +- .../admin/advocacy/email-queue/index.html | 16 +- mkdocs/site/docs/admin/advocacy/index.html | 16 +- .../admin/advocacy/representatives/index.html | 16 +- .../docs/admin/advocacy/responses/index.html | 16 +- .../broadcast/email-templates/index.html | 16 +- mkdocs/site/docs/admin/broadcast/index.html | 16 +- .../admin/broadcast/newsletter/index.html | 16 +- .../site/docs/admin/broadcast/sms/index.html | 1294 ++++++- mkdocs/site/docs/admin/dashboard/index.html | 16 +- mkdocs/site/docs/admin/index.html | 16 +- mkdocs/site/docs/admin/map/areas/index.html | 16 +- .../site/docs/admin/map/canvassing/index.html | 16 +- .../docs/admin/map/data-quality/index.html | 16 +- mkdocs/site/docs/admin/map/index.html | 16 +- .../site/docs/admin/map/locations/index.html | 16 +- .../site/docs/admin/map/settings/index.html | 16 +- mkdocs/site/docs/admin/map/shifts/index.html | 16 +- mkdocs/site/docs/admin/media/ads/index.html | 16 +- .../docs/admin/media/analytics/index.html | 16 +- .../site/docs/admin/media/curated/index.html | 16 +- mkdocs/site/docs/admin/media/index.html | 16 +- .../site/docs/admin/media/library/index.html | 16 +- .../docs/admin/media/moderation/index.html | 16 +- .../docs/admin/payments/donations/index.html | 16 +- mkdocs/site/docs/admin/payments/index.html | 16 +- .../site/docs/admin/payments/plans/index.html | 16 +- .../docs/admin/payments/products/index.html | 16 +- .../docs/admin/payments/settings/index.html | 16 +- .../site/docs/admin/people-access/index.html | 16 +- .../docs/admin/services/crowdsec/index.html | 3106 +++++++++++++++++ mkdocs/site/docs/admin/services/index.html | 47 +- .../admin/services/integrations/index.html | 46 +- .../docs/admin/services/monitoring/index.html | 52 +- .../docs/admin/services/tunnel/index.html | 83 +- .../services/user-provisioning/index.html | 46 +- mkdocs/site/docs/admin/settings/index.html | 16 +- .../docs/admin/web/documentation/index.html | 16 +- .../site/docs/admin/web/homepage/index.html | 16 +- mkdocs/site/docs/admin/web/index.html | 16 +- .../docs/admin/web/landing-pages/index.html | 16 +- .../site/docs/admin/web/navigation/index.html | 16 +- mkdocs/site/docs/api/index.html | 14 +- mkdocs/site/docs/architecture/index.html | 14 +- mkdocs/site/docs/deployment/index.html | 14 +- .../environment-variables/index.html | 341 +- .../docs/getting-started/features/index.html | 14 +- .../getting-started/first-steps/index.html | 14 +- mkdocs/site/docs/getting-started/index.html | 14 +- .../getting-started/installation/index.html | 14 +- mkdocs/site/docs/index.html | 14 +- mkdocs/site/docs/phil/index.html | 14 +- mkdocs/site/docs/services/index.html | 14 +- mkdocs/site/docs/troubleshooting/index.html | 14 +- .../site/docs/user-guide/campaigns/index.html | 14 +- .../site/docs/user-guide/donations/index.html | 14 +- mkdocs/site/docs/user-guide/events/index.html | 14 +- .../site/docs/user-guide/gallery/index.html | 14 +- mkdocs/site/docs/user-guide/index.html | 14 +- mkdocs/site/docs/user-guide/map/index.html | 14 +- .../site/docs/user-guide/profile/index.html | 14 +- mkdocs/site/docs/user-guide/shifts/index.html | 14 +- mkdocs/site/docs/user-guide/shop/index.html | 14 +- .../docs/volunteer/achievements/index.html | 14 +- .../site/docs/volunteer/canvassing/index.html | 14 +- mkdocs/site/docs/volunteer/index.html | 14 +- mkdocs/site/docs/volunteer/shifts/index.html | 14 +- mkdocs/site/docs/volunteer/social/index.html | 14 +- mkdocs/site/index.html | 197 +- mkdocs/site/lander/index.html | 197 +- mkdocs/site/main/index.html | 14 +- mkdocs/site/overrides/lander.html | 197 +- mkdocs/site/overrides/main.html | 4 +- mkdocs/site/search/search_index.json | 2 +- mkdocs/site/sitemap.xml | 152 +- mkdocs/site/sitemap.xml.gz | Bin 717 -> 725 bytes mkdocs/site/test/index.html | 14 +- 215 files changed, 23128 insertions(+), 1968 deletions(-) create mode 100644 SOCIAL_CALENDAR_PLAN.md create mode 100644 admin/src/components/calendar/CalendarItemModal.tsx create mode 100644 admin/src/components/calendar/CalendarLayerPanel.tsx create mode 100644 admin/src/components/calendar/MobileDayView.tsx create mode 100644 admin/src/components/calendar/PersonalCalendarView.tsx create mode 100644 admin/src/components/calendar/RecurrenceEditor.tsx create mode 100644 admin/src/components/social/CampaignCelebration.tsx create mode 100644 admin/src/components/social/ChallengeCard.tsx create mode 100644 admin/src/components/social/ChallengeLeaderboard.tsx create mode 100644 admin/src/components/social/ImpactStoryCard.tsx create mode 100644 admin/src/components/social/InviteCodeCard.tsx create mode 100644 admin/src/components/social/PublicLeaderboard.tsx create mode 100644 admin/src/components/social/SpotlightCard.tsx create mode 100644 admin/src/components/social/TeamJoinCard.tsx create mode 100644 admin/src/pages/SchedulingCalendarPage.tsx create mode 100644 admin/src/pages/events/CheckInScannerPage.tsx create mode 100644 admin/src/pages/events/EventDetailPage.tsx create mode 100644 admin/src/pages/events/TicketedEventsPage.tsx create mode 100644 admin/src/pages/influence/ImpactStoriesPage.tsx create mode 100644 admin/src/pages/public/TicketConfirmationPage.tsx create mode 100644 admin/src/pages/public/TicketedEventDetailPage.tsx create mode 100644 admin/src/pages/public/WallOfFamePage.tsx create mode 100644 admin/src/pages/social/ChallengesAdminPage.tsx create mode 100644 admin/src/pages/social/ReferralAdminPage.tsx create mode 100644 admin/src/pages/social/SpotlightAdminPage.tsx create mode 100644 admin/src/pages/volunteer/ChallengeDetailPage.tsx create mode 100644 admin/src/pages/volunteer/ChallengesPage.tsx create mode 100644 admin/src/pages/volunteer/MyCalendarPage.tsx create mode 100644 admin/src/pages/volunteer/MyTicketsPage.tsx create mode 100644 admin/src/pages/volunteer/ReferralsPage.tsx create mode 100644 api/prisma/migrations/20260304045540_social_stories_spotlight_referrals_challenges/migration.sql create mode 100644 api/prisma/migrations/20260305165051_ticketed_events/migration.sql create mode 100644 api/prisma/migrations/20260305170000_add_event_format_meeting_link/migration.sql create mode 100644 api/prisma/migrations/20260306203326_social_calendar_layers_items/migration.sql create mode 100644 api/prisma/migrations/20260306213132_add_shared_calendar_notification_types/migration.sql create mode 100644 api/src/modules/calendar/calendar.routes.ts create mode 100644 api/src/modules/calendar/calendar.schemas.ts create mode 100644 api/src/modules/calendar/calendar.service.ts create mode 100644 api/src/modules/calendar/shared-calendar.schemas.ts create mode 100644 api/src/modules/social/challenge.routes.ts create mode 100644 api/src/modules/social/challenge.schemas.ts create mode 100644 api/src/modules/social/challenge.service.ts create mode 100644 api/src/modules/social/impact-stories.routes.ts create mode 100644 api/src/modules/social/impact-stories.schemas.ts create mode 100644 api/src/modules/social/impact-stories.service.ts create mode 100644 api/src/modules/social/referral.routes.ts create mode 100644 api/src/modules/social/referral.schemas.ts create mode 100644 api/src/modules/social/referral.service.ts create mode 100644 api/src/modules/social/spotlight.routes.ts create mode 100644 api/src/modules/social/spotlight.schemas.ts create mode 100644 api/src/modules/social/spotlight.service.ts create mode 100644 api/src/modules/ticketed-events/checkin.routes.ts create mode 100644 api/src/modules/ticketed-events/ticket-email.service.ts create mode 100644 api/src/modules/ticketed-events/ticketed-events-admin.routes.ts create mode 100644 api/src/modules/ticketed-events/ticketed-events-public.routes.ts create mode 100644 api/src/modules/ticketed-events/ticketed-events.schemas.ts create mode 100644 api/src/modules/ticketed-events/ticketed-events.service.ts create mode 100644 api/src/modules/ticketed-events/tickets.service.ts create mode 100644 api/src/services/challenge-scoring.service.ts create mode 100644 api/src/services/env-writer.service.ts create mode 100644 api/upgrade/trigger.json create mode 100644 mcp-server/src/tools/packs/events.ts create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/admin/services/crowdsec.png create mode 100644 mkdocs/docs/docs/admin/services/crowdsec.md create mode 100644 mkdocs/site/assets/images/social/docs/admin/services/crowdsec.png create mode 100644 mkdocs/site/assets/js/scheduling-poll.js create mode 100644 mkdocs/site/docs/admin/services/crowdsec/index.html diff --git a/SOCIAL_CALENDAR_PLAN.md b/SOCIAL_CALENDAR_PLAN.md new file mode 100644 index 0000000..11e7e91 --- /dev/null +++ b/SOCIAL_CALENDAR_PLAN.md @@ -0,0 +1,556 @@ +# Social Calendar Feature Plan + +**Created:** 2026-03-06 +**Status:** Planning Complete — Ready for Phase A Implementation +**Branch:** v2 +**Feature Flag:** `enableSocialCalendar` (new, under SiteSettings) + +--- + +## Overview + +A layered personal and social calendar system. Each user gets their own calendar with multiple layers (system-populated, user-created, external feeds). Calendars can be shared between users at the item, category (layer), or whole-calendar level. Shared views allow multiple users' events to appear on a merged, color-coded calendar. Admin shared views can auto-include users by role. + +### Design Principles + +- **Layers are the core abstraction** — every event belongs to a layer, layers control visibility and sharing +- **System layers are virtual** — shifts, tickets, polls are queried live from source tables, not duplicated +- **Recurrence uses materialization** — consistent with existing ShiftSeries pattern (generate DB rows, allow exceptions) +- **Social-first** — friend relationships gate sharing; admin views are separate and only expose system data +- **Privacy by default** — layers default to PRIVATE; users explicitly opt into sharing + +--- + +## Data Model + +### CalendarLayer + +Each user has multiple layers. System layers are auto-created on first calendar access. + +| Field | Type | Notes | +|-------|------|-------| +| id | String (cuid) | PK | +| userId | String | FK to User | +| name | String | "Personal", "Gym", "Google Cal", etc. | +| layerType | Enum | SYSTEM, USER, EXTERNAL | +| systemType | Enum? | SHIFTS, TICKETS, POLLS, PUBLIC_EVENTS (for SYSTEM layers only) | +| color | String | Hex color (#1890ff) | +| visibility | Enum | PRIVATE, FRIENDS, PUBLIC | +| isEnabled | Boolean | User can toggle layers on/off for themselves | +| sortOrder | Int | Display ordering | +| createdAt | DateTime | | +| updatedAt | DateTime | | + +**System layers (auto-created per user):** +- My Shifts — from ShiftSignup records +- My Tickets — from EventTicket records +- My Polls — from SchedulingPollVote records +- Public Events — the existing Gancio/platform feed (togglable) + +System layers are **virtual** — no CalendarItem rows are created. The API queries source tables directly and maps to the CalendarItem shape at response time. + +### CalendarItem + +User-created events, time blocks, and cached .ics feed entries. + +| Field | Type | Notes | +|-------|------|-------| +| id | String (cuid) | PK | +| userId | String | FK to User (owner) | +| layerId | String | FK to CalendarLayer | +| title | String | | +| description | String? | Text | +| date | DateTime | Date of this occurrence | +| startTime | String | HH:MM | +| endTime | String | HH:MM | +| isAllDay | Boolean | Default false | +| itemType | Enum | EVENT, TIME_BLOCK, REMINDER | +| location | String? | | +| color | String? | Override (null = inherit layer color) | +| visibility | Enum? | PRIVATE, FRIENDS, PUBLIC (null = inherit from layer) | +| busyStatus | Enum | BUSY, TENTATIVE, FREE (default BUSY) | +| showDetailsTo | Enum | NOBODY, FRIENDS, EVERYONE (default FRIENDS) | +| recurrenceRule | Json? | See Recurrence section | +| recurrenceEnd | DateTime? | When series stops | +| seriesId | String? | Groups recurring instances | +| isException | Boolean | Edited instance that broke from pattern | +| sourceType | Enum | MANUAL, ICS_FEED | +| sourceId | String? | External reference (ics UID, etc.) | +| createdAt | DateTime | | +| updatedAt | DateTime | | + +**Notes:** +- System-layer items (shifts, tickets, polls) are NOT stored as CalendarItem rows — they're virtual +- .ics feed items ARE stored as CalendarItem rows (cached from external source, read-only to user) +- MANUAL items are user-created freeform events + +### CalendarFeed (.ics import) + +| Field | Type | Notes | +|-------|------|-------| +| id | String (cuid) | PK | +| userId | String | FK to User | +| name | String | "Google Calendar", "Work" | +| url | String | .ics URL | +| layerId | String | FK to auto-created CalendarLayer | +| refreshInterval | Enum | FIFTEEN_MIN, HOURLY, SIX_HOUR, DAILY | +| lastFetchedAt | DateTime? | | +| lastStatus | Enum | OK, ERROR, PENDING | +| lastError | String? | Error message if failed | +| itemCount | Int | How many items imported | +| createdAt | DateTime | | +| updatedAt | DateTime | | + +### SharedCalendarView + +| Field | Type | Notes | +|-------|------|-------| +| id | String (cuid) | PK | +| name | String | "Weekend Crew", "All Shift Admins" | +| description | String? | | +| ownerId | String | FK to User (creator) | +| viewType | Enum | MANUAL, ROLE_BASED | +| autoIncludeRoles | Json? | ["MAP_ADMIN", "USER"] (for ROLE_BASED) | +| includedLayerTypes | Json | ["shifts", "tickets", "personal-public"] | +| shareScope | Enum | MEMBERS, PUBLIC | +| shareToken | String? | Unique token for public share URL | +| createdAt | DateTime | | +| updatedAt | DateTime | | + +**ROLE_BASED views:** +- Auto-include users matching specified roles +- Only pull system layers (shifts, tickets, polls) — never personal layers +- No notifications sent to included users (admin operational tool) +- Created/managed by SUPER_ADMIN or MAP_ADMIN + +**MANUAL views:** +- Members are explicitly invited via notification system +- Can include personal layers (with member consent) +- Members can decline/leave + +### SharedCalendarMember + +| Field | Type | Notes | +|-------|------|-------| +| id | String (cuid) | PK | +| viewId | String | FK to SharedCalendarView | +| userId | String | FK to User | +| status | Enum | INVITED, ACCEPTED, DECLINED | +| color | String | Auto-assigned from palette | +| joinedAt | DateTime? | | +| @@unique | [viewId, userId] | | + +**Auto-color palette:** +``` +#1890ff (blue), #52c41a (green), #fa8c16 (orange), #722ed1 (purple), +#eb2f96 (pink), #13c2c2 (cyan), #faad14 (gold), #f5222d (red), +#2f54eb (geekblue), #a0d911 (lime) +``` +Assigned sequentially as members join: `PALETTE[memberIndex % length]`. +Users can override their assigned color per shared view. + +### SharedViewComment + +| Field | Type | Notes | +|-------|------|-------| +| id | String (cuid) | PK | +| viewId | String | FK to SharedCalendarView | +| userId | String | FK to User | +| itemDate | String | YYYY-MM-DD (which date this comment is about) | +| itemId | String? | Optional: specific CalendarItem or source item ID | +| content | String | Text | +| createdAt | DateTime | | + +### SharedViewReaction + +| Field | Type | Notes | +|-------|------|-------| +| id | String (cuid) | PK | +| viewId | String | FK to SharedCalendarView | +| userId | String | FK to User | +| itemId | String | CalendarItem or source item ID (e.g., "shift-abc123") | +| emoji | String | Single emoji or shortcode | +| createdAt | DateTime | | +| @@unique | [viewId, userId, itemId, emoji] | One reaction type per user per item | + +### CalendarExportToken (.ics export) + +| Field | Type | Notes | +|-------|------|-------| +| id | String (cuid) | PK | +| userId | String | FK to User | +| token | String | Unique, random (for URL auth) | +| includePersonal | Boolean | Whether personal events are exported | +| includeLayers | Json? | Array of layer IDs (null = all enabled) | +| createdAt | DateTime | | + +Export URL: `GET /api/calendar/feed/:userId/:token.ics` + +--- + +## Recurrence Model + +Uses **materialization** (consistent with existing ShiftSeries pattern): + +1. User creates a recurring event with a recurrence rule +2. System generates CalendarItem rows for the next 3 months +3. Background job (BullMQ, daily) extends series forward by 1 month +4. Individual instances can be edited (becomes `isException: true`) or deleted +5. Editing the series template updates all non-exception future instances + +### Recurrence Rule JSON + +```json +{ + "frequency": "DAILY | WEEKLY | BIWEEKLY | MONTHLY", + "daysOfWeek": [1, 3, 5], + "dayOfMonth": 15, + "interval": 1 +} +``` + +- `WEEKLY` + `daysOfWeek: [1,3,5]` = every Mon/Wed/Fri +- `MONTHLY` + `dayOfMonth: 15` = 15th of every month +- `BIWEEKLY` + `daysOfWeek: [2,4]` = every other Tue/Thu +- `interval` for skip patterns (every 2 weeks, every 3 months) + +### Recurrence Edit Options (UI) + +When editing a recurring event instance: +- "This event only" — marks as exception, edits the single instance +- "This and future events" — updates template + regenerates future non-exception instances +- "All events in series" — updates template + all instances (including past, excluding exceptions) + +When deleting: +- "This event only" — soft-delete the single instance +- "This and future events" — delete future instances, set recurrenceEnd on template +- "All events" — delete entire series + +--- + +## Time Block Visibility (Configurable per item) + +| `showDetailsTo` | Friends see | Public sees | +|-----------------|-------------|-------------| +| NOBODY | "Busy 2-4pm" | "Busy 2-4pm" | +| FRIENDS | "Dentist 2-4pm" | "Busy 2-4pm" | +| EVERYONE | "Dentist 2-4pm" | "Dentist 2-4pm" | + +Combined with `busyStatus`: +- **BUSY** — solid color block +- **TENTATIVE** — dashed/lighter block +- **FREE** — no block shown (informational only, e.g., "Available for meetings") + +--- + +## Notification Types (reusing existing system) + +| Type | Message | Trigger | +|------|---------|---------| +| SHARED_VIEW_INVITE | "Alice invited you to 'Weekend Crew' calendar" | Manual shared view invite | +| SHARED_VIEW_ACCEPTED | "Bob accepted your invite to 'Weekend Crew'" | Member accepts | +| CALENDAR_EVENT_INVITE | "Alice added you to 'Planning Meeting' on Mar 10" | Phase B: event-level sharing | +| CALENDAR_REMINDER | "Reminder: Team standup in 15 minutes" | Future: optional reminders | + +Role-based admin views do NOT trigger notifications (admin operational tool using only system data). + +--- + +## Availability Finder (Phase B) + +A dedicated mode within shared calendar views: + +1. Toggle "Find Available Time" on a shared view +2. System overlays all members' BUSY/TENTATIVE time blocks +3. Highlights gaps where ALL members are free +4. Optional: filter by time range ("only show weekday 9am-5pm slots") +5. Click a free slot to create an event and auto-invite all members + +Visual: green highlight on free slots, red/orange on conflicts, member avatars on busy blocks. + +--- + +## API Routes + +### Phase A (Personal Calendar) + +``` +# Layers +GET /api/calendar/layers — list user's layers +POST /api/calendar/layers — create custom layer +PATCH /api/calendar/layers/:id — update layer (name, color, visibility, enabled) +DELETE /api/calendar/layers/:id — delete custom layer (+ its items) + +# Calendar Items +GET /api/calendar/items — list items in date range (all enabled layers merged) +POST /api/calendar/items — create item (event, time block, reminder) +PATCH /api/calendar/items/:id — update item +DELETE /api/calendar/items/:id — delete item + +# Recurrence +POST /api/calendar/items/:id/series — edit series (this-only, this-and-future, all) +DELETE /api/calendar/items/:id/series — delete series (this-only, this-and-future, all) + +# Unified personal view (merges system layers + user items) +GET /api/calendar/my — personal calendar (date range, layer filters) +``` + +### Phase B (Sharing + Social) + +``` +# Shared Views +GET /api/calendar/shared — list shared views I own or am a member of +POST /api/calendar/shared — create shared view +PATCH /api/calendar/shared/:id — update shared view +DELETE /api/calendar/shared/:id — delete shared view (owner only) + +# Members +POST /api/calendar/shared/:id/invite — invite user(s) to shared view +PATCH /api/calendar/shared/:id/respond — accept/decline invite +DELETE /api/calendar/shared/:id/leave — leave a shared view +GET /api/calendar/shared/:id/members — list members + colors + +# Merged calendar data +GET /api/calendar/shared/:id/items — merged items from all members + +# Event-level sharing +POST /api/calendar/items/:id/share — share specific item with friend(s) + +# Comments & Reactions (on shared views) +GET /api/calendar/shared/:id/comments?date=YYYY-MM-DD +POST /api/calendar/shared/:id/comments +DELETE /api/calendar/shared/:id/comments/:commentId +POST /api/calendar/shared/:id/reactions +DELETE /api/calendar/shared/:id/reactions/:reactionId + +# Availability finder +GET /api/calendar/shared/:id/availability?start=&end=&dayStart=09:00&dayEnd=17:00 + +# Friend's public calendar +GET /api/calendar/user/:userId — view a friend's public items +``` + +### Phase C (.ics Integration) + +``` +# Feeds (import) +GET /api/calendar/feeds — list user's subscribed feeds +POST /api/calendar/feeds — subscribe to .ics URL +PATCH /api/calendar/feeds/:id — update feed settings +DELETE /api/calendar/feeds/:id — unsubscribe (deletes layer + cached items) +POST /api/calendar/feeds/:id/refresh — force refresh now + +# Export +GET /api/calendar/export/token — get or create export token +DELETE /api/calendar/export/token — revoke export token +GET /api/calendar/feed/:userId/:token.ics — public .ics feed (no auth, token in URL) +``` + +### Phase D (Admin Shared Views) + +``` +# Admin role-based views (requireRole: SUPER_ADMIN, MAP_ADMIN) +POST /api/admin/calendar/shared — create role-based shared view +PATCH /api/admin/calendar/shared/:id — update +DELETE /api/admin/calendar/shared/:id — delete +GET /api/admin/calendar/shared/:id/items — merged system-layer data for matching users +``` + +--- + +## Frontend Pages & Components + +### Phase A + +| Component | Location | Description | +|-----------|----------|-------------| +| MyCalendarPage | `volunteer/MyCalendarPage.tsx` | Personal calendar (main view) | +| CalendarLayerPanel | `components/calendar/CalendarLayerPanel.tsx` | Sidebar: layer list with toggles, colors, visibility | +| CalendarItemModal | `components/calendar/CalendarItemModal.tsx` | Create/edit event, time block, or reminder | +| RecurrenceEditor | `components/calendar/RecurrenceEditor.tsx` | Recurrence rule builder (frequency, days, end date) | +| PersonalCalendarView | `components/calendar/PersonalCalendarView.tsx` | Month/week/day calendar with layer color-coding | +| MobileDayView | `components/calendar/MobileDayView.tsx` | Day/3-day swipeable view for mobile | + +**Mobile UX:** Day or 3-day swipeable view (not full month grid). Swipe left/right to navigate days. Layer toggles in a collapsible bottom sheet. + +### Phase B + +| Component | Location | Description | +|-----------|----------|-------------| +| SharedCalendarsPage | `volunteer/SharedCalendarsPage.tsx` | List of shared views I'm in | +| SharedCalendarView | `components/calendar/SharedCalendarView.tsx` | Merged multi-user calendar with member colors | +| SharedViewMembersPanel | `components/calendar/SharedViewMembersPanel.tsx` | Member list, color overrides, invite button | +| AvailabilityFinder | `components/calendar/AvailabilityFinder.tsx` | Free/busy overlay with slot highlighting | +| CalendarComments | `components/calendar/CalendarComments.tsx` | Comment thread for a date in shared view | +| CalendarReactions | `components/calendar/CalendarReactions.tsx` | Emoji reactions on items | +| FriendCalendarPage | `volunteer/FriendCalendarPage.tsx` | View a friend's public calendar | + +### Phase C + +| Component | Location | Description | +|-----------|----------|-------------| +| CalendarFeedsPanel | `components/calendar/CalendarFeedsPanel.tsx` | Manage .ics subscriptions | +| CalendarExportPanel | `components/calendar/CalendarExportPanel.tsx` | Export token management, copy URL | + +### Phase D + +| Component | Location | Description | +|-----------|----------|-------------| +| AdminSharedViewsPage | `pages/AdminSharedViewsPage.tsx` | Admin: create/manage role-based views | +| AdminCalendarOverview | `components/calendar/AdminCalendarOverview.tsx` | Big shift/event overview for admins | + +--- + +## Navigation & Routing + +### Volunteer Portal +- Footer nav: add "Calendar" tab (CalendarOutlined icon) +- `/volunteer/calendar` — MyCalendarPage +- `/volunteer/calendar/shared` — SharedCalendarsPage +- `/volunteer/calendar/shared/:id` — SharedCalendarView +- `/volunteer/calendar/friend/:userId` — FriendCalendarPage + +### Admin +- Sidebar under existing section: "Calendar Overview" +- `/app/calendar/shared` — AdminSharedViewsPage + +--- + +## Phase Breakdown + +### Phase A: Personal Calendar + Layers + Freeform Events +**Scope:** +- [x] Prisma models: CalendarLayer, CalendarItem, CalendarFeed, SharedCalendarView, SharedCalendarMember, SharedViewComment, SharedViewReaction, CalendarExportToken (+ 12 enums) +- [x] Auto-create system layers on first calendar access (ensureSystemLayers) +- [x] CalendarItem CRUD (create, read, update, delete) +- [x] Recurrence: create series (materialize 3 months), edit/delete with scope options (THIS_ONLY/THIS_AND_FUTURE/ALL) +- [ ] BullMQ job: extend recurring series daily (add 1 month of future instances) +- [x] Personal calendar API: GET /api/calendar/my (merge system layers + user items) +- [x] System layer queries: shifts (from ShiftSignup), tickets (from Ticket), polls (from SchedulingPollVote) +- [x] Layer CRUD: create custom layers, toggle on/off, set color +- [x] Layer visibility settings (PRIVATE/FRIENDS/PUBLIC) — stored but not enforced until Phase B +- [x] MyCalendarPage: month view (desktop), day/3-day view (mobile) +- [x] CalendarLayerPanel: sidebar with layer toggles, color pickers, inline editing, grouped by type +- [x] CalendarItemModal: create/edit form with item type, recurrence, time block settings, scope selector +- [x] RecurrenceEditor: frequency/days/interval/end-date with preview text +- [x] PersonalCalendarView: desktop month view with layer-colored items +- [x] MobileDayView: day view with time grid, current time indicator, floating add button +- [x] Volunteer footer nav: "Calendar" tab (gated behind enableSocialCalendar) +- [x] Feature flag: enableSocialCalendar in SiteSettings, Zod schema, frontend types, FeatureGate +- [x] Settings page toggle added ("Social Calendar" in People & Engagement section) + +### Phase B: Sharing + Social +**Scope:** +- [ ] Prisma models: SharedCalendarView, SharedCalendarMember, SharedViewComment, SharedViewReaction +- [ ] SharedCalendarView CRUD +- [ ] Invite flow: send invite via notification system, accept/decline/leave +- [ ] Merged calendar API: query all members' items with layer type filtering +- [ ] Auto-color assignment for members +- [ ] Layer visibility enforcement (PRIVATE/FRIENDS/PUBLIC filtering based on relationship) +- [ ] Event-level sharing: share a specific item with friend(s) via notification +- [ ] Comments on shared view dates/items +- [ ] Emoji reactions on shared view items +- [ ] Availability finder: free/busy overlay, slot highlighting, time range filter +- [ ] Friend's public calendar view +- [ ] SharedCalendarsPage, SharedCalendarView components +- [ ] AvailabilityFinder component +- [ ] CalendarComments, CalendarReactions components +- [ ] Public share URL (shareToken for unauthenticated view) + +### Phase C: .ics Integration +**Scope:** +- [ ] Prisma models: CalendarFeed, CalendarExportToken +- [ ] .ics feed parser (node-ical or similar) +- [ ] BullMQ job: refresh feeds on configured intervals +- [ ] Feed CRUD: subscribe, update, delete, force refresh +- [ ] Auto-create layer per feed, cache items as CalendarItem rows +- [ ] .ics export: generate feed from user's calendar, token-authenticated URL +- [ ] Export token management (create, revoke) +- [ ] CalendarFeedsPanel, CalendarExportPanel components + +### Phase D: Admin Shared Views +**Scope:** +- [ ] Role-based SharedCalendarView (viewType: ROLE_BASED) +- [ ] Auto-include users by role(s) — query live, no member rows needed +- [ ] Only expose system layers (shifts, tickets, polls) — no personal data +- [ ] No notifications to included users +- [ ] Admin routes (requireRole: SUPER_ADMIN, MAP_ADMIN) +- [ ] AdminSharedViewsPage +- [ ] AdminCalendarOverview (big shift/event dashboard) + +--- + +## Implementation Notes + +### Extending UnifiedCalendar + +The existing `UnifiedCalendar` component and `unified-calendar.service.ts` remain as the **public** calendar. The new personal calendar service (`calendar.service.ts`) reuses the same source queries (shifts, Gancio, polls, ticketed events) but filters to the user's own records and merges with their CalendarItem rows. + +### Recurrence Background Job + +```typescript +// jobs/calendar-recurrence.job.ts +// Runs daily via BullMQ repeatable job +// 1. Find all CalendarItems with recurrenceRule where latest materialized date < now + 3 months +// 2. Generate new instances up to 3 months ahead +// 3. Skip dates that already have an instance (idempotent) +``` + +### .ics Feed Refresh Job + +```typescript +// jobs/calendar-feed-refresh.job.ts +// Runs every 15 minutes via BullMQ repeatable job +// 1. Find feeds where lastFetchedAt + refreshInterval < now +// 2. Fetch .ics URL, parse events +// 3. Upsert CalendarItem rows (match on sourceId = ics UID) +// 4. Delete items no longer in feed +// 5. Update feed status +``` + +### Privacy Boundaries + +| Scenario | What's visible | +|----------|---------------| +| Viewing own calendar | Everything (all layers, all items) | +| Friend views your calendar | Items on FRIENDS or PUBLIC visibility layers, plus items with individual FRIENDS/PUBLIC override | +| Public profile calendar | Only PUBLIC visibility layers and PUBLIC override items | +| Admin role-based view | Only system layers (shifts, tickets, polls) for users matching role filter | +| Shared view (MANUAL) | Items from includedLayerTypes on layers with appropriate visibility for the viewer | +| Time blocks (BUSY) | Title shown per showDetailsTo setting, always shows busy bar | + +### Performance Considerations + +- CalendarItem table will grow with materialized recurrence — add indexes on (userId, date), (layerId, date), (seriesId) +- System layers query source tables directly — leverage existing indexes on ShiftSignup, EventTicket, etc. +- .ics feed items are cached — only re-parsed on refresh interval +- Shared view queries can be expensive (N members x M layers) — cache merged results in Redis (2min TTL, bust on member change) +- Availability finder operates on time blocks only — narrow query scope + +--- + +## Tracking Log + +### 2026-03-06 — Planning Complete +- Brainstormed feature across 3 rounds of refinement +- Decided on layer-based architecture (system, user, external layers) +- Recurrence uses materialization (consistent with ShiftSeries pattern) +- Time block visibility is configurable per item (showDetailsTo: NOBODY/FRIENDS/EVERYONE) +- Shared views support manual (invite-based) and role-based (admin, system data only) +- Availability finder included in Phase B +- Comments and reactions on shared view items included in Phase B +- .ics import and export in Phase C +- Admin role-based views in Phase D (no personal data, no notifications) +- Reuse existing notification system for invites +- Auto-color assignment for shared view members with user override option +- Mobile UX: day/3-day swipeable view instead of month grid + +### 2026-03-06 — Phase A Implementation Complete +- Schema: 8 models, 12 enums, migration `20260306203326_social_calendar_layers_items` applied +- Fixed pre-existing migration ordering issue (ticketed_events create must come before alter) +- Backend: calendar.service.ts (layer mgmt, item CRUD, recurrence materialization, personal calendar merge), calendar.routes.ts (9 endpoints), calendar.schemas.ts (Zod validation) +- Frontend: 5 new components (CalendarLayerPanel, CalendarItemModal, RecurrenceEditor, PersonalCalendarView, MobileDayView), MyCalendarPage +- Navigation: VolunteerFooterNav Calendar tab, App.tsx route, SettingsPage toggle +- Smoke tested: layers auto-create, item CRUD works, recurring events materialize correctly (Weekly Mon/Wed/Fri generated 11 instances through June) +- Both API and Admin compile with zero TypeScript errors +- Remaining Phase A item: BullMQ job for extending recurring series (not critical for launch, series materializes 3 months on creation) diff --git a/admin/package-lock.json b/admin/package-lock.json index a012765..7a81311 100644 --- a/admin/package-lock.json +++ b/admin/package-lock.json @@ -32,6 +32,7 @@ "grapesjs-tabs": "^1.0.6", "grapesjs-touch": "^0.1.1", "grapesjs-typed": "^2.0.1", + "html5-qrcode": "^2.3.8", "jwt-decode": "^4.0.0", "leaflet": "^1.9.4", "minisearch": "^7.2.0", @@ -2606,6 +2607,11 @@ "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-1.4.0.tgz", "integrity": "sha512-8nxjcBcd8wovbeKx7h3wTji4e6+rhaVuPNpMqwWgnHh+N9ToqsCs6XztWRBPQ+UtzsoMAdKZtUENoVzU/EMtZA==" }, + "node_modules/html5-qrcode": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/html5-qrcode/-/html5-qrcode-2.3.8.tgz", + "integrity": "sha512-jsr4vafJhwoLVEDW3n1KvPnCCXWaQfRng0/EEYk1vNcQGcG/htAdhJX0be8YyqMoSz7+hZvOZSTAepsabiuhiQ==" + }, "node_modules/immer": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", diff --git a/admin/package.json b/admin/package.json index e7b944b..e1050a6 100644 --- a/admin/package.json +++ b/admin/package.json @@ -33,6 +33,7 @@ "grapesjs-tabs": "^1.0.6", "grapesjs-touch": "^0.1.1", "grapesjs-typed": "^2.0.1", + "html5-qrcode": "^2.3.8", "jwt-decode": "^4.0.0", "leaflet": "^1.9.4", "minisearch": "^7.2.0", diff --git a/admin/src/App.tsx b/admin/src/App.tsx index 82cf6c9..ab6430d 100644 --- a/admin/src/App.tsx +++ b/admin/src/App.tsx @@ -114,11 +114,27 @@ import ContactProfilePage from '@/pages/public/ContactProfilePage'; import SocialDashboardPage from '@/pages/social/SocialDashboardPage'; import SocialGraphPage from '@/pages/social/SocialGraphPage'; import SocialModerationPage from '@/pages/social/SocialModerationPage'; +import ReferralAdminPage from '@/pages/social/ReferralAdminPage'; +import SpotlightAdminPage from '@/pages/social/SpotlightAdminPage'; +import ChallengesAdminPage from '@/pages/social/ChallengesAdminPage'; +import ImpactStoriesPage from '@/pages/influence/ImpactStoriesPage'; +import ReferralsPage from '@/pages/volunteer/ReferralsPage'; +import ChallengesPage from '@/pages/volunteer/ChallengesPage'; +import ChallengeDetailPage from '@/pages/volunteer/ChallengeDetailPage'; +import WallOfFamePage from '@/pages/public/WallOfFamePage'; import MeetingJoinPage from '@/pages/public/MeetingJoinPage'; import MeetingPlannerPage from '@/pages/MeetingPlannerPage'; import SchedulingPollPage from '@/pages/public/SchedulingPollPage'; import PollsListPage from '@/pages/public/PollsListPage'; import JitsiAuthPage from '@/pages/JitsiAuthPage'; +import SchedulingCalendarPage from '@/pages/SchedulingCalendarPage'; +import TicketedEventsPage from '@/pages/events/TicketedEventsPage'; +import EventDetailPage from '@/pages/events/EventDetailPage'; +import CheckInScannerPage from '@/pages/events/CheckInScannerPage'; +import TicketedEventDetailPage from '@/pages/public/TicketedEventDetailPage'; +import TicketConfirmationPage from '@/pages/public/TicketConfirmationPage'; +import MyTicketsPage from '@/pages/volunteer/MyTicketsPage'; +import MyCalendarPage from '@/pages/volunteer/MyCalendarPage'; import NotFoundPage from '@/pages/NotFoundPage'; import CommandPalette from '@/components/command-palette/CommandPalette'; @@ -228,6 +244,9 @@ export default function App() { }> } /> + }> + } /> + {/* Scheduling polls — feature-gated */} }> } /> @@ -236,6 +255,14 @@ export default function App() { } /> + {/* Public ticketed event pages — feature-gated */} + }> + } /> + + }> + } /> + + {/* Public meeting join page — feature-gated */} }> } /> @@ -318,6 +345,11 @@ export default function App() { } /> } /> } /> + } /> + } /> + } /> + } /> + } /> } /> } /> @@ -328,6 +360,18 @@ export default function App() { element={} /> + {/* Full-screen check-in scanner (outside AppLayout) */} + + + + + + } + /> + } /> } /> } /> @@ -388,6 +432,36 @@ export default function App() { } /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> } /> + + + + } + /> } /> + + + + } + /> + + + + + + } + /> + + + + + + } + /> , label: 'Social Dashboard' }, { key: '/app/social/graph', icon: , label: 'Social Graph' }, { key: '/app/social/moderation', icon: , label: 'Social Moderation' }, + { key: '/app/social/referrals', icon: , label: 'Referrals' }, + { key: '/app/social/spotlights', icon: , label: 'Spotlights' }, + { key: '/app/social/challenges', icon: , label: 'Challenges' }, ); } items.push({ @@ -193,6 +199,7 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, isS { key: '/app/email-queue', icon: , label: badges?.pendingEmails ? Outgoing Emails : 'Outgoing Emails' }, { key: '/app/responses', icon: , label: badges?.pendingResponses ? Responses : 'Responses' }, { key: '/app/influence/effectiveness', icon: , label: 'Effectiveness' }, + { key: '/app/influence/stories', icon: , label: 'Impact Stories' }, ], }); } @@ -257,8 +264,8 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, isS }); } - // Scheduling submenu — visible if either Shifts (enableMap) or Meeting Planner is enabled - if (settings?.enableMap !== false || settings?.enableMeetingPlanner) { + // Scheduling submenu — visible if Shifts, Meeting Planner, or Ticketed Events is enabled + if (settings?.enableMap !== false || settings?.enableMeetingPlanner || settings?.enableTicketedEvents) { const schedulingChildren: any[] = []; if (settings?.enableMap !== false) { schedulingChildren.push({ key: '/app/map/shifts', icon: , label: 'Shifts' }); @@ -266,6 +273,11 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, isS if (settings?.enableMeetingPlanner) { schedulingChildren.push({ key: '/app/meeting-planner', icon: , label: 'Meeting Planner' }); } + if (settings?.enableTicketedEvents) { + schedulingChildren.push({ key: '/app/events', icon: , label: 'Events' }); + } + // Always add Calendar as the last item in scheduling + schedulingChildren.push({ key: '/app/scheduling/calendar', icon: , label: 'Calendar' }); if (schedulingChildren.length > 0) { items.push({ key: 'scheduling-submenu', diff --git a/admin/src/components/FeatureGate.tsx b/admin/src/components/FeatureGate.tsx index f6ea09c..3e94d14 100644 --- a/admin/src/components/FeatureGate.tsx +++ b/admin/src/components/FeatureGate.tsx @@ -20,10 +20,12 @@ const FEATURE_LABELS: Record = { enableSocial: 'Social Connections', enableMeet: 'Video Meetings', enableMeetingPlanner: 'Meeting Planner', + enableTicketedEvents: 'Ticketed Events', + enableSocialCalendar: 'Social Calendar', }; interface FeatureGateProps { - feature: keyof Pick; + feature: keyof Pick; children: ReactNode; } diff --git a/admin/src/components/PublicLayout.tsx b/admin/src/components/PublicLayout.tsx index a5b0082..01c9a2b 100644 --- a/admin/src/components/PublicLayout.tsx +++ b/admin/src/components/PublicLayout.tsx @@ -32,6 +32,7 @@ export default function PublicLayout() { } if (settings?.enableMediaFeatures !== false) links.push({ label: 'Gallery', path: '/gallery' }); if (settings?.enablePayments) links.push({ label: 'Donate', path: '/donate' }); + if (settings?.enableSocial) links.push({ label: 'Wall of Fame', path: '/wall-of-fame' }); return links; } diff --git a/admin/src/components/VolunteerFooterNav.tsx b/admin/src/components/VolunteerFooterNav.tsx index e672483..0e087e3 100644 --- a/admin/src/components/VolunteerFooterNav.tsx +++ b/admin/src/components/VolunteerFooterNav.tsx @@ -8,6 +8,8 @@ import { NodeIndexOutlined, MessageOutlined, TeamOutlined, + TagOutlined, + CalendarOutlined, MenuOutlined, } from '@ant-design/icons'; import { useSettingsStore } from '@/stores/settings.store'; @@ -33,6 +35,12 @@ export default function VolunteerFooterNav({ style, onMenuOpen, menuActive = fal const NAV_ITEMS = useMemo(() => { const items = [...BASE_NAV_ITEMS]; + if (settings?.enableSocialCalendar) { + items.push({ key: '/volunteer/calendar', icon: CalendarOutlined, label: 'Calendar' }); + } + if (settings?.enableTicketedEvents) { + items.push({ key: '/volunteer/tickets', icon: TagOutlined, label: 'Tickets' }); + } if (settings?.enableSocial) { items.push({ key: '/volunteer/feed', icon: TeamOutlined, label: 'Social' }); } @@ -40,7 +48,7 @@ export default function VolunteerFooterNav({ style, onMenuOpen, menuActive = fal items.push({ key: '/volunteer/chat', icon: MessageOutlined, label: 'Chat' }); } return items; - }, [settings?.enableChat, settings?.enableSocial]); + }, [settings?.enableChat, settings?.enableSocial, settings?.enableSocialCalendar, settings?.enableTicketedEvents]); const activeKey = (() => { const path = location.pathname; diff --git a/admin/src/components/calendar/CalendarItemModal.tsx b/admin/src/components/calendar/CalendarItemModal.tsx new file mode 100644 index 0000000..a400d45 --- /dev/null +++ b/admin/src/components/calendar/CalendarItemModal.tsx @@ -0,0 +1,479 @@ +import { useState, useEffect, useMemo } from 'react'; +import { + Modal, + Form, + Input, + DatePicker, + TimePicker, + Select, + Switch, + Collapse, + Radio, + Button, + Typography, + Space, +} from 'antd'; +import { + CalendarOutlined, + ClockCircleOutlined, + BellOutlined, + BlockOutlined, + EnvironmentOutlined, + DeleteOutlined, +} from '@ant-design/icons'; +import dayjs from 'dayjs'; +import RecurrenceEditor from './RecurrenceEditor'; +import type { + CalendarLayer, + PersonalCalendarItem, + CalendarItemType, + CalendarVisibility, + CalendarBusyStatus, + CalendarShowDetailsTo, + CalendarRecurrenceRule, + SeriesEditScope, +} from '@/types/api'; + +const { TextArea } = Input; +const { Text } = Typography; + +const PRESET_COLORS = [ + '#1890ff', '#52c41a', '#fa8c16', '#722ed1', '#eb2f96', + '#13c2c2', '#f5222d', '#faad14', '#2f54eb', '#a0d911', +]; + +const ITEM_TYPE_OPTIONS: { value: CalendarItemType; label: string; icon: React.ReactNode }[] = [ + { value: 'EVENT', label: 'Event', icon: }, + { value: 'TIME_BLOCK', label: 'Time Block', icon: }, + { value: 'REMINDER', label: 'Reminder', icon: }, +]; + +export interface CalendarItemFormData { + layerId: string; + title: string; + description?: string; + date: string; + startTime: string; + endTime: string; + isAllDay: boolean; + itemType: CalendarItemType; + location?: string; + color?: string; + visibility?: CalendarVisibility | null; + busyStatus: CalendarBusyStatus; + showDetailsTo: CalendarShowDetailsTo; + recurrenceRule?: CalendarRecurrenceRule | null; + recurrenceEnd?: string | null; +} + +interface CalendarItemModalProps { + open: boolean; + onCancel: () => void; + onSave: (data: CalendarItemFormData, scope?: SeriesEditScope) => void; + onDelete?: () => void; + item?: PersonalCalendarItem | null; + defaultDate?: string | null; + layers: CalendarLayer[]; + loading?: boolean; +} + +export default function CalendarItemModal({ + open, + onCancel, + onSave, + onDelete, + item, + defaultDate, + layers, + loading, +}: CalendarItemModalProps) { + const [form] = Form.useForm(); + const [itemType, setItemType] = useState('EVENT'); + const [isAllDay, setIsAllDay] = useState(false); + const [colorOverride, setColorOverride] = useState(undefined); + const [recurrenceRule, setRecurrenceRule] = useState(null); + const [recurrenceEnd, setRecurrenceEnd] = useState(null); + const [editScope, setEditScope] = useState('THIS_ONLY'); + + const isEditing = !!item; + const isRecurringEdit = isEditing && !!item?.seriesId; + + const userLayers = useMemo( + () => layers.filter((l) => l.layerType === 'USER'), + [layers], + ); + + // Reset form when modal opens + useEffect(() => { + if (!open) return; + + if (item) { + form.setFieldsValue({ + title: item.title ?? '', + layerId: item.layerId ?? userLayers[0]?.id, + date: item.date ? dayjs(item.date) : dayjs(), + startTime: item.startTime ? dayjs(item.startTime, 'HH:mm') : dayjs('09:00', 'HH:mm'), + endTime: item.endTime ? dayjs(item.endTime, 'HH:mm') : dayjs('10:00', 'HH:mm'), + description: '', + location: item.location ?? '', + visibility: null, + busyStatus: item.busyStatus ?? 'BUSY', + showDetailsTo: item.showDetailsTo ?? 'FRIENDS', + }); + setItemType(item.itemType ?? 'EVENT'); + setIsAllDay(item.isAllDay ?? false); + setColorOverride(item.color !== '#1890ff' ? item.color : undefined); + setRecurrenceRule(null); + setRecurrenceEnd(null); + } else { + form.resetFields(); + const date = defaultDate ? dayjs(defaultDate) : dayjs(); + form.setFieldsValue({ + date, + startTime: dayjs('09:00', 'HH:mm'), + endTime: dayjs('10:00', 'HH:mm'), + layerId: userLayers[0]?.id, + busyStatus: 'BUSY', + showDetailsTo: 'FRIENDS', + visibility: null, + }); + setItemType('EVENT'); + setIsAllDay(false); + setColorOverride(undefined); + setRecurrenceRule(null); + setRecurrenceEnd(null); + } + setEditScope('THIS_ONLY'); + }, [open, item, defaultDate, userLayers, form]); + + const handleFinish = (values: Record) => { + const data: CalendarItemFormData = { + layerId: values.layerId as string, + title: values.title as string, + description: (values.description as string) || undefined, + date: (values.date as dayjs.Dayjs).format('YYYY-MM-DD'), + startTime: isAllDay ? '00:00' : (values.startTime as dayjs.Dayjs).format('HH:mm'), + endTime: isAllDay ? '23:59' : (values.endTime as dayjs.Dayjs).format('HH:mm'), + isAllDay, + itemType, + location: (values.location as string) || undefined, + color: colorOverride, + visibility: (values.visibility as CalendarVisibility | null) ?? null, + busyStatus: (values.busyStatus as CalendarBusyStatus) ?? 'BUSY', + showDetailsTo: (values.showDetailsTo as CalendarShowDetailsTo) ?? 'FRIENDS', + recurrenceRule: recurrenceRule ?? undefined, + recurrenceEnd: recurrenceEnd ?? undefined, + }; + + onSave(data, isRecurringEdit ? editScope : undefined); + }; + + return ( + +
+ {/* Item type selector */} +
+ {ITEM_TYPE_OPTIONS.map((opt) => { + const selected = itemType === opt.value; + return ( +
setItemType(opt.value)} + style={{ + flex: 1, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + gap: 6, + padding: '8px 12px', + borderRadius: 8, + cursor: 'pointer', + userSelect: 'none', + fontSize: 13, + fontWeight: selected ? 600 : 400, + background: selected ? 'rgba(24, 144, 255, 0.15)' : 'rgba(255,255,255,0.04)', + border: selected + ? '1px solid rgba(24, 144, 255, 0.4)' + : '1px solid rgba(255,255,255,0.1)', + color: selected ? '#1890ff' : 'rgba(255,255,255,0.65)', + transition: 'all 0.2s', + }} + > + {opt.icon} + {opt.label} +
+ ); + })} +
+ + + + + + + } placeholder="Optional location" /> + + )} + + +