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" /> + + )} + + +