Compare commits

..

No commits in common. "eba645398159a41ffde2d8fe14f5d9d2917f28f2" and "e7890b0be19b7acf44a6cb4b26306a32aa60b82c" have entirely different histories.

311 changed files with 5125 additions and 64270 deletions

View File

@ -106,7 +106,6 @@ REPRESENT_API_URL=https://represent.opennorth.ca
# --- NocoDB v2 (read-only data browser) --- # --- NocoDB v2 (read-only data browser) ---
# NocoDB uses its own database (nocodb_meta) to avoid conflicts with Prisma # NocoDB uses its own database (nocodb_meta) to avoid conflicts with Prisma
# The database is auto-created by init-nocodb-db.sh on first PostgreSQL startup # The database is auto-created by init-nocodb-db.sh on first PostgreSQL startup
# nocodb-init container auto-registers changemaker_v2 as a browsable data source
NOCODB_V2_PORT=8091 NOCODB_V2_PORT=8091
NOCODB_URL=http://changemaker-v2-nocodb:8080 NOCODB_URL=http://changemaker-v2-nocodb:8080
NOCODB_PORT=8091 NOCODB_PORT=8091

3
.gitignore vendored
View File

@ -38,9 +38,6 @@ node_modules/
# Media files (managed by Docker volumes, not git) # Media files (managed by Docker volumes, not git)
/media/ /media/
# Nginx generated configs (built from *.template at container startup)
nginx/conf.d/*.conf
# Ansible per-instance override (generated by Bunker Ops) # Ansible per-instance override (generated by Bunker Ops)
docker-compose.override.yml docker-compose.override.yml

View File

@ -1,569 +0,0 @@
# 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:**
- [x] Prisma models: CalendarFeed, CalendarExportToken (already existed from Phase A migration)
- [x] .ics feed parser (node-ical v0.25.5)
- [x] BullMQ job: refresh feeds every 15 minutes (calendar-feed-refresh queue)
- [x] Feed CRUD: subscribe, update, delete, force refresh
- [x] Auto-create EXTERNAL layer per feed, cache items as CalendarItem rows (sourceType: ICS_FEED)
- [x] .ics export: generate feed from user's calendar via ical-generator v10, token-authenticated URL
- [x] Export token management (create, list, revoke)
- [x] CalendarFeedsPanel, CalendarExportPanel components
- [x] MyCalendarPage settings Drawer integration (gear icon)
### 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)
### 2026-03-07 — Phase C Implementation Complete
- Backend: feed.schemas.ts (3 Zod schemas), feed.service.ts (feed CRUD, ICS parsing, RRULE materialization, export generation), feed.routes.ts (1 public + 8 auth routes), calendar-feed-queue.service.ts (BullMQ 15min repeatable job)
- Dependencies: node-ical v0.25.5 (ICS parsing), ical-generator v10.0.0 (ICS output)
- Feed import: streaming body read with 5MB limit, 1000 event cap, RRULE materialization via rrule.between(), stale event cleanup, status tracking (OK/ERROR/PENDING)
- Feed export: 32-byte random token, configurable layer/personal inclusion, past 1 month + future 3 months, standard iCalendar output with Content-Type: text/calendar
- Frontend: CalendarFeedsPanel (add/edit/delete/refresh with status badges), CalendarExportPanel (create/copy/revoke tokens), settings Drawer in MyCalendarPage (gear icon)
- Types: CalendarFeed, CalendarExportToken, CalendarFeedStatus, CalendarFeedInterval added to admin/src/types/api.ts
- server.ts: feedRoutes mounted before calendarRoutes (public .ics route needs no auth), queue worker started on bootstrap, graceful shutdown
- Smoke tested: Google US Holidays feed → 317 events imported with status OK; export token → valid .ics with VEVENT entries; revoke → 404
- Docker gotcha: anonymous volume `/app/node_modules` caches old dependencies — must `docker compose rm -sf api` to clear when adding new npm packages
- Both API and Admin compile with zero TypeScript errors

View File

@ -32,7 +32,6 @@
"grapesjs-tabs": "^1.0.6", "grapesjs-tabs": "^1.0.6",
"grapesjs-touch": "^0.1.1", "grapesjs-touch": "^0.1.1",
"grapesjs-typed": "^2.0.1", "grapesjs-typed": "^2.0.1",
"html5-qrcode": "^2.3.8",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"minisearch": "^7.2.0", "minisearch": "^7.2.0",
@ -2607,11 +2606,6 @@
"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-1.4.0.tgz", "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-1.4.0.tgz",
"integrity": "sha512-8nxjcBcd8wovbeKx7h3wTji4e6+rhaVuPNpMqwWgnHh+N9ToqsCs6XztWRBPQ+UtzsoMAdKZtUENoVzU/EMtZA==" "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": { "node_modules/immer": {
"version": "10.2.0", "version": "10.2.0",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",

View File

@ -33,7 +33,6 @@
"grapesjs-tabs": "^1.0.6", "grapesjs-tabs": "^1.0.6",
"grapesjs-touch": "^0.1.1", "grapesjs-touch": "^0.1.1",
"grapesjs-typed": "^2.0.1", "grapesjs-typed": "^2.0.1",
"html5-qrcode": "^2.3.8",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"minisearch": "^7.2.0", "minisearch": "^7.2.0",

View File

@ -1,28 +0,0 @@
<!DOCTYPE html>
<html><head><title>Auth Check</title></head>
<body>
<script>
// This page is loaded in a hidden iframe from the MkDocs header.
// It reads the auth state from this origin's localStorage and
// posts it back to the parent window via postMessage.
(function() {
var authenticated = false;
try {
var stored = localStorage.getItem('cml-auth');
if (stored) {
var parsed = JSON.parse(stored);
if (parsed && parsed.state && parsed.state.accessToken) {
authenticated = true;
}
}
} catch(e) {}
if (window.parent && window.parent !== window) {
window.parent.postMessage({
type: 'cml-auth-status',
authenticated: authenticated
}, '*');
}
})();
</script>
</body>
</html>

View File

@ -114,32 +114,11 @@ import ContactProfilePage from '@/pages/public/ContactProfilePage';
import SocialDashboardPage from '@/pages/social/SocialDashboardPage'; import SocialDashboardPage from '@/pages/social/SocialDashboardPage';
import SocialGraphPage from '@/pages/social/SocialGraphPage'; import SocialGraphPage from '@/pages/social/SocialGraphPage';
import SocialModerationPage from '@/pages/social/SocialModerationPage'; 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 MeetingJoinPage from '@/pages/public/MeetingJoinPage';
import MeetingPlannerPage from '@/pages/MeetingPlannerPage'; import MeetingPlannerPage from '@/pages/MeetingPlannerPage';
import SchedulingPollPage from '@/pages/public/SchedulingPollPage'; import SchedulingPollPage from '@/pages/public/SchedulingPollPage';
import PollsListPage from '@/pages/public/PollsListPage'; import PollsListPage from '@/pages/public/PollsListPage';
import JitsiAuthPage from '@/pages/JitsiAuthPage'; import JitsiAuthPage from '@/pages/JitsiAuthPage';
import SchedulingCalendarPage from '@/pages/SchedulingCalendarPage';
import AdminCalendarPage from '@/pages/AdminCalendarPage';
import AdminCalendarViewPage from '@/pages/AdminCalendarViewPage';
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 SharedCalendarsPage from '@/pages/volunteer/SharedCalendarsPage';
import SharedCalendarViewPage from '@/pages/volunteer/SharedCalendarViewPage';
import FriendCalendarPage from '@/pages/volunteer/FriendCalendarPage';
import NotFoundPage from '@/pages/NotFoundPage'; import NotFoundPage from '@/pages/NotFoundPage';
import CommandPalette from '@/components/command-palette/CommandPalette'; import CommandPalette from '@/components/command-palette/CommandPalette';
@ -249,9 +228,6 @@ export default function App() {
<Route path="/events" element={<FeatureGate feature="enableEvents"><PublicLayout /></FeatureGate>}> <Route path="/events" element={<FeatureGate feature="enableEvents"><PublicLayout /></FeatureGate>}>
<Route index element={<EventsPage />} /> <Route index element={<EventsPage />} />
</Route> </Route>
<Route path="/wall-of-fame" element={<FeatureGate feature="enableSocial"><PublicLayout /></FeatureGate>}>
<Route index element={<WallOfFamePage />} />
</Route>
{/* Scheduling polls — feature-gated */} {/* Scheduling polls — feature-gated */}
<Route path="/polls" element={<FeatureGate feature="enableMeetingPlanner"><PublicLayout /></FeatureGate>}> <Route path="/polls" element={<FeatureGate feature="enableMeetingPlanner"><PublicLayout /></FeatureGate>}>
<Route index element={<PollsListPage />} /> <Route index element={<PollsListPage />} />
@ -260,14 +236,6 @@ export default function App() {
<Route index element={<SchedulingPollPage />} /> <Route index element={<SchedulingPollPage />} />
</Route> </Route>
{/* Public ticketed event pages — feature-gated */}
<Route path="/event/:slug" element={<FeatureGate feature="enableTicketedEvents"><PublicLayout /></FeatureGate>}>
<Route index element={<TicketedEventDetailPage />} />
</Route>
<Route path="/event/:slug/ticket/:ticketCode" element={<FeatureGate feature="enableTicketedEvents"><PublicLayout /></FeatureGate>}>
<Route index element={<TicketConfirmationPage />} />
</Route>
{/* Public meeting join page — feature-gated */} {/* Public meeting join page — feature-gated */}
<Route path="/meet/:slug" element={<FeatureGate feature="enableMeet"><PublicLayout /></FeatureGate>}> <Route path="/meet/:slug" element={<FeatureGate feature="enableMeet"><PublicLayout /></FeatureGate>}>
<Route index element={<MeetingJoinPage />} /> <Route index element={<MeetingJoinPage />} />
@ -331,16 +299,6 @@ export default function App() {
} }
/> />
{/* Full-screen volunteer chat — outside VolunteerLayout for max screen space */}
<Route
path="/volunteer/chat"
element={
<ProtectedRoute>
<VolunteerChatPage />
</ProtectedRoute>
}
/>
{/* Volunteer pages with VolunteerLayout */} {/* Volunteer pages with VolunteerLayout */}
<Route <Route
element={ element={
@ -360,14 +318,7 @@ export default function App() {
<Route path="/volunteer/notifications" element={<NotificationsPage />} /> <Route path="/volunteer/notifications" element={<NotificationsPage />} />
<Route path="/volunteer/groups/:id" element={<GroupDetailPage />} /> <Route path="/volunteer/groups/:id" element={<GroupDetailPage />} />
<Route path="/volunteer/achievements" element={<AchievementsPage />} /> <Route path="/volunteer/achievements" element={<AchievementsPage />} />
<Route path="/volunteer/referrals" element={<ReferralsPage />} /> <Route path="/volunteer/chat" element={<VolunteerChatPage />} />
<Route path="/volunteer/challenges" element={<ChallengesPage />} />
<Route path="/volunteer/challenges/:id" element={<ChallengeDetailPage />} />
<Route path="/volunteer/tickets" element={<MyTicketsPage />} />
<Route path="/volunteer/calendar/shared/:id" element={<SharedCalendarViewPage />} />
<Route path="/volunteer/calendar/shared" element={<SharedCalendarsPage />} />
<Route path="/volunteer/calendar/friend/:userId" element={<FriendCalendarPage />} />
<Route path="/volunteer/calendar" element={<MyCalendarPage />} />
<Route path="/volunteer/*" element={<NotFoundPage />} /> <Route path="/volunteer/*" element={<NotFoundPage />} />
</Route> </Route>
@ -377,18 +328,6 @@ export default function App() {
element={<NavigateToCutMap />} element={<NavigateToCutMap />}
/> />
{/* Full-screen check-in scanner (outside AppLayout) */}
<Route
path="/app/events/:id/checkin"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<FeatureGate feature="enableTicketedEvents">
<CheckInScannerPage />
</FeatureGate>
</ProtectedRoute>
}
/>
<Route path="/join" element={<QuickJoinPage />} /> <Route path="/join" element={<QuickJoinPage />} />
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/jitsi-auth/:room" element={<JitsiAuthPage />} /> <Route path="/jitsi-auth/:room" element={<JitsiAuthPage />} />
@ -449,36 +388,6 @@ export default function App() {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="social/referrals"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<FeatureGate feature="enableSocial">
<ReferralAdminPage />
</FeatureGate>
</ProtectedRoute>
}
/>
<Route
path="social/spotlights"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<FeatureGate feature="enableSocial">
<SpotlightAdminPage />
</FeatureGate>
</ProtectedRoute>
}
/>
<Route
path="social/challenges"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<FeatureGate feature="enableSocial">
<ChallengesAdminPage />
</FeatureGate>
</ProtectedRoute>
}
/>
<Route <Route
path="campaigns" path="campaigns"
element={ element={
@ -535,14 +444,6 @@ export default function App() {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="influence/stories"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<ImpactStoriesPage />
</ProtectedRoute>
}
/>
<Route <Route
path="listmonk" path="listmonk"
element={ element={
@ -792,50 +693,6 @@ export default function App() {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="scheduling/calendar-views/:id"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<AdminCalendarViewPage />
</ProtectedRoute>
}
/>
<Route
path="scheduling/calendar-views"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<AdminCalendarPage />
</ProtectedRoute>
}
/>
<Route
path="scheduling/calendar"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<SchedulingCalendarPage />
</ProtectedRoute>
}
/>
<Route
path="events"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<FeatureGate feature="enableTicketedEvents">
<TicketedEventsPage />
</FeatureGate>
</ProtectedRoute>
}
/>
<Route
path="events/:id"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<FeatureGate feature="enableTicketedEvents">
<EventDetailPage />
</FeatureGate>
</ProtectedRoute>
}
/>
<Route <Route
path="map/cuts" path="map/cuts"
element={ element={

View File

@ -15,6 +15,7 @@ import {
MenuFoldOutlined, MenuFoldOutlined,
MenuUnfoldOutlined, MenuUnfoldOutlined,
MenuOutlined, MenuOutlined,
HomeOutlined,
ScissorOutlined, ScissorOutlined,
CalendarOutlined, CalendarOutlined,
ScheduleOutlined, ScheduleOutlined,
@ -51,9 +52,6 @@ import {
SafetyOutlined, SafetyOutlined,
StarFilled, StarFilled,
StarOutlined, StarOutlined,
TrophyOutlined,
FlagOutlined,
UserAddOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import type { MenuProps } from 'antd'; import type { MenuProps } from 'antd';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
@ -63,14 +61,6 @@ import { hasAnyRole } from '@/utils/roles';
import type { PageHeaderConfig, AppOutletContext } from '@/types/api'; import type { PageHeaderConfig, AppOutletContext } from '@/types/api';
import { buildHomeUrl, resolveNavUrl } from '@/lib/service-url'; import { buildHomeUrl, resolveNavUrl } from '@/lib/service-url';
import type { NavItem } from '@/types/api'; import type { NavItem } from '@/types/api';
import {
DEFAULT_NAV_ITEMS,
ICON_MAP,
mergeNavDefaults,
filterNavItems,
buildFeatureFlags,
applyAdminOverrides,
} from '@/lib/nav-defaults';
import { useCommandPaletteStore } from '@/stores/command-palette.store'; import { useCommandPaletteStore } from '@/stores/command-palette.store';
import { useFavoritesStore } from '@/stores/favorites.store'; import { useFavoritesStore } from '@/stores/favorites.store';
import { resolveValidFavorites, collectLeafKeys } from '@/utils/menu-items'; import { resolveValidFavorites, collectLeafKeys } from '@/utils/menu-items';
@ -116,12 +106,50 @@ const { Header, Sider, Content } = Layout;
const { Text } = Typography; const { Text } = Typography;
const { useBreakpoint } = Grid; const { useBreakpoint } = Grid;
/** Admin icon overrides: some icons differ in the admin header context */ /** Default nav items for the admin header when navConfig is null */
const ADMIN_ICON_OVERRIDES: Record<string, React.ReactNode> = { const DEFAULT_ADMIN_NAV_ITEMS: NavItem[] = [
{ id: 'home', label: 'Home', path: '/', icon: 'HomeOutlined', enabled: true, order: 0, type: 'builtin', external: true },
{ id: 'campaigns', label: 'Campaigns', path: '/campaigns', icon: 'SendOutlined', enabled: true, order: 1, type: 'builtin', featureFlag: 'enableInfluence' },
{ id: 'map', label: 'Map', path: '/map', icon: 'EnvironmentOutlined', enabled: true, order: 2, type: 'builtin', featureFlag: 'enableMap' },
{ id: 'shifts', label: 'Shifts', path: '/shifts', icon: 'ScheduleOutlined', enabled: true, order: 3, type: 'builtin', featureFlag: 'enableMap' },
{ id: 'polls', label: 'Polls', path: '/polls', icon: 'CalendarOutlined', enabled: true, order: 4, type: 'builtin', featureFlag: 'enableMeetingPlanner' },
{ id: 'gallery', label: 'Gallery', path: '/gallery', icon: 'PlayCircleOutlined', enabled: true, order: 5, type: 'builtin', featureFlag: 'enableMediaFeatures' },
{ id: 'pricing', label: 'Pricing', path: '/pricing', icon: 'DollarOutlined', enabled: true, order: 6, type: 'builtin', featureFlag: 'enablePayments' },
{ id: 'shop', label: 'Shop', path: '/shop', icon: 'ShoppingOutlined', enabled: true, order: 7, type: 'builtin', featureFlag: 'enablePayments' },
{ id: 'donate', label: 'Donate', path: '/donate', icon: 'HeartOutlined', enabled: true, order: 8, type: 'builtin', featureFlag: 'enablePayments' },
{ id: 'landing', label: 'Website', path: '$landing', icon: 'GlobalOutlined', enabled: false, order: 9, type: 'builtin', external: true },
{ id: 'docs', label: 'Docs', path: '$docs', icon: 'BookOutlined', enabled: false, order: 10, type: 'builtin', external: true },
];
/** Map icon string IDs to Ant Design icon components for the admin header */
const ADMIN_ICON_MAP: Record<string, React.ReactNode> = {
HomeOutlined: <HomeOutlined />,
SendOutlined: <SoundOutlined />, SendOutlined: <SoundOutlined />,
EnvironmentOutlined: <EnvironmentOutlined />,
CalendarOutlined: <CalendarOutlined />,
ScheduleOutlined: <ScheduleOutlined />,
PlayCircleOutlined: <PlaySquareOutlined />, PlayCircleOutlined: <PlaySquareOutlined />,
HeartOutlined: <HeartOutlined />,
DollarOutlined: <DollarOutlined />,
ShoppingOutlined: <ShoppingOutlined />,
LinkOutlined: <GlobalOutlined />,
GlobalOutlined: <GlobalOutlined />,
BookOutlined: <BookOutlined />,
}; };
/** Merge missing builtin defaults into stored navConfig items and sync icons */
function mergeAdminNavDefaults(stored: NavItem[]): NavItem[] {
const defaultMap = new Map(DEFAULT_ADMIN_NAV_ITEMS.filter(d => d.type === 'builtin').map(d => [d.id, d]));
// Sync icon for existing builtin items so code-level icon changes propagate
const synced = stored.map(item => {
const def = defaultMap.get(item.id);
return (def && item.type === 'builtin') ? { ...item, icon: def.icon } : item;
});
const ids = new Set(synced.map(i => i.id));
const missing = DEFAULT_ADMIN_NAV_ITEMS.filter(d => d.type === 'builtin' && !ids.has(d.id));
return missing.length > 0 ? [...synced, ...missing] : synced;
}
function buildMenuItems(settings: import('@/types/api').SiteSettings | null, isSuperAdmin: boolean, badges?: { pendingResponses?: number; pendingEmails?: number; pendingCampaignReview?: number; pendingComments?: number }): MenuProps['items'] { function buildMenuItems(settings: import('@/types/api').SiteSettings | null, isSuperAdmin: boolean, badges?: { pendingResponses?: number; pendingEmails?: number; pendingCampaignReview?: number; pendingComments?: number }): MenuProps['items'] {
const items: MenuProps['items'] = [ const items: MenuProps['items'] = [
{ {
@ -143,9 +171,6 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, isS
{ key: '/app/social', icon: <HeartOutlined />, label: 'Social Dashboard' }, { key: '/app/social', icon: <HeartOutlined />, label: 'Social Dashboard' },
{ key: '/app/social/graph', icon: <ApartmentOutlined />, label: 'Social Graph' }, { key: '/app/social/graph', icon: <ApartmentOutlined />, label: 'Social Graph' },
{ key: '/app/social/moderation', icon: <SafetyOutlined />, label: 'Social Moderation' }, { key: '/app/social/moderation', icon: <SafetyOutlined />, label: 'Social Moderation' },
{ key: '/app/social/referrals', icon: <UserAddOutlined />, label: 'Referrals' },
{ key: '/app/social/spotlights', icon: <StarOutlined />, label: 'Spotlights' },
{ key: '/app/social/challenges', icon: <FlagOutlined />, label: 'Challenges' },
); );
} }
items.push({ items.push({
@ -168,7 +193,6 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, isS
{ key: '/app/email-queue', icon: <MailOutlined />, label: badges?.pendingEmails ? <Badge count={badges.pendingEmails} size="small" offset={[8, 0]}>Outgoing Emails</Badge> : 'Outgoing Emails' }, { key: '/app/email-queue', icon: <MailOutlined />, label: badges?.pendingEmails ? <Badge count={badges.pendingEmails} size="small" offset={[8, 0]}>Outgoing Emails</Badge> : 'Outgoing Emails' },
{ key: '/app/responses', icon: <MessageOutlined />, label: badges?.pendingResponses ? <Badge count={badges.pendingResponses} size="small" offset={[8, 0]}>Responses</Badge> : 'Responses' }, { key: '/app/responses', icon: <MessageOutlined />, label: badges?.pendingResponses ? <Badge count={badges.pendingResponses} size="small" offset={[8, 0]}>Responses</Badge> : 'Responses' },
{ key: '/app/influence/effectiveness', icon: <LineChartOutlined />, label: 'Effectiveness' }, { key: '/app/influence/effectiveness', icon: <LineChartOutlined />, label: 'Effectiveness' },
{ key: '/app/influence/stories', icon: <TrophyOutlined />, label: 'Impact Stories' },
], ],
}); });
} }
@ -178,9 +202,6 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, isS
{ key: '/app/listmonk', icon: <MailOutlined />, label: 'Newsletter' }, { key: '/app/listmonk', icon: <MailOutlined />, label: 'Newsletter' },
{ key: '/app/email-templates', icon: <FileTextOutlined />, label: 'Email Templates' }, { key: '/app/email-templates', icon: <FileTextOutlined />, label: 'Email Templates' },
]; ];
if (settings?.enableChat) {
broadcastChildren.push({ key: '/app/services/rocketchat', icon: <MessageOutlined />, label: 'Team Chat' });
}
if (settings?.enableSms || isSuperAdmin) { if (settings?.enableSms || isSuperAdmin) {
broadcastChildren.push( broadcastChildren.push(
{ key: '/app/sms/setup', icon: <SettingOutlined />, label: 'SMS Setup' }, { key: '/app/sms/setup', icon: <SettingOutlined />, label: 'SMS Setup' },
@ -236,27 +257,15 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, isS
}); });
} }
// Scheduling submenu — visible if Shifts, Meeting Planner, or Ticketed Events is enabled // Scheduling submenu — visible if either Shifts (enableMap) or Meeting Planner is enabled
if (settings?.enableMap !== false || settings?.enableMeetingPlanner || settings?.enableTicketedEvents || settings?.enableMeet || settings?.enableEvents) { if (settings?.enableMap !== false || settings?.enableMeetingPlanner) {
const schedulingChildren: any[] = []; const schedulingChildren: any[] = [];
if (settings?.enableMap !== false) { if (settings?.enableMap !== false) {
schedulingChildren.push({ key: '/app/map/shifts', icon: <ScheduleOutlined />, label: 'Shifts' }); schedulingChildren.push({ key: '/app/map/shifts', icon: <ScheduleOutlined />, label: 'Shifts' });
} }
if (settings?.enableMeetingPlanner) { if (settings?.enableMeetingPlanner) {
schedulingChildren.push({ key: '/app/meeting-planner', icon: <ScheduleOutlined />, label: 'Meeting Planner' }); schedulingChildren.push({ key: '/app/meeting-planner', icon: <CalendarOutlined />, label: 'Meeting Planner' });
} }
if (settings?.enableTicketedEvents) {
schedulingChildren.push({ key: '/app/events', icon: <TagOutlined />, label: 'Events' });
}
if (settings?.enableMeet) {
schedulingChildren.push({ key: '/app/services/jitsi', icon: <VideoCameraOutlined />, label: 'Video Meet' });
}
if (settings?.enableEvents) {
schedulingChildren.push({ key: '/app/services/gancio', icon: <GlobalOutlined />, label: 'Gancio' });
}
schedulingChildren.push({ key: '/app/scheduling/calendar-views', icon: <TeamOutlined />, label: 'Calendar Views' });
// Always add Calendar as the last item in scheduling
schedulingChildren.push({ key: '/app/scheduling/calendar', icon: <CalendarOutlined />, label: 'Calendar' });
if (schedulingChildren.length > 0) { if (schedulingChildren.length > 0) {
items.push({ items.push({
key: 'scheduling-submenu', key: 'scheduling-submenu',
@ -317,6 +326,9 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, isS
{ key: '/app/services/n8n', icon: <ApiOutlined />, label: 'Workflows' }, { key: '/app/services/n8n', icon: <ApiOutlined />, label: 'Workflows' },
{ key: '/app/services/gitea', icon: <BranchesOutlined />, label: 'Git' }, { key: '/app/services/gitea', icon: <BranchesOutlined />, label: 'Git' },
{ key: '/app/services/excalidraw', icon: <EditOutlined />, label: 'Whiteboard' }, { key: '/app/services/excalidraw', icon: <EditOutlined />, label: 'Whiteboard' },
...(settings?.enableChat ? [{ key: '/app/services/rocketchat', icon: <MessageOutlined />, label: 'Team Chat' }] : []),
...(settings?.enableMeet ? [{ key: '/app/services/jitsi', icon: <VideoCameraOutlined />, label: 'Video Meet' }] : []),
{ key: '/app/services/gancio', icon: <CalendarOutlined />, label: 'Events' },
{ key: '/app/services/miniqr', icon: <QrcodeOutlined />, label: 'QR Codes' }, { key: '/app/services/miniqr', icon: <QrcodeOutlined />, label: 'QR Codes' },
]}, ]},
], ],
@ -602,13 +614,29 @@ export default function AppLayout() {
</Tooltip> </Tooltip>
{pageHeader?.actions} {pageHeader?.actions}
{(() => { {(() => {
const merged = mergeNavDefaults(settings?.navConfig?.items ?? DEFAULT_NAV_ITEMS); const items = mergeAdminNavDefaults(settings?.navConfig?.items ?? DEFAULT_ADMIN_NAV_ITEMS);
const withOverrides = applyAdminOverrides(merged); const featureFlags: Record<string, boolean | undefined> = {
const flags = buildFeatureFlags(settings); enableInfluence: settings?.enableInfluence,
const filtered = filterNavItems(withOverrides, flags); enableMap: settings?.enableMap,
enableMediaFeatures: settings?.enableMediaFeatures,
const getIcon = (iconName: string) => ADMIN_ICON_OVERRIDES[iconName] ?? ICON_MAP[iconName] ?? <GlobalOutlined />; enablePayments: settings?.enablePayments,
const handleItemClick = (item: NavItem) => { enableEvents: settings?.enableEvents,
};
return items
.filter(item => item.enabled)
.filter(item => {
if (!item.featureFlag) return true;
if (item.featureFlag === 'enablePayments') return featureFlags[item.featureFlag] === true;
return featureFlags[item.featureFlag] !== false;
})
.sort((a, b) => a.order - b.order)
.map(item => (
<Tooltip key={item.id} title={item.label}>
<Button
type="text"
size="small"
icon={ADMIN_ICON_MAP[item.icon] ?? <GlobalOutlined />}
onClick={() => {
if (item.path.startsWith('$')) { if (item.path.startsWith('$')) {
window.open(resolveNavUrl(item.path), '_blank'); window.open(resolveNavUrl(item.path), '_blank');
} else if (item.external && item.id === 'home') { } else if (item.external && item.id === 'home') {
@ -618,44 +646,15 @@ export default function AppLayout() {
} else { } else {
navigate(item.path); navigate(item.path);
} }
};
return filtered.map(item => {
if (item.type === 'group' && item.children) {
return (
<Dropdown
key={item.id}
menu={{
items: item.children.map(child => ({
key: child.id,
icon: getIcon(child.icon),
label: child.label,
onClick: () => handleItemClick(child),
})),
}} }}
placement="bottomRight"
>
<Button type="text" size="small" icon={getIcon(item.icon)}>
{!isMobile && !collapsed && item.label}
</Button>
</Dropdown>
);
}
return (
<Tooltip key={item.id} title={item.label}>
<Button
type="text"
size="small"
icon={getIcon(item.icon)}
onClick={() => handleItemClick(item)}
> >
{!isMobile && !collapsed && item.label} {!isMobile && !collapsed && item.label}
</Button> </Button>
</Tooltip> </Tooltip>
); ));
});
})()} })()}
{/* Volunteer Portal button — always visible for quick switching */} {/* Canvass button — always tied to enableMap, not in navConfig */}
{settings?.enableMap !== false && (
<Tooltip title="Switch to Volunteer Portal"> <Tooltip title="Switch to Volunteer Portal">
<Button <Button
type="text" type="text"
@ -663,9 +662,10 @@ export default function AppLayout() {
icon={<TeamOutlined />} icon={<TeamOutlined />}
onClick={() => navigate('/volunteer')} onClick={() => navigate('/volunteer')}
> >
{!isMobile && !collapsed && 'Volunteer'} {!isMobile && !collapsed && 'Canvass'}
</Button> </Button>
</Tooltip> </Tooltip>
)}
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight"> <Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
<Button type="text" icon={<UserOutlined />}> <Button type="text" icon={<UserOutlined />}>
{!isMobile && !collapsed && ( {!isMobile && !collapsed && (

View File

@ -20,12 +20,10 @@ const FEATURE_LABELS: Record<string, string> = {
enableSocial: 'Social Connections', enableSocial: 'Social Connections',
enableMeet: 'Video Meetings', enableMeet: 'Video Meetings',
enableMeetingPlanner: 'Meeting Planner', enableMeetingPlanner: 'Meeting Planner',
enableTicketedEvents: 'Ticketed Events',
enableSocialCalendar: 'Social Calendar',
}; };
interface FeatureGateProps { interface FeatureGateProps {
feature: keyof Pick<SiteSettings, 'enableInfluence' | 'enableMap' | 'enableLandingPages' | 'enableNewsletter' | 'enableMediaFeatures' | 'enablePayments' | 'enableGalleryAds' | 'enablePeople' | 'enableEvents' | 'enableSocial' | 'enableMeet' | 'enableMeetingPlanner' | 'enableTicketedEvents' | 'enableSocialCalendar'>; feature: keyof Pick<SiteSettings, 'enableInfluence' | 'enableMap' | 'enableLandingPages' | 'enableNewsletter' | 'enableMediaFeatures' | 'enablePayments' | 'enableGalleryAds' | 'enablePeople' | 'enableEvents' | 'enableSocial' | 'enableMeet' | 'enableMeetingPlanner'>;
children: ReactNode; children: ReactNode;
} }

View File

@ -5,13 +5,6 @@ import { useSettingsStore } from '@/stores/settings.store';
import AuthModal from '@/components/AuthModal'; import AuthModal from '@/components/AuthModal';
import PublicNavBar from '@/components/PublicNavBar'; import PublicNavBar from '@/components/PublicNavBar';
import NewsletterSignup from '@/components/public/NewsletterSignup'; import NewsletterSignup from '@/components/public/NewsletterSignup';
import {
DEFAULT_NAV_ITEMS,
mergeNavDefaults,
filterNavItems,
flattenNavItems,
buildFeatureFlags,
} from '@/lib/nav-defaults';
const { Content, Footer } = Layout; const { Content, Footer } = Layout;
@ -26,14 +19,38 @@ export default function PublicLayout() {
const colorBgContainer = settings?.publicColorBgContainer ?? '#1b2838'; const colorBgContainer = settings?.publicColorBgContainer ?? '#1b2838';
const footerText = settings?.footerText ?? 'Powered by Changemaker Lite'; const footerText = settings?.footerText ?? 'Powered by Changemaker Lite';
// Build footer links from navConfig (or defaults) — flatten groups for flat footer // Build footer links from navConfig (or defaults)
const footerLinks = useMemo(() => { const footerLinks = useMemo(() => {
const featureFlags = buildFeatureFlags(settings); const items = settings?.navConfig?.items;
const merged = mergeNavDefaults(settings?.navConfig?.items ?? DEFAULT_NAV_ITEMS); if (!items) {
const filtered = filterNavItems(merged, featureFlags); // Legacy fallback: hardcoded links
const flat = flattenNavItems(filtered); const links: { label: string; path: string; external?: boolean }[] = [];
return flat if (settings?.enableInfluence !== false) links.push({ label: 'Campaigns', path: '/campaigns' });
.filter(item => item.id !== 'home') if (settings?.enableMap !== false) {
links.push({ label: 'Map', path: '/map' });
links.push({ label: 'Shifts', path: '/shifts' });
}
if (settings?.enableMediaFeatures !== false) links.push({ label: 'Gallery', path: '/gallery' });
if (settings?.enablePayments) links.push({ label: 'Donate', path: '/donate' });
return links;
}
const featureFlags: Record<string, boolean | undefined> = {
enableInfluence: settings?.enableInfluence,
enableMap: settings?.enableMap,
enableMediaFeatures: settings?.enableMediaFeatures,
enablePayments: settings?.enablePayments,
enableEvents: settings?.enableEvents,
};
return items
.filter(item => item.enabled && item.id !== 'home') // Skip home in footer
.filter(item => {
if (!item.featureFlag) return true;
if (item.featureFlag === 'enablePayments') return featureFlags[item.featureFlag] === true;
return featureFlags[item.featureFlag] !== false;
})
.sort((a, b) => a.order - b.order)
.map(item => ({ label: item.label, path: item.path, external: item.external })); .map(item => ({ label: item.label, path: item.path, external: item.external }));
}, [settings]); }, [settings]);

View File

@ -1,38 +1,69 @@
import { useState, useEffect, useMemo } from 'react'; import { useState, useEffect, useMemo } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom'; import { Link, useLocation, useNavigate } from 'react-router-dom';
import { Typography, Space, Grid, Drawer, Dropdown, Button, Tooltip, message } from 'antd'; import { Typography, Space, Grid, Drawer, Button, Tooltip, message } from 'antd';
import { import {
HomeOutlined,
SendOutlined,
EnvironmentOutlined,
CalendarOutlined,
ScheduleOutlined,
PlayCircleOutlined,
HeartOutlined,
DollarOutlined,
ShoppingOutlined,
MenuOutlined, MenuOutlined,
CloseOutlined, CloseOutlined,
LoginOutlined, LoginOutlined,
LogoutOutlined, LogoutOutlined,
AppstoreOutlined, AppstoreOutlined,
TeamOutlined, TeamOutlined,
LinkOutlined,
MenuFoldOutlined, MenuFoldOutlined,
MenuUnfoldOutlined, MenuUnfoldOutlined,
EllipsisOutlined,
SearchOutlined, SearchOutlined,
UserOutlined, UserOutlined,
DownOutlined, GlobalOutlined,
UpOutlined, BookOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useSettingsStore } from '@/stores/settings.store'; import { useSettingsStore } from '@/stores/settings.store';
import { useAuthStore } from '@/stores/auth.store'; import { useAuthStore } from '@/stores/auth.store';
import { useLocalStorage } from '@/hooks/useLocalStorage'; import { useLocalStorage } from '@/hooks/useLocalStorage';
import PublicSearchModal from '@/components/PublicSearchModal'; import PublicSearchModal from '@/components/PublicSearchModal';
import NotificationBell from '@/components/social/NotificationBell';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { resolveNavUrl } from '@/lib/service-url'; import { resolveNavUrl } from '@/lib/service-url';
import {
DEFAULT_NAV_ITEMS,
ICON_MAP,
mergeNavDefaults,
filterNavItems,
buildFeatureFlags,
isItemActive,
} from '@/lib/nav-defaults';
import type { NavItem } from '@/types/api'; import type { NavItem } from '@/types/api';
// Map icon string IDs to Ant Design icon components
const ICON_MAP: Record<string, React.ReactNode> = {
HomeOutlined: <HomeOutlined />,
SendOutlined: <SendOutlined />,
EnvironmentOutlined: <EnvironmentOutlined />,
CalendarOutlined: <CalendarOutlined />,
ScheduleOutlined: <ScheduleOutlined />,
PlayCircleOutlined: <PlayCircleOutlined />,
HeartOutlined: <HeartOutlined />,
DollarOutlined: <DollarOutlined />,
ShoppingOutlined: <ShoppingOutlined />,
LinkOutlined: <LinkOutlined />,
GlobalOutlined: <GlobalOutlined />,
BookOutlined: <BookOutlined />,
};
/** Default nav items used when navConfig is null (matches plan's builtin items) */
const DEFAULT_NAV_ITEMS: NavItem[] = [
{ id: 'home', label: 'Home', path: '/home', icon: 'HomeOutlined', enabled: true, order: 0, type: 'builtin' },
{ id: 'campaigns', label: 'Campaigns', path: '/campaigns', icon: 'SendOutlined', enabled: true, order: 1, type: 'builtin', featureFlag: 'enableInfluence' },
{ id: 'map', label: 'Map', path: '/map', icon: 'EnvironmentOutlined', enabled: true, order: 2, type: 'builtin', featureFlag: 'enableMap' },
{ id: 'shifts', label: 'Shifts', path: '/shifts', icon: 'ScheduleOutlined', enabled: true, order: 3, type: 'builtin', featureFlag: 'enableMap' },
{ id: 'events', label: 'Calendar', path: '/events', icon: 'CalendarOutlined', enabled: true, order: 4, type: 'builtin', featureFlag: 'enableEvents' },
{ id: 'gallery', label: 'Gallery', path: '/gallery', icon: 'PlayCircleOutlined', enabled: true, order: 5, type: 'builtin', featureFlag: 'enableMediaFeatures' },
{ id: 'pricing', label: 'Pricing', path: '/pricing', icon: 'DollarOutlined', enabled: true, order: 6, type: 'builtin', featureFlag: 'enablePayments' },
{ id: 'shop', label: 'Shop', path: '/shop', icon: 'ShoppingOutlined', enabled: true, order: 7, type: 'builtin', featureFlag: 'enablePayments' },
{ id: 'donate', label: 'Donate', path: '/donate', icon: 'HeartOutlined', enabled: true, order: 8, type: 'builtin', featureFlag: 'enablePayments' },
{ id: 'landing', label: 'Website', path: '$landing', icon: 'GlobalOutlined', enabled: false, order: 9, type: 'builtin', external: true },
{ id: 'docs', label: 'Docs', path: '$docs', icon: 'BookOutlined', enabled: false, order: 10, type: 'builtin', external: true },
];
const navItemStyle: React.CSSProperties = { const navItemStyle: React.CSSProperties = {
color: 'rgba(255, 255, 255, 0.85)', color: 'rgba(255, 255, 255, 0.85)',
textDecoration: 'none', textDecoration: 'none',
@ -56,6 +87,19 @@ function resolveItemUrl(item: NavItem): string {
return item.path; return item.path;
} }
/** Merge missing builtin defaults into stored navConfig items and sync icons */
function mergeNavDefaults(stored: NavItem[]): NavItem[] {
const defaultMap = new Map(DEFAULT_NAV_ITEMS.filter(d => d.type === 'builtin').map(d => [d.id, d]));
// Sync icon for existing builtin items so code-level icon changes propagate
const synced = stored.map(item => {
const def = defaultMap.get(item.id);
return (def && item.type === 'builtin') ? { ...item, icon: def.icon } : item;
});
const ids = new Set(synced.map(i => i.id));
const missing = DEFAULT_NAV_ITEMS.filter(d => d.type === 'builtin' && !ids.has(d.id));
return missing.length > 0 ? [...synced, ...missing] : synced;
}
interface PublicNavBarProps { interface PublicNavBarProps {
activePath?: string; activePath?: string;
showAuth?: boolean; showAuth?: boolean;
@ -72,7 +116,6 @@ export default function PublicNavBar({ activePath, showAuth = true, onSignInClic
const [searchOpen, setSearchOpen] = useState(false); const [searchOpen, setSearchOpen] = useState(false);
const [navCollapsed, setNavCollapsed] = useLocalStorage('public_nav_collapsed', false); const [navCollapsed, setNavCollapsed] = useLocalStorage('public_nav_collapsed', false);
const [profileLoading, setProfileLoading] = useState(false); const [profileLoading, setProfileLoading] = useState(false);
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
const handleSignIn = onSignInClick ?? (() => navigate('/login')); const handleSignIn = onSignInClick ?? (() => navigate('/login'));
const handleMyProfile = async () => { const handleMyProfile = async () => {
@ -123,32 +166,46 @@ export default function PublicNavBar({ activePath, showAuth = true, onSignInClic
const colorBgContainer = settings?.publicColorBgContainer ?? '#1b2838'; const colorBgContainer = settings?.publicColorBgContainer ?? '#1b2838';
// Determine active route for nav highlight // Determine active route for nav highlight
const currentActive = activePath ?? location.pathname; const currentActive = activePath ?? (() => {
const p = location.pathname;
if (p === '/home') return '/home';
if (p.startsWith('/campaign')) return '/campaigns';
if (p.startsWith('/map')) return '/map';
if (p.startsWith('/shifts') || p.startsWith('/volunteer')) return '/shifts';
if (p.startsWith('/events')) return '/events';
if (p.startsWith('/gallery')) return '/gallery';
if (p.startsWith('/donate')) return '/donate';
if (p.startsWith('/pricing')) return '/pricing';
if (p.startsWith('/shop')) return '/shop';
return '';
})();
const featureFlags = useMemo(() => buildFeatureFlags(settings), [settings]); // Feature flag map for filtering
const featureFlags: Record<string, boolean | undefined> = useMemo(() => ({
enableInfluence: settings?.enableInfluence,
enableMap: settings?.enableMap,
enableMediaFeatures: settings?.enableMediaFeatures,
enablePayments: settings?.enablePayments,
enableEvents: settings?.enableEvents,
}), [settings]);
// Get filtered, sorted nav items (with group support) // Get filtered, sorted nav items
const navItems = useMemo(() => { const navItems = useMemo(() => {
const merged = mergeNavDefaults(settings?.navConfig?.items ?? DEFAULT_NAV_ITEMS); const items = mergeNavDefaults(settings?.navConfig?.items ?? DEFAULT_NAV_ITEMS);
return filterNavItems(merged, featureFlags); return items
.filter(item => item.enabled)
.filter(item => {
if (!item.featureFlag) return true;
// For payments flag, enablePayments defaults to false (opt-in)
if (item.featureFlag === 'enablePayments') return featureFlags[item.featureFlag] === true;
// Other flags default to true
return featureFlags[item.featureFlag] !== false;
})
.sort((a, b) => a.order - b.order);
}, [settings?.navConfig, featureFlags]); }, [settings?.navConfig, featureFlags]);
// Desktop overflow: group items beyond MAX_VISIBLE into "More" dropdown
const MAX_VISIBLE_NAV = 7;
const visibleNavItems = navCollapsed ? navItems : navItems.slice(0, MAX_VISIBLE_NAV);
const overflowNavItems = navCollapsed ? [] : navItems.slice(MAX_VISIBLE_NAV);
const toggleGroup = (groupId: string) => {
setExpandedGroups(prev => {
const next = new Set(prev);
if (next.has(groupId)) next.delete(groupId);
else next.add(groupId);
return next;
});
};
const renderDesktopLink = (item: NavItem) => { const renderDesktopLink = (item: NavItem) => {
const isActive = isItemActive(item, currentActive); const isActive = currentActive === item.path;
const icon = ICON_MAP[item.icon] ?? null; const icon = ICON_MAP[item.icon] ?? null;
const linkStyle: React.CSSProperties = { const linkStyle: React.CSSProperties = {
...navItemStyle, ...navItemStyle,
@ -159,38 +216,6 @@ export default function PublicNavBar({ activePath, showAuth = true, onSignInClic
paddingBottom: 2, paddingBottom: 2,
}; };
// Group item: render as Dropdown trigger
if (item.type === 'group' && item.children) {
const menuItems = item.children.map(child => ({
key: child.id,
icon: ICON_MAP[child.icon],
label: child.external ? (
<a href={resolveItemUrl(child)} target="_blank" rel="noopener noreferrer" style={{ color: 'inherit' }}>{child.label}</a>
) : child.label,
onClick: child.external ? undefined : () => navigate(child.path),
}));
return (
<Dropdown
key={item.id}
menu={{ items: menuItems }}
placement="bottomRight"
>
<Tooltip title={navCollapsed ? item.label : ''}>
<span
style={linkStyle}
onMouseEnter={(e) => { e.currentTarget.style.color = '#fff'; }}
onMouseLeave={(e) => { if (!isActive) e.currentTarget.style.color = 'rgba(255, 255, 255, 0.85)'; }}
>
{icon}
<NavLabel label={item.label} />
{!navCollapsed && <DownOutlined style={{ fontSize: 10, marginLeft: -2 }} />}
</span>
</Tooltip>
</Dropdown>
);
}
if (item.external) { if (item.external) {
return ( return (
<Tooltip key={item.id} title={navCollapsed ? item.label : ''}> <Tooltip key={item.id} title={navCollapsed ? item.label : ''}>
@ -224,17 +249,17 @@ export default function PublicNavBar({ activePath, showAuth = true, onSignInClic
); );
}; };
const renderMobileLink = (item: NavItem, indent = false) => { const renderMobileLink = (item: NavItem) => {
const isActive = isItemActive(item, currentActive); const isActive = currentActive === item.path;
const icon = ICON_MAP[item.icon] ?? null; const icon = ICON_MAP[item.icon] ?? null;
const style: React.CSSProperties = { const style: React.CSSProperties = {
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: 10, gap: 10,
padding: indent ? '10px 24px 10px 44px' : '12px 24px', padding: '12px 24px',
color: isActive ? '#fff' : 'rgba(255,255,255,0.85)', color: isActive ? '#fff' : 'rgba(255,255,255,0.85)',
textDecoration: 'none', textDecoration: 'none',
fontSize: indent ? 14 : 15, fontSize: 15,
fontWeight: isActive ? 600 : 400, fontWeight: isActive ? 600 : 400,
background: isActive ? 'rgba(255,255,255,0.1)' : 'transparent', background: isActive ? 'rgba(255,255,255,0.1)' : 'transparent',
borderRadius: 4, borderRadius: 4,
@ -269,70 +294,6 @@ export default function PublicNavBar({ activePath, showAuth = true, onSignInClic
); );
}; };
const renderMobileGroup = (item: NavItem) => {
const isActive = isItemActive(item, currentActive);
const icon = ICON_MAP[item.icon] ?? null;
const expanded = expandedGroups.has(item.id);
return (
<div key={item.id}>
<span
role="button"
tabIndex={0}
onClick={() => toggleGroup(item.id)}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleGroup(item.id); } }}
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '12px 24px',
color: isActive ? '#fff' : 'rgba(255,255,255,0.85)',
fontSize: 15,
fontWeight: isActive ? 600 : 400,
cursor: 'pointer',
background: 'none',
border: 'none',
font: 'inherit',
width: '100%',
textAlign: 'left',
}}
>
{icon}
<span style={{ flex: 1 }}>{item.label}</span>
{expanded ? <UpOutlined style={{ fontSize: 10 }} /> : <DownOutlined style={{ fontSize: 10 }} />}
</span>
{expanded && item.children?.map(child => renderMobileLink(child, true))}
</div>
);
};
// Build overflow menu items with group support (nested children)
const overflowMenuItems = overflowNavItems.map(item => {
if (item.type === 'group' && item.children) {
return {
key: item.id,
icon: ICON_MAP[item.icon],
label: item.label,
children: item.children.map(child => ({
key: child.id,
icon: ICON_MAP[child.icon],
label: child.external ? (
<a href={resolveItemUrl(child)} target="_blank" rel="noopener noreferrer" style={{ color: 'inherit' }}>{child.label}</a>
) : child.label,
onClick: child.external ? undefined : () => navigate(child.path),
})),
};
}
return {
key: item.id,
icon: ICON_MAP[item.icon],
label: item.external ? (
<a href={resolveItemUrl(item)} target="_blank" rel="noopener noreferrer" style={{ color: 'inherit' }}>{item.label}</a>
) : item.label,
onClick: item.external ? undefined : () => navigate(item.path),
};
});
return ( return (
<> <>
<div <div
@ -362,8 +323,6 @@ export default function PublicNavBar({ activePath, showAuth = true, onSignInClic
{/* Right: Navigation */} {/* Right: Navigation */}
{isMobile ? ( {isMobile ? (
<Space size={4}>
{isAuthenticated && settings?.enableSocial && <NotificationBell />}
<Button <Button
type="text" type="text"
icon={<MenuOutlined style={{ color: '#fff', fontSize: 20 }} />} icon={<MenuOutlined style={{ color: '#fff', fontSize: 20 }} />}
@ -371,25 +330,9 @@ export default function PublicNavBar({ activePath, showAuth = true, onSignInClic
aria-label="Open navigation menu" aria-label="Open navigation menu"
style={{ padding: '4px 8px' }} style={{ padding: '4px 8px' }}
/> />
</Space>
) : ( ) : (
<Space size={navCollapsed ? 8 : 16}> <Space size={navCollapsed ? 8 : 16}>
{visibleNavItems.map(renderDesktopLink)} {navItems.map(renderDesktopLink)}
{overflowMenuItems.length > 0 && (
<Dropdown
menu={{ items: overflowMenuItems }}
placement="bottomRight"
>
<span
style={{ ...navItemStyle, cursor: 'pointer' }}
onMouseEnter={(e) => { e.currentTarget.style.color = '#fff'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'rgba(255, 255, 255, 0.85)'; }}
>
<EllipsisOutlined />
<NavLabel label="More" />
</span>
</Dropdown>
)}
{/* Search button */} {/* Search button */}
<Tooltip title={navCollapsed ? 'Search (Ctrl+K)' : 'Search'}> <Tooltip title={navCollapsed ? 'Search (Ctrl+K)' : 'Search'}>
@ -428,73 +371,55 @@ export default function PublicNavBar({ activePath, showAuth = true, onSignInClic
</span> </span>
</Tooltip> </Tooltip>
{/* Notification bell (authenticated + social enabled) */} {/* Auth buttons: always show Admin/Logout when logged in; show Sign In when not */}
{isAuthenticated && settings?.enableSocial && <NotificationBell />}
{/* Auth: user dropdown when logged in, Sign In when not */}
{isAuthenticated ? ( {isAuthenticated ? (
<Dropdown <>
menu={{ <Tooltip title={navCollapsed ? 'My Profile' : ''}>
items: [
...(isAdmin ? [{
key: 'admin',
icon: <AppstoreOutlined />,
label: 'Admin Panel',
onClick: () => navigate('/app'),
style: { fontWeight: 600 },
}] : []),
{
key: 'volunteer',
icon: <TeamOutlined />,
label: 'Volunteer Portal',
onClick: () => navigate('/volunteer'),
style: isAdmin ? undefined : { fontWeight: 600 },
},
{ type: 'divider' as const },
{
key: 'profile',
icon: <UserOutlined />,
label: 'My Profile',
disabled: profileLoading,
onClick: handleMyProfile,
},
{ type: 'divider' as const },
{
key: 'logout',
icon: <LogoutOutlined />,
label: 'Logout',
onClick: () => logout(),
},
],
}}
placement="bottomRight"
trigger={['click']}
>
<span <span
style={{ role="button"
...navItemStyle, tabIndex={0}
gap: 6, onClick={handleMyProfile}
cursor: 'pointer', onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleMyProfile(); } }}
borderLeft: '1px solid rgba(255,255,255,0.2)', style={{ ...navItemStyle, gap: navCollapsed ? 0 : 6, opacity: profileLoading ? 0.5 : 1 }}
paddingLeft: 12,
marginLeft: 4,
}}
onMouseEnter={(e) => { e.currentTarget.style.color = '#fff'; }} onMouseEnter={(e) => { e.currentTarget.style.color = '#fff'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'rgba(255, 255, 255, 0.85)'; }} onMouseLeave={(e) => { e.currentTarget.style.color = 'rgba(255, 255, 255, 0.85)'; }}
> >
<UserOutlined /> <UserOutlined /><NavLabel label="My Profile" />
<span style={{
maxWidth: navCollapsed ? 0 : 120,
opacity: navCollapsed ? 0 : 1,
overflow: 'hidden',
transition: 'max-width 0.25s ease, opacity 0.2s ease',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
}}>
{user?.name || user?.email || 'Account'}
</span> </span>
</Tooltip>
{isAdmin ? (
<Tooltip title={navCollapsed ? 'Admin' : ''}>
<Link to="/app" style={{ ...navItemStyle, gap: navCollapsed ? 0 : 6 }}
onMouseEnter={(e) => { e.currentTarget.style.color = '#fff'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'rgba(255, 255, 255, 0.85)'; }}
>
<AppstoreOutlined /><NavLabel label="Admin" />
</Link>
</Tooltip>
) : (
<Tooltip title={navCollapsed ? 'Volunteer Portal' : ''}>
<Link to="/volunteer" style={{ ...navItemStyle, gap: navCollapsed ? 0 : 6 }}
onMouseEnter={(e) => { e.currentTarget.style.color = '#fff'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'rgba(255, 255, 255, 0.85)'; }}
>
<TeamOutlined /><NavLabel label="Volunteer Portal" />
</Link>
</Tooltip>
)}
<Tooltip title={navCollapsed ? 'Logout' : ''}>
<span
role="button"
tabIndex={0}
onClick={() => logout()}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); logout(); } }}
style={{ ...navItemStyle, gap: navCollapsed ? 0 : 6 }}
onMouseEnter={(e) => { e.currentTarget.style.color = '#fff'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'rgba(255, 255, 255, 0.85)'; }}
>
<LogoutOutlined /><NavLabel label="Logout" />
</span> </span>
</Dropdown> </Tooltip>
</>
) : showAuth && ( ) : showAuth && (
<Tooltip title={navCollapsed ? 'Sign In' : ''}> <Tooltip title={navCollapsed ? 'Sign In' : ''}>
<span <span
@ -528,49 +453,11 @@ export default function PublicNavBar({ activePath, showAuth = true, onSignInClic
}} }}
> >
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{/* Highlighted portal/admin link at top when authenticated */} {navItems.map(renderMobileLink)}
{isAuthenticated && (
<>
<Link
to={isAdmin ? '/app' : '/volunteer'}
onClick={() => setDrawerOpen(false)}
style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '12px 24px',
color: '#fff',
textDecoration: 'none', fontSize: 15,
fontWeight: 600,
borderRadius: 4,
margin: '0 8px 4px',
background: 'rgba(52,152,219,0.15)',
}}
>
{isAdmin ? <AppstoreOutlined /> : <TeamOutlined />}
<span>{isAdmin ? 'Open Admin Panel' : 'Open Volunteer Portal'}</span>
</Link>
</>
)}
{navItems.map(item =>
item.type === 'group' && item.children
? renderMobileGroup(item)
: renderMobileLink(item)
)}
<div style={{ borderTop: '1px solid rgba(255,255,255,0.1)', margin: '8px 24px' }} /> <div style={{ borderTop: '1px solid rgba(255,255,255,0.1)', margin: '8px 24px' }} />
{/* Auth buttons: always show Admin/Logout when logged in; show Sign In when not */}
{isAuthenticated ? ( {isAuthenticated ? (
<> <>
<Link
to="/volunteer"
onClick={() => setDrawerOpen(false)}
style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '12px 24px',
color: 'rgba(255,255,255,0.85)',
textDecoration: 'none', fontSize: 15,
borderRadius: 4,
}}
>
<TeamOutlined /> <span>Volunteer Portal</span>
</Link>
<span <span
role="button" role="button"
tabIndex={0} tabIndex={0}
@ -580,6 +467,20 @@ export default function PublicNavBar({ activePath, showAuth = true, onSignInClic
> >
<UserOutlined /> <span>My Profile</span> <UserOutlined /> <span>My Profile</span>
</span> </span>
<Link
to={isAdmin ? '/app' : '/volunteer'}
onClick={() => setDrawerOpen(false)}
style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '12px 24px',
color: 'rgba(255,255,255,0.85)',
textDecoration: 'none', fontSize: 15,
borderRadius: 4,
}}
>
{isAdmin ? <AppstoreOutlined /> : <TeamOutlined />}
<span>{isAdmin ? 'Admin Panel' : 'Volunteer Portal'}</span>
</Link>
<span <span
role="button" role="button"
tabIndex={0} tabIndex={0}

View File

@ -8,8 +8,6 @@ import {
NodeIndexOutlined, NodeIndexOutlined,
MessageOutlined, MessageOutlined,
TeamOutlined, TeamOutlined,
TagOutlined,
CalendarOutlined,
MenuOutlined, MenuOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useSettingsStore } from '@/stores/settings.store'; import { useSettingsStore } from '@/stores/settings.store';
@ -35,12 +33,6 @@ export default function VolunteerFooterNav({ style, onMenuOpen, menuActive = fal
const NAV_ITEMS = useMemo(() => { const NAV_ITEMS = useMemo(() => {
const items = [...BASE_NAV_ITEMS]; 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) { if (settings?.enableSocial) {
items.push({ key: '/volunteer/feed', icon: TeamOutlined, label: 'Social' }); items.push({ key: '/volunteer/feed', icon: TeamOutlined, label: 'Social' });
} }
@ -48,7 +40,7 @@ export default function VolunteerFooterNav({ style, onMenuOpen, menuActive = fal
items.push({ key: '/volunteer/chat', icon: MessageOutlined, label: 'Chat' }); items.push({ key: '/volunteer/chat', icon: MessageOutlined, label: 'Chat' });
} }
return items; return items;
}, [settings?.enableChat, settings?.enableSocial, settings?.enableSocialCalendar, settings?.enableTicketedEvents]); }, [settings?.enableChat, settings?.enableSocial]);
const activeKey = (() => { const activeKey = (() => {
const path = location.pathname; const path = location.pathname;
@ -65,7 +57,7 @@ export default function VolunteerFooterNav({ style, onMenuOpen, menuActive = fal
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-around', justifyContent: 'space-around',
minHeight: 44, minHeight: 56,
background: 'rgba(13, 27, 42, 0.95)', background: 'rgba(13, 27, 42, 0.95)',
borderTop: '1px solid rgba(255,255,255,0.1)', borderTop: '1px solid rgba(255,255,255,0.1)',
backdropFilter: 'blur(10px)', backdropFilter: 'blur(10px)',
@ -80,20 +72,24 @@ export default function VolunteerFooterNav({ style, onMenuOpen, menuActive = fal
onClick={onMenuOpen} onClick={onMenuOpen}
style={{ style={{
display: 'flex', display: 'flex',
flexDirection: 'column',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
flex: 1, flex: 1,
cursor: 'pointer', cursor: 'pointer',
padding: '10px 0', padding: '6px 0',
color: menuActive ? token.colorPrimary : 'rgba(255,255,255,0.5)', color: menuActive ? token.colorPrimary : 'rgba(255,255,255,0.5)',
transition: 'color 0.2s', transition: 'color 0.2s',
}} }}
> >
<MenuOutlined style={{ fontSize: 22 }} /> <MenuOutlined style={{ fontSize: 22, marginBottom: 2 }} />
<span style={{ fontSize: 12, lineHeight: '16px', fontWeight: menuActive ? 600 : 400 }}>
Menu
</span>
</div> </div>
)} )}
{NAV_ITEMS.map(({ key, icon: Icon }) => { {NAV_ITEMS.map(({ key, icon: Icon, label }) => {
const isActive = activeKey === key; const isActive = activeKey === key;
return ( return (
<div <div
@ -101,16 +97,20 @@ export default function VolunteerFooterNav({ style, onMenuOpen, menuActive = fal
onClick={() => navigate(key)} onClick={() => navigate(key)}
style={{ style={{
display: 'flex', display: 'flex',
flexDirection: 'column',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
flex: 1, flex: 1,
cursor: 'pointer', cursor: 'pointer',
padding: '10px 0', padding: '6px 0',
color: isActive ? token.colorPrimary : 'rgba(255,255,255,0.5)', color: isActive ? token.colorPrimary : 'rgba(255,255,255,0.5)',
transition: 'color 0.2s', transition: 'color 0.2s',
}} }}
> >
<Icon style={{ fontSize: 22 }} /> <Icon style={{ fontSize: 22, marginBottom: 2 }} />
<span style={{ fontSize: 12, lineHeight: '16px', fontWeight: isActive ? 600 : 400 }}>
{label}
</span>
</div> </div>
); );
})} })}

View File

@ -1,80 +1,37 @@
import { useState, useMemo } from 'react'; import { useNavigate, Outlet } from 'react-router-dom';
import { useNavigate, useLocation, Outlet } from 'react-router-dom'; import { ConfigProvider, Layout, Button, Typography, Dropdown, theme } from 'antd';
import { ConfigProvider, Layout, Typography, theme, Drawer, Divider, Alert, Tag } from 'antd'; import { LogoutOutlined, UserOutlined, GlobalOutlined, HomeOutlined } from '@ant-design/icons';
import { import type { MenuProps } from 'antd';
LogoutOutlined,
UserOutlined,
GlobalOutlined,
AppstoreOutlined,
EnvironmentOutlined,
ScheduleOutlined,
HistoryOutlined,
NodeIndexOutlined,
CalendarOutlined,
TagOutlined,
TeamOutlined,
MessageOutlined,
} from '@ant-design/icons';
import { useAuthStore } from '@/stores/auth.store'; import { useAuthStore } from '@/stores/auth.store';
import { useSettingsStore } from '@/stores/settings.store'; import { useSettingsStore } from '@/stores/settings.store';
import VolunteerFooterNav from '@/components/VolunteerFooterNav'; import VolunteerFooterNav from '@/components/VolunteerFooterNav';
import PublicNavBar from '@/components/PublicNavBar'; import NotificationBell from '@/components/social/NotificationBell';
import { buildHomeUrl } from '@/lib/service-url';
import { useSSE } from '@/hooks/useSSE'; import { useSSE } from '@/hooks/useSSE';
import { useLocalStorage } from '@/hooks/useLocalStorage';
const { Content, Footer } = Layout; const { Header, Content, Footer } = Layout;
export default function VolunteerLayout() { export default function VolunteerLayout() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const { user, logout } = useAuthStore(); const { user, logout } = useAuthStore();
const { settings } = useSettingsStore(); const { settings } = useSettingsStore();
const [menuOpen, setMenuOpen] = useState(false);
const [welcomeDismissed, setWelcomeDismissed] = useLocalStorage('volunteer_welcome_dismissed', false);
// Initialize SSE connection for real-time notifications + online presence // Initialize SSE connection for real-time notifications + online presence
useSSE(); useSSE();
const isAdmin = user?.role === 'SUPER_ADMIN' || user?.role === 'INFLUENCE_ADMIN' || user?.role === 'MAP_ADMIN'; const orgName = settings?.organizationName ?? 'Changemaker Lite';
const colorBgBase = settings?.publicColorBgBase ?? '#0d1b2a';
const colorBgContainer = settings?.publicColorBgContainer ?? '#1b2838';
const handleLogout = async () => { const handleLogout = async () => {
await logout(); await logout();
navigate('/login', { replace: true }); navigate('/login', { replace: true });
}; };
// Build nav items list (mirrors VolunteerFooterNav logic) const userMenuItems: MenuProps['items'] = [
const navItems = useMemo(() => { { key: 'home', icon: <HomeOutlined />, label: 'Home', onClick: () => window.open(buildHomeUrl(), '_blank') },
const items: { key: string; icon: React.ReactNode; label: string }[] = [ { key: 'browse', icon: <GlobalOutlined />, label: 'Browse Site', onClick: () => navigate('/campaigns') },
{ key: '/volunteer', icon: <EnvironmentOutlined />, label: 'Map' }, { type: 'divider' },
{ key: '/volunteer/shifts', icon: <ScheduleOutlined />, label: 'Shifts' }, { key: 'logout', icon: <LogoutOutlined />, label: 'Logout', onClick: handleLogout },
{ key: '/volunteer/activity', icon: <HistoryOutlined />, label: 'Activity' },
{ key: '/volunteer/routes', icon: <NodeIndexOutlined />, label: 'Routes' },
]; ];
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' });
}
if (settings?.enableChat) {
items.push({ key: '/volunteer/chat', icon: <MessageOutlined />, label: 'Chat' });
}
return items;
}, [settings?.enableSocialCalendar, settings?.enableTicketedEvents, settings?.enableSocial, settings?.enableChat]);
const activeKey = (() => {
const path = location.pathname;
if (path === '/volunteer') return '/volunteer';
for (const item of navItems) {
if (item.key !== '/volunteer' && path.startsWith(item.key)) return item.key;
}
return '/volunteer';
})();
return ( return (
<ConfigProvider <ConfigProvider
@ -91,27 +48,38 @@ export default function VolunteerLayout() {
}, },
}} }}
> >
<Layout style={{ minHeight: '100dvh', background: settings?.publicColorBgBase ?? '#0d1b2a' }}> <Layout style={{ minHeight: '100vh', background: settings?.publicColorBgBase ?? '#0d1b2a' }}>
<PublicNavBar /> <Header
style={{
background: settings?.publicHeaderGradient ?? 'linear-gradient(135deg, #005a9c 0%, #007acc 100%)',
display: 'flex',
alignItems: 'center',
padding: '0 16px',
height: 48,
gap: 12,
}}
>
<Typography.Text strong style={{ fontSize: 16, color: '#fff', flex: 1 }}>
{orgName}
</Typography.Text>
{settings?.enableSocial && <NotificationBell />}
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
<Button type="text" size="small" icon={<UserOutlined style={{ color: '#fff' }} />}>
<Typography.Text style={{ marginLeft: 4, color: '#fff', fontSize: 13 }}>
{user?.name || user?.email || 'User'}
</Typography.Text>
</Button>
</Dropdown>
</Header>
<Content <Content
style={{ style={{
maxWidth: 800, maxWidth: 800,
width: '100%', width: '100%',
margin: '0 auto', margin: '0 auto',
padding: '16px 12px max(60px, calc(44px + 16px + env(safe-area-inset-bottom))) 12px', padding: '16px 12px max(72px, calc(56px + 16px + env(safe-area-inset-bottom))) 12px',
}} }}
> >
{!welcomeDismissed && (
<Alert
message="Welcome to the Volunteer Portal!"
description="Here you can view your shifts, canvass your area, track your activity, and connect with your team."
type="info"
closable
onClose={() => setWelcomeDismissed(true)}
style={{ marginBottom: 16 }}
/>
)}
<Outlet /> <Outlet />
</Content> </Content>
@ -125,145 +93,9 @@ export default function VolunteerLayout() {
zIndex: 100, zIndex: 100,
}} }}
> >
<VolunteerFooterNav <VolunteerFooterNav />
onMenuOpen={() => setMenuOpen(true)}
menuActive={menuOpen}
/>
</Footer> </Footer>
</Layout> </Layout>
{/* Navigation Menu Drawer */}
<Drawer
title={null}
placement="left"
onClose={() => setMenuOpen(false)}
open={menuOpen}
width={280}
styles={{
header: { display: 'none' },
body: { background: colorBgBase, padding: 0 },
}}
>
{/* User profile section */}
<div style={{
padding: '20px 20px 16px',
background: colorBgContainer,
borderBottom: '1px solid rgba(255,255,255,0.1)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{
width: 40,
height: 40,
borderRadius: '50%',
background: 'rgba(52,152,219,0.2)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}>
<UserOutlined style={{ fontSize: 18, color: 'rgba(255,255,255,0.85)' }} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<Typography.Text strong style={{ color: '#fff', display: 'block', fontSize: 14 }}>
{user?.name || 'Volunteer'}
</Typography.Text>
<Typography.Text style={{ color: 'rgba(255,255,255,0.45)', display: 'block', fontSize: 12 }}>
{user?.email}
</Typography.Text>
</div>
</div>
<Tag color="blue" style={{ marginTop: 8, fontSize: 11 }}>
{user?.role === 'USER' ? 'Volunteer' : user?.role?.replace('_', ' ') ?? 'Volunteer'}
</Tag>
</div>
{/* Navigation items */}
<div style={{ padding: '8px 0' }}>
{navItems.map(({ key, icon, label }) => {
const isActive = activeKey === key;
return (
<div
key={key}
onClick={() => { navigate(key); setMenuOpen(false); }}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '12px 20px',
cursor: 'pointer',
color: isActive ? '#fff' : 'rgba(255,255,255,0.7)',
fontWeight: isActive ? 600 : 400,
fontSize: 14,
background: isActive ? 'rgba(52,152,219,0.15)' : 'transparent',
borderRight: isActive ? '3px solid #3498db' : '3px solid transparent',
transition: 'all 0.2s',
}}
>
{icon}
<span>{label}</span>
</div>
);
})}
</div>
<Divider style={{ margin: '4px 20px', borderColor: 'rgba(255,255,255,0.1)' }} />
{/* Cross-navigation links */}
<div style={{ padding: '4px 0' }}>
<div
onClick={() => { navigate('/home'); setMenuOpen(false); }}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '12px 20px',
cursor: 'pointer',
color: 'rgba(255,255,255,0.7)',
fontSize: 14,
}}
>
<GlobalOutlined />
<span>Public Website</span>
</div>
{isAdmin && (
<div
onClick={() => { navigate('/app'); setMenuOpen(false); }}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '12px 20px',
cursor: 'pointer',
color: 'rgba(255,255,255,0.7)',
fontSize: 14,
}}
>
<AppstoreOutlined />
<span>Admin Panel</span>
</div>
)}
</div>
<Divider style={{ margin: '4px 20px', borderColor: 'rgba(255,255,255,0.1)' }} />
{/* Logout */}
<div style={{ padding: '4px 0' }}>
<div
onClick={() => { handleLogout(); setMenuOpen(false); }}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '12px 20px',
cursor: 'pointer',
color: 'rgba(255,255,255,0.7)',
fontSize: 14,
}}
>
<LogoutOutlined />
<span>Logout</span>
</div>
</div>
</Drawer>
</ConfigProvider> </ConfigProvider>
); );
} }

View File

@ -1,252 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import {
DatePicker,
Select,
Space,
Typography,
Skeleton,
Empty,
Tooltip,
theme,
} from 'antd';
import dayjs, { Dayjs } from 'dayjs';
import { api } from '@/lib/api';
import type { AvailabilityResponse } from '@/types/api';
const { Text } = Typography;
const { RangePicker } = DatePicker;
interface AvailabilityFinderProps {
viewId: string;
onSlotClick?: (date: string, time: string) => void;
}
const DURATION_OPTIONS = [
{ value: 15, label: '15 min' },
{ value: 30, label: '30 min' },
{ value: 60, label: '1 hour' },
];
export default function AvailabilityFinder({ viewId, onSlotClick }: AvailabilityFinderProps) {
const { token } = theme.useToken();
const [dateRange, setDateRange] = useState<[Dayjs, Dayjs]>([
dayjs(),
dayjs().add(6, 'day'),
]);
const [dayStart, setDayStart] = useState(9);
const [dayEnd, setDayEnd] = useState(18);
const [slotDuration, setSlotDuration] = useState(30);
const [availability, setAvailability] = useState<AvailabilityResponse | null>(null);
const [loading, setLoading] = useState(false);
const hourOptions = Array.from({ length: 24 }, (_, i) => ({
value: i,
label: `${String(i).padStart(2, '0')}:00`,
}));
const fetchAvailability = useCallback(async () => {
setLoading(true);
try {
const { data } = await api.get<AvailabilityResponse>(
`/calendar/shared/${viewId}/availability`,
{
params: {
startDate: dateRange[0].format('YYYY-MM-DD'),
endDate: dateRange[1].format('YYYY-MM-DD'),
dayStart,
dayEnd,
slotMinutes: slotDuration,
},
},
);
setAvailability(data);
} catch {
setAvailability(null);
} finally {
setLoading(false);
}
}, [viewId, dateRange, dayStart, dayEnd, slotDuration]);
useEffect(() => {
fetchAvailability();
}, [fetchAvailability]);
// Build time slots
const timeSlots: string[] = [];
for (let h = dayStart; h < dayEnd; h++) {
for (let m = 0; m < 60; m += slotDuration) {
if (h === dayEnd - 1 && m + slotDuration > 60) break;
timeSlots.push(`${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`);
}
}
// Build date columns
const dates: string[] = [];
let d = dateRange[0];
while (d.isBefore(dateRange[1]) || d.isSame(dateRange[1], 'day')) {
dates.push(d.format('YYYY-MM-DD'));
d = d.add(1, 'day');
}
const statusColors: Record<string, string> = {
free: '#52c41a',
busy: '#ff4d4f',
tentative: '#faad14',
};
return (
<div>
<Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12 }}>
Find Availability
</Text>
<Space wrap style={{ marginBottom: 12 }}>
<RangePicker
size="small"
value={dateRange}
onChange={(vals) => {
if (vals && vals[0] && vals[1]) {
setDateRange([vals[0], vals[1]]);
}
}}
/>
<Select
size="small"
value={dayStart}
options={hourOptions}
onChange={setDayStart}
style={{ width: 90 }}
placeholder="Start"
/>
<Select
size="small"
value={dayEnd}
options={hourOptions}
onChange={setDayEnd}
style={{ width: 90 }}
placeholder="End"
/>
<Select
size="small"
value={slotDuration}
options={DURATION_OPTIONS}
onChange={setSlotDuration}
style={{ width: 90 }}
/>
</Space>
{loading ? (
<Skeleton active paragraph={{ rows: 6 }} />
) : !availability || dates.length === 0 ? (
<Empty description="Select a date range" image={Empty.PRESENTED_IMAGE_SIMPLE} />
) : (
<div style={{ overflowX: 'auto' }}>
<table style={{ borderCollapse: 'collapse', width: '100%', fontSize: 11 }}>
<thead>
<tr>
<th
style={{
padding: '4px 8px',
borderBottom: `1px solid ${token.colorBorderSecondary}`,
position: 'sticky',
left: 0,
background: token.colorBgContainer,
zIndex: 1,
}}
/>
{dates.map((date) => (
<th
key={date}
style={{
padding: '4px 8px',
borderBottom: `1px solid ${token.colorBorderSecondary}`,
whiteSpace: 'nowrap',
fontWeight: 500,
}}
>
{dayjs(date).format('ddd M/D')}
</th>
))}
</tr>
</thead>
<tbody>
{timeSlots.map((time) => (
<tr key={time}>
<td
style={{
padding: '3px 8px',
borderRight: `1px solid ${token.colorBorderSecondary}`,
whiteSpace: 'nowrap',
position: 'sticky',
left: 0,
background: token.colorBgContainer,
zIndex: 1,
color: 'rgba(255,255,255,0.6)',
}}
>
{time}
</td>
{dates.map((date) => {
const dayData = availability.dates[date];
const slot = dayData?.slots.find((s) => s.time === time);
const allFree = slot?.allFree;
return (
<td
key={date}
onClick={() => allFree && onSlotClick?.(date, time)}
style={{
padding: '3px 6px',
border: `1px solid ${token.colorBorderSecondary}`,
background: allFree
? 'rgba(82, 196, 26, 0.15)'
: 'transparent',
cursor: allFree ? 'pointer' : 'default',
textAlign: 'center',
}}
>
{slot && (
<Space size={2}>
{slot.members.map((m) => (
<Tooltip key={m.userId} title={`${m.userName}: ${m.status}`}>
<div
style={{
width: 8,
height: 8,
borderRadius: '50%',
background: statusColors[m.status] || '#999',
display: 'inline-block',
}}
/>
</Tooltip>
))}
</Space>
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
<Space style={{ marginTop: 8 }}>
<Space size={4}>
<div style={{ width: 8, height: 8, borderRadius: '50%', background: '#52c41a' }} />
<Text style={{ fontSize: 11 }}>Free</Text>
</Space>
<Space size={4}>
<div style={{ width: 8, height: 8, borderRadius: '50%', background: '#ff4d4f' }} />
<Text style={{ fontSize: 11 }}>Busy</Text>
</Space>
<Space size={4}>
<div style={{ width: 8, height: 8, borderRadius: '50%', background: '#faad14' }} />
<Text style={{ fontSize: 11 }}>Tentative</Text>
</Space>
</Space>
</div>
)}
</div>
);
}

View File

@ -1,156 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import { List, Input, Button, Typography, Space, Popconfirm, message, Empty, Skeleton } from 'antd';
import { DeleteOutlined, SendOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import { api } from '@/lib/api';
import type { SharedViewComment } from '@/types/api';
dayjs.extend(relativeTime);
const { Text } = Typography;
interface CalendarCommentsProps {
viewId: string;
date: string;
currentUserId: string;
}
export default function CalendarComments({ viewId, date, currentUserId }: CalendarCommentsProps) {
const [comments, setComments] = useState<SharedViewComment[]>([]);
const [loading, setLoading] = useState(true);
const [newComment, setNewComment] = useState('');
const [submitting, setSubmitting] = useState(false);
const fetchComments = useCallback(async () => {
try {
const { data } = await api.get<SharedViewComment[]>(
`/calendar/shared/${viewId}/comments`,
{ params: { date } },
);
setComments(data);
} catch {
// ignore
} finally {
setLoading(false);
}
}, [viewId, date]);
useEffect(() => {
setLoading(true);
fetchComments();
}, [fetchComments]);
const handleSubmit = async () => {
if (!newComment.trim()) return;
setSubmitting(true);
try {
await api.post(`/calendar/shared/${viewId}/comments`, {
itemDate: date,
content: newComment.trim(),
});
setNewComment('');
await fetchComments();
} catch {
message.error('Failed to post comment');
} finally {
setSubmitting(false);
}
};
const handleDelete = async (commentId: string) => {
try {
await api.delete(`/calendar/shared/${viewId}/comments/${commentId}`);
setComments((prev) => prev.filter((c) => c.id !== commentId));
} catch {
message.error('Failed to delete comment');
}
};
if (loading) return <Skeleton active paragraph={{ rows: 2 }} />;
return (
<div>
<Text strong style={{ fontSize: 13, marginBottom: 8, display: 'block' }}>
Comments
</Text>
{comments.length === 0 ? (
<Empty description="No comments yet" image={Empty.PRESENTED_IMAGE_SIMPLE} />
) : (
<List
size="small"
dataSource={comments}
renderItem={(comment) => (
<List.Item
style={{ padding: '6px 0', alignItems: 'flex-start' }}
actions={
comment.userId === currentUserId
? [
<Popconfirm
key="delete"
title="Delete comment?"
onConfirm={() => handleDelete(comment.id)}
okText="Delete"
okType="danger"
>
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>,
]
: undefined
}
>
<div>
<Space size={4}>
<div
style={{
width: 24,
height: 24,
borderRadius: '50%',
background: 'rgba(157, 78, 221, 0.3)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 11,
flexShrink: 0,
}}
>
{(comment.user.name || comment.user.email)[0]?.toUpperCase()}
</div>
<Text strong style={{ fontSize: 12 }}>
{comment.user.name || comment.user.email}
</Text>
<Text type="secondary" style={{ fontSize: 11 }}>
{dayjs(comment.createdAt).fromNow()}
</Text>
</Space>
<div style={{ marginLeft: 28, marginTop: 2, fontSize: 13 }}>
{comment.content}
</div>
</div>
</List.Item>
)}
/>
)}
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<Input
size="small"
placeholder="Add a comment..."
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
onPressEnter={handleSubmit}
disabled={submitting}
/>
<Button
size="small"
type="primary"
icon={<SendOutlined />}
onClick={handleSubmit}
loading={submitting}
disabled={!newComment.trim()}
/>
</div>
</div>
);
}

View File

@ -1,172 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import {
List,
Button,
Modal,
Form,
Checkbox,
Select,
Typography,
message,
} from 'antd';
import {
PlusOutlined,
CopyOutlined,
DeleteOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import { api } from '@/lib/api';
import type { CalendarExportToken, CalendarLayer } from '@/types/api';
const { Text } = Typography;
interface Props {
layers: CalendarLayer[];
}
export default function CalendarExportPanel({ layers }: Props) {
const [tokens, setTokens] = useState<CalendarExportToken[]>([]);
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [form] = Form.useForm();
const fetchTokens = useCallback(async () => {
try {
const { data } = await api.get<{ tokens: CalendarExportToken[] }>('/calendar/export/tokens');
setTokens(data.tokens);
} catch {
// ignore
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchTokens();
}, [fetchTokens]);
const handleCreate = async () => {
try {
const values = await form.validateFields();
await api.post('/calendar/export/tokens', {
includePersonal: values.includePersonal ?? true,
includeLayers: values.includeLayers?.length ? values.includeLayers : null,
});
message.success('Export link created');
setModalOpen(false);
form.resetFields();
await fetchTokens();
} catch {
message.error('Failed to create export link');
}
};
const handleRevoke = (token: CalendarExportToken) => {
Modal.confirm({
title: 'Revoke export link?',
content: 'Anyone using this link will no longer be able to access your calendar.',
okText: 'Revoke',
okType: 'danger',
onOk: async () => {
try {
await api.delete(`/calendar/export/tokens/${token.id}`);
message.success('Export link revoked');
await fetchTokens();
} catch {
message.error('Failed to revoke link');
}
},
});
};
const copyUrl = (token: string) => {
const url = `${window.location.origin}/api/calendar/feed/${token}.ics`;
navigator.clipboard.writeText(url).then(
() => message.success('URL copied'),
() => message.error('Failed to copy'),
);
};
const describeScope = (t: CalendarExportToken) => {
const parts: string[] = [];
if (t.includePersonal) parts.push('Personal events');
if (t.includeLayers?.length) {
const names = t.includeLayers
.map((id) => layers.find((l) => l.id === id)?.name)
.filter(Boolean);
if (names.length) parts.push(names.join(', '));
}
return parts.length ? parts.join(' + ') : 'All layers';
};
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<Text strong>Export Calendar</Text>
<Button size="small" icon={<PlusOutlined />} onClick={() => { form.resetFields(); setModalOpen(true); }}>
Create Export Link
</Button>
</div>
<List
size="small"
loading={loading}
dataSource={tokens}
locale={{ emptyText: 'No export links' }}
renderItem={(t) => (
<List.Item
actions={[
<Button
key="copy"
type="text"
size="small"
icon={<CopyOutlined />}
onClick={() => copyUrl(t.token)}
/>,
<Button
key="revoke"
type="text"
size="small"
danger
icon={<DeleteOutlined />}
onClick={() => handleRevoke(t)}
/>,
]}
>
<List.Item.Meta
title={<Text style={{ fontSize: 13 }}>{describeScope(t)}</Text>}
description={
<Text style={{ fontSize: 11 }} type="secondary">
Created {dayjs(t.createdAt).format('MMM D, YYYY')}
</Text>
}
/>
</List.Item>
)}
/>
<Modal
title="Create Export Link"
open={modalOpen}
onOk={handleCreate}
onCancel={() => { setModalOpen(false); form.resetFields(); }}
okText="Create"
destroyOnClose
>
<Form form={form} layout="vertical" initialValues={{ includePersonal: true }}>
<Form.Item name="includePersonal" valuePropName="checked">
<Checkbox>Include personal events</Checkbox>
</Form.Item>
<Form.Item name="includeLayers" label="Include specific layers (optional)">
<Select
mode="multiple"
allowClear
placeholder="All layers"
options={layers.map((l) => ({ value: l.id, label: l.name }))}
/>
</Form.Item>
</Form>
</Modal>
</div>
);
}

View File

@ -1,233 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import {
List,
Button,
Tag,
Modal,
Form,
Input,
Select,
Tooltip,
Space,
message,
Typography,
} from 'antd';
import {
PlusOutlined,
EditOutlined,
SyncOutlined,
DeleteOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import { api } from '@/lib/api';
import type { CalendarFeed, CalendarFeedInterval } from '@/types/api';
dayjs.extend(relativeTime);
const { Text } = Typography;
const INTERVAL_OPTIONS: { value: CalendarFeedInterval; label: string }[] = [
{ value: 'FIFTEEN_MIN', label: 'Every 15 minutes' },
{ value: 'HOURLY', label: 'Hourly' },
{ value: 'SIX_HOUR', label: 'Every 6 hours' },
{ value: 'DAILY', label: 'Daily' },
];
export default function CalendarFeedsPanel() {
const [feeds, setFeeds] = useState<CalendarFeed[]>([]);
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [editingFeed, setEditingFeed] = useState<CalendarFeed | null>(null);
const [refreshingId, setRefreshingId] = useState<string | null>(null);
const [form] = Form.useForm();
const fetchFeeds = useCallback(async () => {
try {
const { data } = await api.get<{ feeds: CalendarFeed[] }>('/calendar/feeds');
setFeeds(data.feeds);
} catch {
// ignore
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchFeeds();
}, [fetchFeeds]);
const handleSubmit = async () => {
try {
const values = await form.validateFields();
if (editingFeed) {
await api.patch(`/calendar/feeds/${editingFeed.id}`, values);
message.success('Feed updated');
} else {
await api.post('/calendar/feeds', values);
message.success('Feed added');
}
setModalOpen(false);
setEditingFeed(null);
form.resetFields();
await fetchFeeds();
} catch {
message.error('Failed to save feed');
}
};
const handleDelete = (feed: CalendarFeed) => {
Modal.confirm({
title: 'Delete feed?',
content: `Remove "${feed.name}" and all its imported events?`,
okText: 'Delete',
okType: 'danger',
onOk: async () => {
try {
await api.delete(`/calendar/feeds/${feed.id}`);
message.success('Feed deleted');
await fetchFeeds();
} catch {
message.error('Failed to delete feed');
}
},
});
};
const handleRefresh = async (feed: CalendarFeed) => {
setRefreshingId(feed.id);
try {
await api.post(`/calendar/feeds/${feed.id}/refresh`);
message.success('Feed refreshed');
await fetchFeeds();
} catch {
message.error('Failed to refresh feed');
} finally {
setRefreshingId(null);
}
};
const openEdit = (feed: CalendarFeed) => {
setEditingFeed(feed);
form.setFieldsValue({
name: feed.name,
url: feed.url,
refreshInterval: feed.refreshInterval,
});
setModalOpen(true);
};
const openAdd = () => {
setEditingFeed(null);
form.resetFields();
setModalOpen(true);
};
const statusTag = (feed: CalendarFeed) => {
const colorMap: Record<string, string> = { OK: 'green', ERROR: 'red', PENDING: 'gold' };
const tag = (
<Tag color={colorMap[feed.lastStatus] ?? 'default'}>
{feed.lastStatus}
</Tag>
);
if (feed.lastStatus === 'ERROR' && feed.lastError) {
return <Tooltip title={feed.lastError}>{tag}</Tooltip>;
}
return tag;
};
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<Text strong>External Feeds</Text>
<Button size="small" icon={<PlusOutlined />} onClick={openAdd}>
Add Feed
</Button>
</div>
<List
size="small"
loading={loading}
dataSource={feeds}
locale={{ emptyText: 'No external feeds' }}
renderItem={(feed) => (
<List.Item
actions={[
<Button
key="edit"
type="text"
size="small"
icon={<EditOutlined />}
onClick={() => openEdit(feed)}
/>,
<Button
key="refresh"
type="text"
size="small"
icon={<SyncOutlined spin={refreshingId === feed.id} />}
onClick={() => handleRefresh(feed)}
/>,
<Button
key="delete"
type="text"
size="small"
danger
icon={<DeleteOutlined />}
onClick={() => handleDelete(feed)}
/>,
]}
>
<List.Item.Meta
title={
<Space size={8}>
<Text style={{ fontSize: 13 }}>{feed.name}</Text>
{statusTag(feed)}
</Space>
}
description={
<Space size={4} style={{ fontSize: 11 }}>
<span>{feed.itemCount} events</span>
{feed.lastFetchedAt && (
<span>· {dayjs(feed.lastFetchedAt).fromNow()}</span>
)}
</Space>
}
/>
</List.Item>
)}
/>
<Modal
title={editingFeed ? 'Edit Feed' : 'Add External Feed'}
open={modalOpen}
onOk={handleSubmit}
onCancel={() => {
setModalOpen(false);
setEditingFeed(null);
form.resetFields();
}}
okText={editingFeed ? 'Save' : 'Add'}
destroyOnClose
>
<Form form={form} layout="vertical" initialValues={{ refreshInterval: 'HOURLY' }}>
<Form.Item name="name" label="Name" rules={[{ required: true, message: 'Enter a name' }]}>
<Input placeholder="e.g. Work Calendar" />
</Form.Item>
<Form.Item
name="url"
label="ICS URL"
rules={[
{ required: true, message: 'Enter an ICS URL' },
{ type: 'url', message: 'Enter a valid URL' },
]}
>
<Input placeholder="https://example.com/calendar.ics" />
</Form.Item>
<Form.Item name="refreshInterval" label="Refresh Interval">
<Select options={INTERVAL_OPTIONS} />
</Form.Item>
</Form>
</Modal>
</div>
);
}

View File

@ -1,479 +0,0 @@
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: <CalendarOutlined /> },
{ value: 'TIME_BLOCK', label: 'Time Block', icon: <BlockOutlined /> },
{ value: 'REMINDER', label: 'Reminder', icon: <BellOutlined /> },
];
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<CalendarItemType>('EVENT');
const [isAllDay, setIsAllDay] = useState(false);
const [colorOverride, setColorOverride] = useState<string | undefined>(undefined);
const [recurrenceRule, setRecurrenceRule] = useState<CalendarRecurrenceRule | null>(null);
const [recurrenceEnd, setRecurrenceEnd] = useState<string | null>(null);
const [editScope, setEditScope] = useState<SeriesEditScope>('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<string, unknown>) => {
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 (
<Modal
open={open}
onCancel={onCancel}
title={isEditing ? 'Edit Calendar Item' : 'New Calendar Item'}
footer={null}
width={520}
destroyOnClose
>
<Form
form={form}
layout="vertical"
onFinish={handleFinish}
style={{ marginTop: 16 }}
>
{/* Item type selector */}
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
{ITEM_TYPE_OPTIONS.map((opt) => {
const selected = itemType === opt.value;
return (
<div
key={opt.value}
onClick={() => 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}
</div>
);
})}
</div>
<Form.Item
name="title"
rules={[{ required: true, message: 'Title is required' }]}
>
<Input
placeholder={
itemType === 'REMINDER'
? 'Reminder title...'
: itemType === 'TIME_BLOCK'
? 'Block title (e.g. "Focus time")...'
: 'Event title...'
}
size="large"
style={{ fontSize: 16 }}
/>
</Form.Item>
<Form.Item
name="layerId"
label="Layer"
rules={[{ required: true, message: 'Select a layer' }]}
>
<Select
options={userLayers.map((l) => ({
value: l.id,
label: (
<Space>
<span
style={{
width: 10,
height: 10,
borderRadius: '50%',
background: l.color,
display: 'inline-block',
}}
/>
{l.name}
</Space>
),
}))}
placeholder="Select layer"
/>
</Form.Item>
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}>
<Form.Item
name="date"
label="Date"
rules={[{ required: true, message: 'Required' }]}
style={{ flex: 1 }}
>
<DatePicker style={{ width: '100%' }} />
</Form.Item>
<Form.Item label=" " style={{ paddingTop: 4 }}>
<Space>
<Switch
checked={isAllDay}
onChange={setIsAllDay}
size="small"
/>
<Text style={{ fontSize: 13, color: 'rgba(255,255,255,0.65)' }}>All day</Text>
</Space>
</Form.Item>
</div>
{!isAllDay && (
<div style={{ display: 'flex', gap: 12 }}>
<Form.Item
name="startTime"
label="Start"
rules={[{ required: true, message: 'Required' }]}
style={{ flex: 1 }}
>
<TimePicker format="HH:mm" minuteStep={5} style={{ width: '100%' }} />
</Form.Item>
<Form.Item
name="endTime"
label="End"
rules={[{ required: true, message: 'Required' }]}
style={{ flex: 1 }}
>
<TimePicker format="HH:mm" minuteStep={5} style={{ width: '100%' }} />
</Form.Item>
</div>
)}
{itemType === 'EVENT' && (
<Form.Item name="location" label="Location">
<Input prefix={<EnvironmentOutlined />} placeholder="Optional location" />
</Form.Item>
)}
<Form.Item name="description" label="Description">
<TextArea rows={2} placeholder="Optional description" />
</Form.Item>
{/* Time block specific fields */}
{itemType === 'TIME_BLOCK' && (
<div style={{ display: 'flex', gap: 12 }}>
<Form.Item name="busyStatus" label="Status" style={{ flex: 1 }}>
<Select
options={[
{ value: 'BUSY', label: 'Busy' },
{ value: 'TENTATIVE', label: 'Tentative' },
{ value: 'FREE', label: 'Free' },
]}
/>
</Form.Item>
<Form.Item name="showDetailsTo" label="Show details to" style={{ flex: 1 }}>
<Select
options={[
{ value: 'NOBODY', label: 'Nobody' },
{ value: 'FRIENDS', label: 'Friends' },
{ value: 'EVERYONE', label: 'Everyone' },
]}
/>
</Form.Item>
</div>
)}
{/* Visibility override */}
<Form.Item name="visibility" label="Visibility override">
<Select
allowClear
placeholder="Inherit from layer"
options={[
{ value: 'PRIVATE', label: 'Private' },
{ value: 'FRIENDS', label: 'Friends only' },
{ value: 'PUBLIC', label: 'Public' },
]}
/>
</Form.Item>
{/* Color override */}
<div style={{ marginBottom: 16 }}>
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 6 }}>
Color override
</Text>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<div
onClick={() => setColorOverride(undefined)}
style={{
width: 24,
height: 24,
borderRadius: '50%',
background: 'rgba(255,255,255,0.08)',
cursor: 'pointer',
border: !colorOverride ? '2px solid #1890ff' : '2px solid transparent',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 10,
color: 'rgba(255,255,255,0.45)',
}}
title="Use layer color"
>
Auto
</div>
{PRESET_COLORS.map((c) => (
<div
key={c}
onClick={() => setColorOverride(c)}
style={{
width: 24,
height: 24,
borderRadius: '50%',
background: c,
cursor: 'pointer',
border: colorOverride === c ? '2px solid #fff' : '2px solid transparent',
}}
/>
))}
</div>
</div>
{/* Recurrence */}
<Collapse
ghost
items={[
{
key: 'recurrence',
label: (
<Text style={{ fontSize: 13, color: 'rgba(255,255,255,0.65)' }}>
<ClockCircleOutlined style={{ marginRight: 6 }} />
Recurrence
{recurrenceRule && (
<span style={{ color: '#1890ff', marginLeft: 8, fontSize: 12 }}>
(configured)
</span>
)}
</Text>
),
children: (
<RecurrenceEditor
value={recurrenceRule}
endDate={recurrenceEnd}
onChange={(rule, end) => {
setRecurrenceRule(rule);
setRecurrenceEnd(end);
}}
/>
),
},
]}
style={{ marginBottom: 16, background: 'rgba(255,255,255,0.02)', borderRadius: 8 }}
/>
{/* Edit scope for recurring items */}
{isRecurringEdit && (
<div
style={{
marginBottom: 16,
padding: 12,
background: 'rgba(250, 140, 22, 0.08)',
border: '1px solid rgba(250, 140, 22, 0.2)',
borderRadius: 8,
}}
>
<Text style={{ fontSize: 13, color: 'rgba(255,255,255,0.85)', display: 'block', marginBottom: 8 }}>
This is a recurring event. Apply changes to:
</Text>
<Radio.Group
value={editScope}
onChange={(e) => setEditScope(e.target.value)}
>
<Space direction="vertical" size={4}>
<Radio value="THIS_ONLY">This event only</Radio>
<Radio value="THIS_AND_FUTURE">This and future events</Radio>
<Radio value="ALL">All events in the series</Radio>
</Space>
</Radio.Group>
</div>
)}
{/* Actions */}
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, marginTop: 8 }}>
<div>
{isEditing && onDelete && (
<Button
danger
icon={<DeleteOutlined />}
onClick={onDelete}
>
Delete
</Button>
)}
</div>
<Space>
<Button onClick={onCancel}>Cancel</Button>
<Button type="primary" htmlType="submit" loading={loading}>
{isEditing ? 'Save Changes' : 'Create'}
</Button>
</Space>
</div>
</Form>
</Modal>
);
}

View File

@ -1,435 +0,0 @@
import { useState } from 'react';
import {
Typography,
Switch,
Button,
Input,
Popconfirm,
Tooltip,
Space,
Divider,
} from 'antd';
import {
PlusOutlined,
DeleteOutlined,
TeamOutlined,
GlobalOutlined,
LockOutlined,
CheckOutlined,
CloseOutlined,
ThunderboltOutlined,
UserOutlined,
CloudOutlined,
} from '@ant-design/icons';
import type {
CalendarLayer,
CalendarLayerType,
CalendarVisibility,
} from '@/types/api';
const { Text } = Typography;
const PRESET_COLORS = [
'#1890ff', '#52c41a', '#fa8c16', '#722ed1', '#eb2f96',
'#13c2c2', '#f5222d', '#faad14', '#2f54eb', '#a0d911',
];
const VISIBILITY_ICONS: Record<CalendarVisibility, React.ReactNode> = {
PRIVATE: <LockOutlined />,
FRIENDS: <TeamOutlined />,
PUBLIC: <GlobalOutlined />,
};
const VISIBILITY_LABELS: Record<CalendarVisibility, string> = {
PRIVATE: 'Private',
FRIENDS: 'Friends only',
PUBLIC: 'Public',
};
const GROUP_ICONS: Record<CalendarLayerType, React.ReactNode> = {
SYSTEM: <ThunderboltOutlined />,
USER: <UserOutlined />,
EXTERNAL: <CloudOutlined />,
};
const GROUP_LABELS: Record<CalendarLayerType, string> = {
SYSTEM: 'System',
USER: 'Personal',
EXTERNAL: 'External',
};
interface CalendarLayerPanelProps {
layers: CalendarLayer[];
compact?: boolean;
onToggle: (layerId: string, enabled: boolean) => void;
onCreate: (name: string, color: string) => void;
onUpdate: (layerId: string, data: Partial<CalendarLayer>) => void;
onDelete: (layerId: string) => void;
loading?: boolean;
}
export default function CalendarLayerPanel({
layers,
compact,
onToggle,
onCreate,
onUpdate,
onDelete,
loading,
}: CalendarLayerPanelProps) {
const [showAddForm, setShowAddForm] = useState(false);
const [newName, setNewName] = useState('');
const [newColor, setNewColor] = useState(PRESET_COLORS[0]!);
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState('');
const [colorPickerLayerId, setColorPickerLayerId] = useState<string | null>(null);
const grouped = (['SYSTEM', 'USER', 'EXTERNAL'] as CalendarLayerType[]).map((type) => ({
type,
layers: layers.filter((l) => l.layerType === type).sort((a, b) => a.sortOrder - b.sortOrder),
})).filter((g) => g.layers.length > 0 || g.type === 'USER');
const handleCreate = () => {
if (!newName.trim()) return;
onCreate(newName.trim(), newColor);
setNewName('');
setNewColor(PRESET_COLORS[0]!);
setShowAddForm(false);
};
const handleEditSubmit = (layerId: string) => {
if (editName.trim()) {
onUpdate(layerId, { name: editName.trim() });
}
setEditingId(null);
};
const handleColorChange = (layerId: string, color: string) => {
onUpdate(layerId, { color });
setColorPickerLayerId(null);
};
// Compact mode: horizontal strip with just color dots + toggle
if (compact) {
return (
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: 8,
padding: '8px 0',
marginBottom: 8,
borderBottom: '1px solid rgba(255,255,255,0.08)',
}}
>
{layers.map((layer) => (
<Tooltip key={layer.id} title={layer.name}>
<div
onClick={() => onToggle(layer.id, !layer.isEnabled)}
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
padding: '4px 8px',
borderRadius: 12,
cursor: 'pointer',
userSelect: 'none',
background: layer.isEnabled ? 'rgba(255,255,255,0.06)' : 'transparent',
opacity: layer.isEnabled ? 1 : 0.4,
border: `1px solid ${layer.isEnabled ? layer.color : 'rgba(255,255,255,0.1)'}`,
transition: 'all 0.2s',
}}
>
<div
style={{
width: 8,
height: 8,
borderRadius: '50%',
background: layer.color,
flexShrink: 0,
}}
/>
<Text style={{ fontSize: 11, color: 'rgba(255,255,255,0.75)' }}>
{layer.name}
</Text>
</div>
</Tooltip>
))}
</div>
);
}
const renderColorDot = (layer: CalendarLayer) => {
const isOpen = colorPickerLayerId === layer.id;
return (
<div style={{ position: 'relative' }}>
<div
onClick={() => {
if (layer.layerType === 'SYSTEM') return;
setColorPickerLayerId(isOpen ? null : layer.id);
}}
style={{
width: 14,
height: 14,
borderRadius: '50%',
background: layer.color,
cursor: layer.layerType === 'SYSTEM' ? 'default' : 'pointer',
flexShrink: 0,
border: '2px solid rgba(255,255,255,0.15)',
transition: 'transform 0.15s',
}}
/>
{isOpen && (
<div
style={{
position: 'absolute',
top: 20,
left: 0,
zIndex: 10,
background: '#1b2838',
border: '1px solid rgba(255,255,255,0.12)',
borderRadius: 8,
padding: 8,
display: 'flex',
flexWrap: 'wrap',
gap: 6,
width: 160,
boxShadow: '0 4px 12px rgba(0,0,0,0.4)',
}}
>
{PRESET_COLORS.map((c) => (
<div
key={c}
onClick={() => handleColorChange(layer.id, c)}
style={{
width: 24,
height: 24,
borderRadius: '50%',
background: c,
cursor: 'pointer',
border: c === layer.color ? '2px solid #fff' : '2px solid transparent',
transition: 'transform 0.15s',
}}
/>
))}
</div>
)}
</div>
);
};
const renderLayerRow = (layer: CalendarLayer) => {
const isEditing = editingId === layer.id;
const vis = layer.visibility;
return (
<div
key={layer.id}
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '6px 0',
opacity: layer.isEnabled ? 1 : 0.5,
transition: 'opacity 0.2s',
}}
>
{renderColorDot(layer)}
{isEditing ? (
<Input
size="small"
value={editName}
onChange={(e) => setEditName(e.target.value)}
onPressEnter={() => handleEditSubmit(layer.id)}
onBlur={() => handleEditSubmit(layer.id)}
autoFocus
style={{ flex: 1, fontSize: 13 }}
/>
) : (
<Text
style={{
flex: 1,
fontSize: 13,
color: 'rgba(255,255,255,0.85)',
cursor: layer.layerType !== 'SYSTEM' ? 'pointer' : 'default',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
onClick={() => {
if (layer.layerType !== 'SYSTEM') {
setEditingId(layer.id);
setEditName(layer.name);
}
}}
>
{layer.name}
</Text>
)}
<Tooltip title={VISIBILITY_LABELS[vis]}>
<span
style={{
fontSize: 12,
color: vis === 'PUBLIC' ? 'rgba(82,196,26,0.7)' : 'rgba(255,255,255,0.35)',
cursor: layer.layerType !== 'SYSTEM' ? 'pointer' : 'default',
}}
onClick={() => {
if (layer.layerType === 'SYSTEM') return;
const cycle: CalendarVisibility[] = ['PRIVATE', 'FRIENDS', 'PUBLIC'];
const next = cycle[(cycle.indexOf(vis) + 1) % cycle.length];
onUpdate(layer.id, { visibility: next });
}}
>
{VISIBILITY_ICONS[vis]}
</span>
</Tooltip>
<Switch
size="small"
checked={layer.isEnabled}
onChange={(checked) => onToggle(layer.id, checked)}
/>
{layer.layerType !== 'SYSTEM' && (
<Popconfirm
title="Delete this layer?"
description="All items in this layer will be deleted."
onConfirm={() => onDelete(layer.id)}
okText="Delete"
okButtonProps={{ danger: true }}
>
<Button
type="text"
size="small"
icon={<DeleteOutlined />}
style={{ color: 'rgba(255,255,255,0.25)', padding: '0 4px' }}
/>
</Popconfirm>
)}
</div>
);
};
return (
<div
style={{
background: 'rgba(255,255,255,0.03)',
borderRadius: 8,
border: '1px solid rgba(255,255,255,0.08)',
padding: '12px 16px',
display: 'flex',
flexDirection: 'column',
gap: 4,
}}
>
<Text strong style={{ color: 'rgba(255,255,255,0.85)', fontSize: 14, marginBottom: 4 }}>
Layers
</Text>
{loading && (
<Text type="secondary" style={{ fontSize: 12, padding: '8px 0' }}>
Loading layers...
</Text>
)}
{grouped.map((group) => (
<div key={group.type}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '6px 0 2px',
}}
>
<span style={{ color: 'rgba(255,255,255,0.35)', fontSize: 11 }}>
{GROUP_ICONS[group.type]}
</span>
<Text
type="secondary"
style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.5 }}
>
{GROUP_LABELS[group.type]}
</Text>
</div>
{group.layers.map(renderLayerRow)}
{group.layers.length === 0 && (
<Text type="secondary" style={{ fontSize: 12, padding: '4px 0 4px 22px', display: 'block' }}>
No layers
</Text>
)}
<Divider style={{ margin: '6px 0' }} />
</div>
))}
{showAddForm ? (
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 8,
padding: '8px 0',
}}
>
<Input
size="small"
placeholder="Layer name"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onPressEnter={handleCreate}
autoFocus
/>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{PRESET_COLORS.map((c) => (
<div
key={c}
onClick={() => setNewColor(c)}
style={{
width: 20,
height: 20,
borderRadius: '50%',
background: c,
cursor: 'pointer',
border: c === newColor ? '2px solid #fff' : '2px solid transparent',
}}
/>
))}
</div>
<Space>
<Button
size="small"
type="primary"
icon={<CheckOutlined />}
onClick={handleCreate}
disabled={!newName.trim()}
>
Add
</Button>
<Button
size="small"
icon={<CloseOutlined />}
onClick={() => setShowAddForm(false)}
>
Cancel
</Button>
</Space>
</div>
) : (
<Button
type="dashed"
size="small"
icon={<PlusOutlined />}
onClick={() => setShowAddForm(true)}
block
style={{ marginTop: 4 }}
>
Add Layer
</Button>
)}
</div>
);
}

View File

@ -1,97 +0,0 @@
import { useState } from 'react';
import { Button, Popover, Space, Tooltip, message } from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import { api } from '@/lib/api';
import type { SharedViewReactionGroup } from '@/types/api';
const EMOJI_PALETTE = ['👍', '❤️', '🎉', '😄', '🤔', '👀', '🔥', '⭐', '💪', '📅'];
interface CalendarReactionsProps {
viewId: string;
itemId: string;
reactions: SharedViewReactionGroup[];
currentUserId: string;
onUpdate: () => void;
}
export default function CalendarReactions({
viewId,
itemId,
reactions,
currentUserId,
onUpdate,
}: CalendarReactionsProps) {
const [paletteOpen, setPaletteOpen] = useState(false);
const toggleReaction = async (emoji: string) => {
try {
await api.post(`/calendar/shared/${viewId}/reactions`, { itemId, emoji });
onUpdate();
} catch {
message.error('Failed to update reaction');
}
setPaletteOpen(false);
};
return (
<Space size={4} wrap style={{ marginTop: 4 }}>
{reactions.map((r) => {
const hasReacted = r.users.some((u) => u.id === currentUserId);
const tooltip = r.users.map((u) => u.name || 'Someone').join(', ');
return (
<Tooltip key={r.emoji} title={tooltip}>
<Button
size="small"
type={hasReacted ? 'primary' : 'default'}
style={{
fontSize: 13,
padding: '0 6px',
height: 24,
borderRadius: 12,
opacity: hasReacted ? 1 : 0.7,
}}
onClick={() => toggleReaction(r.emoji)}
>
{r.emoji} {r.count}
</Button>
</Tooltip>
);
})}
<Popover
open={paletteOpen}
onOpenChange={setPaletteOpen}
trigger="click"
placement="bottom"
content={
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, maxWidth: 200 }}>
{EMOJI_PALETTE.map((emoji) => (
<Button
key={emoji}
size="small"
type="text"
style={{ fontSize: 18, width: 36, height: 36 }}
onClick={() => toggleReaction(emoji)}
>
{emoji}
</Button>
))}
</div>
}
>
<Button
size="small"
type="text"
icon={<PlusOutlined />}
style={{
fontSize: 12,
height: 24,
width: 24,
borderRadius: 12,
opacity: 0.5,
}}
/>
</Popover>
</Space>
);
}

View File

@ -1,424 +0,0 @@
import { useMemo } from 'react';
import { Button, Typography, Empty, theme } from 'antd';
import {
LeftOutlined,
RightOutlined,
PlusOutlined,
BellOutlined,
EnvironmentOutlined,
LockOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import type { Dayjs } from 'dayjs';
import type { PersonalCalendarItem, CalendarLayer } from '@/types/api';
const { Text } = Typography;
const { useToken } = theme;
interface MobileDayViewProps {
items: PersonalCalendarItem[];
layers?: CalendarLayer[];
currentMonth: Dayjs;
selectedDate: string | null;
onDateSelect: (date: string) => void;
onMonthChange: (month: Dayjs) => void;
onItemClick: (item: PersonalCalendarItem) => void;
onAddItem: (date: string) => void;
}
// Time slots from 6am to 10pm
const START_HOUR = 6;
const END_HOUR = 22;
const SLOT_HEIGHT = 60; // px per hour
export default function MobileDayView({
items,
selectedDate,
onDateSelect,
onItemClick,
onAddItem,
}: MobileDayViewProps) {
const { token } = useToken();
const currentDate = selectedDate ? dayjs(selectedDate) : dayjs();
const dateKey = currentDate.format('YYYY-MM-DD');
const isToday = dateKey === dayjs().format('YYYY-MM-DD');
// Filter items for the selected date
const dayItems = useMemo(() => {
return items
.filter((item) => item.date === dateKey)
.sort((a, b) => a.startTime.localeCompare(b.startTime));
}, [items, dateKey]);
const allDayItems = dayItems.filter((item) => item.isAllDay);
const timedItems = dayItems.filter((item) => !item.isAllDay);
// Calculate position and height of timed items
const positionedItems = useMemo(() => {
return timedItems.map((item) => {
const parts = item.startTime.split(':');
const sh = parseInt(parts[0] ?? '0', 10);
const sm = parseInt(parts[1] ?? '0', 10);
const endParts = item.endTime.split(':');
const eh = parseInt(endParts[0] ?? '0', 10);
const em = parseInt(endParts[1] ?? '0', 10);
const startMinutes = sh * 60 + sm;
const endMinutes = eh * 60 + em;
const topMinutes = startMinutes - START_HOUR * 60;
const durationMinutes = Math.max(endMinutes - startMinutes, 15); // min 15min display
return {
item,
top: Math.max(0, (topMinutes / 60) * SLOT_HEIGHT),
height: Math.max(20, (durationMinutes / 60) * SLOT_HEIGHT),
};
});
}, [timedItems]);
const totalHeight = (END_HOUR - START_HOUR) * SLOT_HEIGHT;
// Current time indicator position
const nowIndicatorTop = useMemo(() => {
if (!isToday) return null;
const now = dayjs();
const minutes = now.hour() * 60 + now.minute();
const top = ((minutes - START_HOUR * 60) / 60) * SLOT_HEIGHT;
if (top < 0 || top > totalHeight) return null;
return top;
}, [isToday, totalHeight]);
const handlePrev = () => {
onDateSelect(currentDate.subtract(1, 'day').format('YYYY-MM-DD'));
};
const handleNext = () => {
onDateSelect(currentDate.add(1, 'day').format('YYYY-MM-DD'));
};
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
height: '100%',
background: 'rgba(255,255,255,0.02)',
borderRadius: 8,
border: '1px solid rgba(255,255,255,0.08)',
overflow: 'hidden',
position: 'relative',
}}
>
{/* Date header with navigation */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '12px 16px',
borderBottom: '1px solid rgba(255,255,255,0.08)',
background: 'rgba(255,255,255,0.03)',
}}
>
<Button
type="text"
icon={<LeftOutlined />}
onClick={handlePrev}
style={{ color: 'rgba(255,255,255,0.65)' }}
/>
<div style={{ textAlign: 'center' }}>
<Text
strong
style={{
color: isToday ? token.colorPrimary : 'rgba(255,255,255,0.85)',
fontSize: 16,
display: 'block',
}}
>
{currentDate.format('dddd')}
</Text>
<Text style={{ color: 'rgba(255,255,255,0.55)', fontSize: 13 }}>
{currentDate.format('MMMM D, YYYY')}
</Text>
</div>
<Button
type="text"
icon={<RightOutlined />}
onClick={handleNext}
style={{ color: 'rgba(255,255,255,0.65)' }}
/>
</div>
{/* Today button */}
{!isToday && (
<div style={{ textAlign: 'center', padding: '6px 0' }}>
<Button
type="link"
size="small"
onClick={() => onDateSelect(dayjs().format('YYYY-MM-DD'))}
style={{ fontSize: 12 }}
>
Go to today
</Button>
</div>
)}
{/* All-day items section */}
{allDayItems.length > 0 && (
<div
style={{
padding: '8px 16px',
borderBottom: '1px solid rgba(255,255,255,0.08)',
display: 'flex',
flexDirection: 'column',
gap: 4,
}}
>
<Text type="secondary" style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.5 }}>
All Day
</Text>
{allDayItems.map((item) => (
<ItemPill
key={item.id}
item={item}
onClick={() => onItemClick(item)}
/>
))}
</div>
)}
{/* Scrollable time grid */}
<div
style={{
flex: 1,
overflowY: 'auto',
overflowX: 'hidden',
position: 'relative',
}}
>
{dayItems.length === 0 && (
<div style={{ padding: '60px 20px', textAlign: 'center' }}>
<Empty
description="Nothing scheduled"
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
</div>
)}
{(timedItems.length > 0 || allDayItems.length > 0) && (
<div style={{ position: 'relative', height: totalHeight, margin: '0 16px' }}>
{/* Hour lines */}
{Array.from({ length: END_HOUR - START_HOUR + 1 }, (_, i) => {
const hour = START_HOUR + i;
const top = i * SLOT_HEIGHT;
return (
<div
key={hour}
style={{
position: 'absolute',
top,
left: 0,
right: 0,
display: 'flex',
alignItems: 'flex-start',
}}
>
<Text
style={{
width: 44,
fontSize: 11,
color: 'rgba(255,255,255,0.3)',
textAlign: 'right',
paddingRight: 8,
marginTop: -7,
flexShrink: 0,
}}
>
{hour === 0 ? '12 AM' : hour < 12 ? `${hour} AM` : hour === 12 ? '12 PM' : `${hour - 12} PM`}
</Text>
<div
style={{
flex: 1,
borderTop: '1px solid rgba(255,255,255,0.06)',
height: 0,
}}
/>
</div>
);
})}
{/* Current time indicator */}
{nowIndicatorTop !== null && (
<div
style={{
position: 'absolute',
top: nowIndicatorTop,
left: 44,
right: 0,
height: 2,
background: token.colorError,
zIndex: 5,
borderRadius: 1,
}}
>
<div
style={{
position: 'absolute',
left: -4,
top: -3,
width: 8,
height: 8,
borderRadius: '50%',
background: token.colorError,
}}
/>
</div>
)}
{/* Positioned items */}
{positionedItems.map(({ item, top, height }) => {
const isTimeBlock = item.itemType === 'TIME_BLOCK';
const isReminder = item.itemType === 'REMINDER';
const color = item.color || token.colorPrimary;
const isBusyHidden = isTimeBlock && item.showDetailsTo === 'NOBODY';
return (
<div
key={item.id}
onClick={() => onItemClick(item)}
style={{
position: 'absolute',
top: top + 1,
left: 50,
right: 4,
height: height - 2,
background: isTimeBlock
? hexToRgba(color, 0.1)
: hexToRgba(color, 0.2),
border: isTimeBlock
? `1px dashed ${hexToRgba(color, 0.35)}`
: `1px solid ${hexToRgba(color, 0.4)}`,
borderLeft: `3px solid ${color}`,
borderRadius: 6,
padding: '4px 8px',
cursor: 'pointer',
overflow: 'hidden',
zIndex: 2,
transition: 'background 0.15s',
}}
>
{isBusyHidden ? (
<Text style={{ fontSize: 12, color: 'rgba(255,255,255,0.45)' }}>
<LockOutlined style={{ marginRight: 4, fontSize: 10 }} />
Busy
</Text>
) : (
<>
<Text
strong
style={{
fontSize: 12,
color: 'rgba(255,255,255,0.85)',
display: 'block',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{isReminder && <BellOutlined style={{ marginRight: 4, fontSize: 10 }} />}
{item.title}
</Text>
<Text style={{ fontSize: 11, color: 'rgba(255,255,255,0.5)' }}>
{item.startTime} - {item.endTime}
</Text>
{item.location && height > 45 && (
<Text
style={{
fontSize: 10,
color: 'rgba(255,255,255,0.4)',
display: 'block',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
<EnvironmentOutlined style={{ marginRight: 3 }} />
{item.location}
</Text>
)}
</>
)}
</div>
);
})}
</div>
)}
</div>
{/* Floating add button */}
<Button
type="primary"
shape="circle"
icon={<PlusOutlined />}
size="large"
onClick={() => onAddItem(dateKey)}
style={{
position: 'absolute',
bottom: 20,
right: 20,
width: 48,
height: 48,
boxShadow: '0 4px 12px rgba(0,0,0,0.4)',
zIndex: 10,
}}
/>
</div>
);
}
/** Compact item pill for all-day items */
function ItemPill({
item,
onClick,
}: {
item: PersonalCalendarItem;
onClick: () => void;
}) {
const isTimeBlock = item.itemType === 'TIME_BLOCK';
const isReminder = item.itemType === 'REMINDER';
const color = item.color;
return (
<div
onClick={onClick}
style={{
background: hexToRgba(color, isTimeBlock ? 0.1 : 0.2),
border: isTimeBlock
? `1px dashed ${hexToRgba(color, 0.35)}`
: `1px solid ${hexToRgba(color, 0.4)}`,
borderLeft: `3px solid ${color}`,
borderRadius: 6,
padding: '6px 10px',
cursor: 'pointer',
overflow: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
fontSize: 13,
color: 'rgba(255,255,255,0.85)',
}}
>
{isReminder && <BellOutlined style={{ marginRight: 4, fontSize: 11 }} />}
{item.title}
</div>
);
}
/** Convert a hex color to rgba string */
function hexToRgba(hex: string, alpha: number): string {
const cleaned = hex.replace('#', '');
if (cleaned.length !== 6) return `rgba(24, 144, 255, ${alpha})`;
const r = parseInt(cleaned.slice(0, 2), 16);
const g = parseInt(cleaned.slice(2, 4), 16);
const b = parseInt(cleaned.slice(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}

View File

@ -1,173 +0,0 @@
import { useMemo } from 'react';
import { Calendar, Spin, Empty, theme } from 'antd';
import { BellOutlined } from '@ant-design/icons';
import type { Dayjs } from 'dayjs';
import type { PersonalCalendarItem, CalendarLayer } from '@/types/api';
const { useToken } = theme;
interface PersonalCalendarViewProps {
items: PersonalCalendarItem[];
layers?: CalendarLayer[];
loading?: boolean;
currentMonth: Dayjs;
selectedDate: string | null;
onDateSelect: (date: string) => void;
onItemClick: (item: PersonalCalendarItem) => void;
onMonthChange: (month: Dayjs) => void;
}
const MAX_CELL_ITEMS = 3;
export default function PersonalCalendarView({
items,
loading,
onDateSelect,
onItemClick,
onMonthChange,
}: PersonalCalendarViewProps) {
useToken();
// Group items by date
const itemsByDate = useMemo(() => {
const map: Record<string, PersonalCalendarItem[]> = {};
for (const item of items) {
const arr = map[item.date];
if (arr) {
arr.push(item);
} else {
map[item.date] = [item];
}
}
// Sort items within each date by startTime
for (const key of Object.keys(map)) {
map[key]!.sort((a, b) => a.startTime.localeCompare(b.startTime));
}
return map;
}, [items]);
const handleSelect = (date: Dayjs) => {
onDateSelect(date.format('YYYY-MM-DD'));
};
const handlePanelChange = (date: Dayjs) => {
onMonthChange(date);
};
const cellRender = (date: Dayjs) => {
const dateKey = date.format('YYYY-MM-DD');
const dayItems = itemsByDate[dateKey];
if (!dayItems || dayItems.length === 0) return null;
const visible = dayItems.slice(0, MAX_CELL_ITEMS);
const overflow = dayItems.length - MAX_CELL_ITEMS;
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 3,
padding: '0 2px',
}}
>
{visible.map((item) => {
const isTimeBlock = item.itemType === 'TIME_BLOCK';
const isReminder = item.itemType === 'REMINDER';
const color = item.color || '#1890ff';
const bgAlpha = isTimeBlock ? 0.1 : 0.2;
const borderAlpha = isTimeBlock ? 0.3 : 0.5;
return (
<div
key={item.id}
onClick={(e) => {
e.stopPropagation();
onItemClick(item);
}}
style={{
background: hexToRgba(color, bgAlpha),
border: isTimeBlock
? `1px dashed ${hexToRgba(color, borderAlpha)}`
: `1px solid ${hexToRgba(color, borderAlpha)}`,
borderLeft: `3px solid ${color}`,
borderRadius: 4,
padding: '2px 5px',
fontSize: 11,
lineHeight: '15px',
overflow: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
color: 'rgba(255,255,255,0.85)',
cursor: 'pointer',
opacity: isTimeBlock ? 0.75 : 1,
}}
>
{!item.isAllDay && (
<span style={{ color: 'rgba(255,255,255,0.5)', marginRight: 3, fontSize: 10 }}>
{item.startTime}
</span>
)}
{isReminder && (
<BellOutlined style={{ fontSize: 9, marginRight: 3, color: 'rgba(255,255,255,0.5)' }} />
)}
{item.title}
</div>
);
})}
{overflow > 0 && (
<div style={{ fontSize: 10, color: 'rgba(255,255,255,0.45)', textAlign: 'center' }}>
+{overflow} more
</div>
)}
</div>
);
};
if (loading) {
return (
<div style={{ textAlign: 'center', padding: 40 }}>
<Spin size="large" />
</div>
);
}
return (
<div style={{ position: 'relative' }}>
{loading && (
<div style={{ textAlign: 'center', padding: 12 }}>
<Spin />
</div>
)}
<Calendar
fullscreen
cellRender={(date) => cellRender(date)}
onSelect={handleSelect}
onPanelChange={handlePanelChange}
/>
{items.length === 0 && (
<div
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
pointerEvents: 'none',
}}
>
<Empty description="No calendar items. Click a date to add one." />
</div>
)}
</div>
);
}
/** Convert a hex color to rgba string */
function hexToRgba(hex: string, alpha: number): string {
const cleaned = hex.replace('#', '');
if (cleaned.length !== 6) return `rgba(24, 144, 255, ${alpha})`;
const r = parseInt(cleaned.slice(0, 2), 16);
const g = parseInt(cleaned.slice(2, 4), 16);
const b = parseInt(cleaned.slice(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}

View File

@ -1,237 +0,0 @@
import { useMemo } from 'react';
import { Select, InputNumber, DatePicker, Space, Typography } from 'antd';
import dayjs from 'dayjs';
import isoWeek from 'dayjs/plugin/isoWeek';
import type { CalendarRecurrenceRule, CalendarRecurrenceFrequency } from '@/types/api';
dayjs.extend(isoWeek);
const { Text } = Typography;
const DAY_LABELS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const DAY_VALUES = [1, 2, 3, 4, 5, 6, 7];
interface RecurrenceEditorProps {
value: CalendarRecurrenceRule | null;
endDate: string | null;
onChange: (rule: CalendarRecurrenceRule | null, endDate: string | null) => void;
}
export default function RecurrenceEditor({ value, endDate, onChange }: RecurrenceEditorProps) {
const frequency = value?.frequency ?? null;
const interval = value?.interval ?? 1;
const daysOfWeek = value?.daysOfWeek ?? [];
const dayOfMonth = value?.dayOfMonth ?? 1;
const handleFrequencyChange = (freq: CalendarRecurrenceFrequency | 'NONE') => {
if (freq === 'NONE') {
onChange(null, null);
return;
}
const rule: CalendarRecurrenceRule = { frequency: freq, interval: 1 };
if (freq === 'WEEKLY' || freq === 'BIWEEKLY') {
rule.daysOfWeek = daysOfWeek.length > 0 ? daysOfWeek : [dayjs().isoWeekday()];
}
if (freq === 'MONTHLY') {
rule.dayOfMonth = dayOfMonth || dayjs().date();
}
onChange(rule, endDate);
};
const handleIntervalChange = (val: number | null) => {
if (!value) return;
onChange({ ...value, interval: val ?? 1 }, endDate);
};
const handleDaysOfWeekToggle = (day: number) => {
if (!value) return;
const current = value.daysOfWeek ?? [];
const next = current.includes(day)
? current.filter(d => d !== day)
: [...current, day].sort((a, b) => a - b);
// Must have at least one day selected
if (next.length === 0) return;
onChange({ ...value, daysOfWeek: next }, endDate);
};
const handleDayOfMonthChange = (val: number | null) => {
if (!value) return;
onChange({ ...value, dayOfMonth: val ?? 1 }, endDate);
};
const handleEndDateChange = (date: dayjs.Dayjs | null) => {
onChange(value, date ? date.format('YYYY-MM-DD') : null);
};
const previewText = useMemo(() => {
if (!value) return '';
const { frequency: freq } = value;
const intv = value.interval ?? 1;
if (freq === 'DAILY') {
return intv === 1 ? 'Every day' : `Every ${intv} days`;
}
if (freq === 'WEEKLY' || freq === 'BIWEEKLY') {
const days = (value.daysOfWeek ?? [])
.map(d => DAY_LABELS[d - 1])
.join(', ');
const base = freq === 'BIWEEKLY'
? 'Every 2 weeks'
: intv === 1
? 'Every week'
: `Every ${intv} weeks`;
return days ? `${base} on ${days}` : base;
}
if (freq === 'MONTHLY') {
const dom = value.dayOfMonth ?? 1;
const suffix = dom === 1 ? 'st' : dom === 2 ? 'nd' : dom === 3 ? 'rd' : 'th';
return intv === 1
? `Every month on the ${dom}${suffix}`
: `Every ${intv} months on the ${dom}${suffix}`;
}
return '';
}, [value]);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div>
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>
Repeats
</Text>
<Select
value={frequency ?? 'NONE'}
onChange={handleFrequencyChange}
style={{ width: '100%' }}
options={[
{ value: 'NONE', label: 'Does not repeat' },
{ value: 'DAILY', label: 'Daily' },
{ value: 'WEEKLY', label: 'Weekly' },
{ value: 'BIWEEKLY', label: 'Bi-weekly' },
{ value: 'MONTHLY', label: 'Monthly' },
]}
/>
</div>
{frequency && frequency !== 'BIWEEKLY' && (
<div>
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>
Every
</Text>
<Space>
<InputNumber
min={1}
max={52}
value={interval}
onChange={handleIntervalChange}
style={{ width: 70 }}
/>
<Text type="secondary">
{frequency === 'DAILY' ? 'day(s)' : frequency === 'MONTHLY' ? 'month(s)' : 'week(s)'}
</Text>
</Space>
</div>
)}
{(frequency === 'WEEKLY' || frequency === 'BIWEEKLY') && (
<div>
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>
On days
</Text>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{DAY_VALUES.map((day, idx) => {
const selected = daysOfWeek.includes(day);
return (
<div
key={day}
onClick={() => handleDaysOfWeekToggle(day)}
style={{
width: 38,
height: 30,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 15,
fontSize: 12,
fontWeight: selected ? 600 : 400,
cursor: 'pointer',
userSelect: 'none',
background: selected ? 'rgba(24, 144, 255, 0.3)' : 'rgba(255,255,255,0.06)',
border: selected ? '1px solid rgba(24, 144, 255, 0.6)' : '1px solid rgba(255,255,255,0.12)',
color: selected ? '#1890ff' : 'rgba(255,255,255,0.65)',
transition: 'all 0.2s',
}}
>
{DAY_LABELS[idx]}
</div>
);
})}
</div>
</div>
)}
{frequency === 'MONTHLY' && (
<div>
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>
Day of month
</Text>
<InputNumber
min={1}
max={31}
value={dayOfMonth}
onChange={handleDayOfMonthChange}
style={{ width: 80 }}
/>
</div>
)}
{frequency && (
<div>
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>
Ends
</Text>
<Space>
<Select
value={endDate ? 'on_date' : 'never'}
onChange={(val) => {
if (val === 'never') {
onChange(value, null);
} else {
onChange(value, dayjs().add(3, 'month').format('YYYY-MM-DD'));
}
}}
style={{ width: 120 }}
options={[
{ value: 'never', label: 'Never' },
{ value: 'on_date', label: 'On date' },
]}
/>
{endDate && (
<DatePicker
value={dayjs(endDate)}
onChange={handleEndDateChange}
disabledDate={(d) => d.isBefore(dayjs(), 'day')}
/>
)}
</Space>
</div>
)}
{frequency && previewText && (
<Text
style={{
fontSize: 12,
color: 'rgba(255,255,255,0.45)',
fontStyle: 'italic',
padding: '4px 0',
}}
>
{previewText}
{endDate ? `, until ${dayjs(endDate).format('MMM D, YYYY')}` : ''}
</Text>
)}
</div>
);
}

View File

@ -1,204 +0,0 @@
import { useState, useCallback } from 'react';
import {
List,
Button,
Typography,
Tag,
Space,
Modal,
Checkbox,
message,
Empty,
Skeleton,
Popconfirm,
} from 'antd';
import { UserAddOutlined, LogoutOutlined } from '@ant-design/icons';
import { api } from '@/lib/api';
import type { SharedCalendarMember } from '@/types/api';
const { Text } = Typography;
interface Friend {
id: string;
name: string | null;
email: string;
}
interface SharedViewMembersPanelProps {
viewId: string;
members: SharedCalendarMember[];
isOwner: boolean;
onInvite: () => void;
onLeave: () => void;
onRefresh: () => void;
}
export default function SharedViewMembersPanel({
viewId,
members,
isOwner,
onInvite: _onInvite,
onLeave,
onRefresh,
}: SharedViewMembersPanelProps) {
const [inviteModalOpen, setInviteModalOpen] = useState(false);
const [friends, setFriends] = useState<Friend[]>([]);
const [selectedFriends, setSelectedFriends] = useState<string[]>([]);
const [loadingFriends, setLoadingFriends] = useState(false);
const [inviting, setInviting] = useState(false);
const memberUserIds = new Set(members.map((m) => m.userId));
const fetchFriends = useCallback(async () => {
setLoadingFriends(true);
try {
const { data } = await api.get('/social/friends');
const accepted = (data.friends || data || [])
.filter((f: any) => f.status === 'accepted')
.map((f: any) => f.friend || f.user || f);
setFriends(accepted);
} catch {
// ignore
} finally {
setLoadingFriends(false);
}
}, []);
const openInviteModal = () => {
setInviteModalOpen(true);
setSelectedFriends([]);
fetchFriends();
};
const handleInvite = async () => {
if (selectedFriends.length === 0) return;
setInviting(true);
try {
await api.post(`/calendar/shared/${viewId}/invite`, { userIds: selectedFriends });
message.success(`Invited ${selectedFriends.length} friend(s)`);
setInviteModalOpen(false);
onRefresh();
} catch {
message.error('Failed to invite');
} finally {
setInviting(false);
}
};
const statusColor: Record<string, string> = {
ACCEPTED: 'green',
INVITED: 'gold',
DECLINED: 'red',
};
return (
<div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<Text strong style={{ fontSize: 14 }}>Members</Text>
<Button size="small" icon={<UserAddOutlined />} onClick={openInviteModal}>
Invite
</Button>
</div>
{members.length === 0 ? (
<Empty description="No members yet" image={Empty.PRESENTED_IMAGE_SIMPLE} />
) : (
<List
size="small"
dataSource={members}
renderItem={(member) => (
<List.Item style={{ padding: '6px 0' }}>
<Space size={8}>
<div
style={{
width: 28,
height: 28,
borderRadius: '50%',
background: member.color,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 12,
color: '#fff',
flexShrink: 0,
}}
>
{(member.user.name || member.user.email)[0]?.toUpperCase()}
</div>
<div>
<Text style={{ fontSize: 13 }} ellipsis>
{member.user.name || member.user.email}
</Text>
<div>
<Tag
color={statusColor[member.status] || 'default'}
style={{ fontSize: 10, margin: 0 }}
>
{member.status}
</Tag>
</div>
</div>
</Space>
</List.Item>
)}
/>
)}
{!isOwner && (
<Popconfirm
title="Leave this shared calendar?"
onConfirm={onLeave}
okText="Leave"
okType="danger"
>
<Button
size="small"
danger
icon={<LogoutOutlined />}
style={{ marginTop: 12, width: '100%' }}
>
Leave Calendar
</Button>
</Popconfirm>
)}
<Modal
title="Invite Friends"
open={inviteModalOpen}
onCancel={() => setInviteModalOpen(false)}
onOk={handleInvite}
okText="Send Invites"
confirmLoading={inviting}
okButtonProps={{ disabled: selectedFriends.length === 0 }}
>
{loadingFriends ? (
<Skeleton active paragraph={{ rows: 3 }} />
) : friends.length === 0 ? (
<Empty description="No friends to invite" />
) : (
<Checkbox.Group
value={selectedFriends}
onChange={(vals) => setSelectedFriends(vals as string[])}
style={{ width: '100%' }}
>
<Space direction="vertical" style={{ width: '100%' }}>
{friends.map((friend) => {
const alreadyMember = memberUserIds.has(friend.id);
return (
<Checkbox key={friend.id} value={friend.id} disabled={alreadyMember}>
{friend.name || friend.email}
{alreadyMember && (
<Text type="secondary" style={{ marginLeft: 8, fontSize: 11 }}>
(already member)
</Text>
)}
</Checkbox>
);
})}
</Space>
</Checkbox.Group>
)}
</Modal>
</div>
);
}

View File

@ -155,10 +155,9 @@ export default function UnifiedCalendar({ onShiftSignup, gancioUrl, onEventSubmi
{visible.map(item => { {visible.map(item => {
const isPoll = item.type === 'poll'; const isPoll = item.type === 'poll';
const isShift = item.type === 'shift'; const isShift = item.type === 'shift';
const isTicketed = item.type === 'ticketed_event'; const bg = isPoll ? 'rgba(250, 140, 22, 0.2)' : isShift ? 'rgba(24, 144, 255, 0.2)' : 'rgba(82, 196, 26, 0.2)';
const bg = isTicketed ? 'rgba(114, 46, 209, 0.2)' : isPoll ? 'rgba(250, 140, 22, 0.2)' : isShift ? 'rgba(24, 144, 255, 0.2)' : 'rgba(82, 196, 26, 0.2)'; const border = isPoll ? 'rgba(250, 140, 22, 0.5)' : isShift ? 'rgba(24, 144, 255, 0.5)' : 'rgba(82, 196, 26, 0.5)';
const border = isTicketed ? 'rgba(114, 46, 209, 0.5)' : isPoll ? 'rgba(250, 140, 22, 0.5)' : isShift ? 'rgba(24, 144, 255, 0.5)' : 'rgba(82, 196, 26, 0.5)'; const accent = isPoll ? '#fa8c16' : isShift ? '#1890ff' : '#52c41a';
const accent = isTicketed ? '#722ed1' : isPoll ? '#fa8c16' : isShift ? '#1890ff' : '#52c41a';
return ( return (
<div <div
key={item.id} key={item.id}
@ -179,7 +178,7 @@ export default function UnifiedCalendar({ onShiftSignup, gancioUrl, onEventSubmi
<span style={{ color: 'rgba(255,255,255,0.5)', marginRight: 3, fontSize: 10 }}> <span style={{ color: 'rgba(255,255,255,0.5)', marginRight: 3, fontSize: 10 }}>
{item.startTime} {item.startTime}
</span> </span>
{(item.tags?.includes('video-meeting') || item.eventFormat === 'ONLINE' || item.eventFormat === 'HYBRID') && ( {item.tags?.includes('video-meeting') && (
<VideoCameraOutlined style={{ fontSize: 9, marginRight: 3, color: 'rgba(255,255,255,0.5)' }} /> <VideoCameraOutlined style={{ fontSize: 9, marginRight: 3, color: 'rgba(255,255,255,0.5)' }} />
)} )}
{item.title} {item.title}
@ -198,7 +197,6 @@ export default function UnifiedCalendar({ onShiftSignup, gancioUrl, onEventSubmi
const renderItemCard = (item: UnifiedCalendarItem) => { const renderItemCard = (item: UnifiedCalendarItem) => {
const isShift = item.type === 'shift'; const isShift = item.type === 'shift';
const isPoll = item.type === 'poll'; const isPoll = item.type === 'poll';
const isTicketed = item.type === 'ticketed_event';
const spotsLeft = isShift && item.maxVolunteers const spotsLeft = isShift && item.maxVolunteers
? item.maxVolunteers - (item.currentVolunteers || 0) ? item.maxVolunteers - (item.currentVolunteers || 0)
: null; : null;
@ -206,9 +204,9 @@ export default function UnifiedCalendar({ onShiftSignup, gancioUrl, onEventSubmi
const pct = isShift && item.maxVolunteers && item.maxVolunteers > 0 const pct = isShift && item.maxVolunteers && item.maxVolunteers > 0
? Math.round(((item.currentVolunteers || 0) / item.maxVolunteers) * 100) ? Math.round(((item.currentVolunteers || 0) / item.maxVolunteers) * 100)
: 0; : 0;
const borderColor = isTicketed ? '#722ed1' : isPoll ? '#fa8c16' : isShift ? '#1890ff' : '#52c41a'; const borderColor = isPoll ? '#fa8c16' : isShift ? '#1890ff' : '#52c41a';
const tagColor = isTicketed ? 'purple' : isPoll ? 'orange' : isShift ? 'blue' : 'green'; const tagColor = isPoll ? 'orange' : isShift ? 'blue' : 'green';
const tagLabel = isTicketed ? 'Ticketed' : isPoll ? 'Poll' : isShift ? 'Shift' : 'Event'; const tagLabel = isPoll ? 'Poll' : isShift ? 'Shift' : 'Event';
return ( return (
<Card <Card
@ -222,9 +220,9 @@ export default function UnifiedCalendar({ onShiftSignup, gancioUrl, onEventSubmi
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 8, marginBottom: 4 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 8, marginBottom: 4 }}>
<Text strong style={{ color: '#fff', fontSize: 14 }}> <Text strong style={{ color: '#fff', fontSize: 14 }}>
{item.title} {item.title}
{(item.tags?.includes('video-meeting') || item.eventFormat === 'ONLINE' || item.eventFormat === 'HYBRID') && ( {item.tags?.includes('video-meeting') && (
<Tooltip title={item.eventFormat === 'HYBRID' ? 'Hybrid (Online + In-Person)' : 'Online Event'}> <Tooltip title="Video Meeting">
<VideoCameraOutlined style={{ marginLeft: 6, fontSize: 13, color: '#722ed1' }} /> <VideoCameraOutlined style={{ marginLeft: 6, fontSize: 13, color: '#52c41a' }} />
</Tooltip> </Tooltip>
)} )}
</Text> </Text>
@ -254,27 +252,6 @@ export default function UnifiedCalendar({ onShiftSignup, gancioUrl, onEventSubmi
</div> </div>
)} )}
{/* Ticketed event: capacity bar */}
{isTicketed && item.maxAttendees != null && (
<div style={{ marginBottom: 8 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 2 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
<TeamOutlined style={{ marginRight: 4 }} />
Attendees
</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
{item.currentAttendees || 0}/{item.maxAttendees}
</Text>
</div>
<Progress
percent={Math.round(((item.currentAttendees || 0) / item.maxAttendees) * 100)}
size="small"
status={item.isSoldOut ? 'exception' : 'active'}
showInfo={false}
/>
</div>
)}
{/* Shift-specific: capacity bar */} {/* Shift-specific: capacity bar */}
{isShift && item.maxVolunteers != null && ( {isShift && item.maxVolunteers != null && (
<div style={{ marginBottom: 8 }}> <div style={{ marginBottom: 8 }}>
@ -339,16 +316,6 @@ export default function UnifiedCalendar({ onShiftSignup, gancioUrl, onEventSubmi
Vote ({item.pollVoteCount ?? 0}) Vote ({item.pollVoteCount ?? 0})
</Button> </Button>
)} )}
{isTicketed && item.eventSlug && (
<Button
type="primary"
size="small"
href={`/event/${item.eventSlug}`}
>
{item.isSoldOut ? 'Sold Out' : 'Get Tickets'}
</Button>
)}
</Space> </Space>
</Card> </Card>
); );

View File

@ -1,799 +0,0 @@
import { useState, useRef, useCallback, useMemo, useEffect } from 'react';
import {
Segmented,
Tree,
Input,
Button,
Spin,
Modal,
Typography,
Dropdown,
List,
theme,
Result,
} from 'antd';
import type { TreeDataNode, MenuProps } from 'antd';
import {
FileAddOutlined,
FolderAddOutlined,
DeleteOutlined,
EditOutlined,
SearchOutlined,
ReloadOutlined,
FileMarkdownOutlined,
UploadOutlined,
EyeOutlined,
CodeOutlined,
FolderOutlined,
BuildOutlined,
EllipsisOutlined,
PlusOutlined,
CloseOutlined,
FileOutlined,
} from '@ant-design/icons';
import type { UseDocsEditorReturn } from '@/hooks/useDocsEditor';
import { isImageFile } from '@/hooks/useDocsEditor';
import { MobileFormattingToolbar } from './MobileFormattingToolbar';
import type { InsertRequestType } from './MobileFormattingToolbar';
import { useMkDocsBuild } from '@/hooks/useMkDocsBuild';
import { VideoPickerModal } from '@/components/media/VideoPickerModal';
import type { Video as PickerVideo } from '@/components/media/VideoPickerModal';
import { PhotoPickerModal } from '@/components/media/PhotoPickerModal';
import type { Photo as PickerPhoto } from '@/components/media/PhotoPickerModal';
import { PhotoInsertModal } from '@/components/media/PhotoInsertModal';
import type { PhotoInsertResult } from '@/components/media/PhotoInsertModal';
import { generateVideoCardHtml } from '@/utils/videoCardHtml';
import { generatePhotoCardHtml } from '@/utils/photoCardHtml';
import { DonateInsertModal } from '@/components/payments/DonateInsertModal';
import type { DonateInsertResult } from '@/components/payments/DonateInsertModal';
import { ProductInsertModal } from '@/components/payments/ProductInsertModal';
import type { ProductInsertResult } from '@/components/payments/ProductInsertModal';
import { AdPickerModal } from '@/components/media/AdPickerModal';
import type { AdInsertResult } from '@/components/media/AdPickerModal';
import { PollInsertModal } from '@/components/scheduling/PollInsertModal';
type MobileTab = 'files' | 'editor' | 'preview';
interface MobileDocsEditorProps {
editor: UseDocsEditorReturn;
}
// Flatten file tree into a searchable list of file paths
interface FlatFile { path: string; name: string; }
function flattenFiles(nodes: import('@/types/api').FileNode[]): FlatFile[] {
const out: FlatFile[] = [];
for (const n of nodes) {
if (n.isDirectory) {
if (n.children) out.push(...flattenFiles(n.children));
} else {
out.push({ path: n.path, name: n.name });
}
}
return out;
}
function fileNodeToTreeData(nodes: import('@/types/api').FileNode[]): TreeDataNode[] {
return nodes.map((node) => {
const displayName = !node.isDirectory && node.name.endsWith('.md')
? node.name.slice(0, -3)
: node.name;
const treeNode: TreeDataNode = {
key: node.path,
title: displayName,
isLeaf: !node.isDirectory,
};
if (node.isDirectory && node.children) {
treeNode.children = fileNodeToTreeData(node.children);
}
return treeNode;
});
}
const LINE_HEIGHT = 20;
const MONO_FONT = 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace';
const FONT_SIZE = 13;
function LineNumberedEditor({
textareaRef,
value,
onChange,
token,
}: {
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
value: string;
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
token: ReturnType<typeof theme.useToken>['token'];
}) {
const gutterRef = useRef<HTMLDivElement>(null);
const lineCount = useMemo(() => value.split('\n').length, [value]);
// Sync gutter scroll with textarea scroll
const handleScroll = useCallback(() => {
if (gutterRef.current && textareaRef.current) {
gutterRef.current.scrollTop = textareaRef.current.scrollTop;
}
}, [textareaRef]);
// Attach scroll listener
useEffect(() => {
const ta = textareaRef.current;
if (!ta) return;
ta.addEventListener('scroll', handleScroll);
return () => ta.removeEventListener('scroll', handleScroll);
}, [textareaRef, handleScroll]);
return (
<div style={{ flex: 1, display: 'flex', minHeight: 0, overflow: 'hidden' }}>
{/* Line number gutter */}
<div
ref={gutterRef}
style={{
width: 36,
flexShrink: 0,
overflow: 'hidden',
paddingTop: 4,
background: token.colorBgLayout,
borderRight: `1px solid ${token.colorBorderSecondary}`,
userSelect: 'none',
fontFamily: MONO_FONT,
fontSize: FONT_SIZE - 2,
lineHeight: `${LINE_HEIGHT}px`,
color: token.colorTextQuaternary,
textAlign: 'right',
paddingRight: 6,
}}
>
{Array.from({ length: lineCount }, (_, i) => (
<div key={i + 1} style={{ height: LINE_HEIGHT }}>{i + 1}</div>
))}
</div>
{/* Textarea */}
<textarea
ref={textareaRef}
value={value}
onChange={onChange}
spellCheck={false}
style={{
flex: 1,
minHeight: 0,
height: '100%',
border: 'none',
outline: 'none',
resize: 'none',
padding: '4px 6px',
margin: 0,
fontFamily: MONO_FONT,
fontSize: FONT_SIZE,
lineHeight: `${LINE_HEIGHT}px`,
background: 'transparent',
color: token.colorText,
boxSizing: 'border-box',
overflow: 'auto',
whiteSpace: 'pre',
WebkitTextSizeAdjust: 'none',
}}
/>
</div>
);
}
export function MobileDocsEditor({ editor }: MobileDocsEditorProps) {
const { token } = theme.useToken();
const { building, confirmAndBuild, isSuperAdmin } = useMkDocsBuild();
const [activeTab, setActiveTab] = useState<MobileTab>('files');
const [searchOpen, setSearchOpen] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Insert modal state
const [videoPickerOpen, setVideoPickerOpen] = useState(false);
const [photoInsertOpen, setPhotoInsertOpen] = useState(false);
const [photoPickerOpen, setPhotoPickerOpen] = useState(false);
const [pendingPhotoVariant, setPendingPhotoVariant] = useState<PhotoInsertResult | null>(null);
const [donateInsertOpen, setDonateInsertOpen] = useState(false);
const [productInsertOpen, setProductInsertOpen] = useState(false);
const [adPickerOpen, setAdPickerOpen] = useState(false);
const [pollInsertOpen, setPollInsertOpen] = useState(false);
const {
fileTree,
filteredTree,
selectedFile,
fileContent,
dirty,
saving,
fileLoading,
loading,
fetchError,
filterQuery,
expandedKeys,
modalType,
modalInput,
contextPath,
config,
setFilterQuery,
setExpandedKeys,
setModalType,
setModalInput,
setContextPath,
fetchData,
loadFile,
saveFile,
onContentChange,
handleDelete,
handleModalOk,
handleNewFileRoot,
handleNewFolderRoot,
refreshTree,
handleUploadFiles,
isDirectoryPath,
previewIframeRef,
fileInputRef,
contextHolder,
} = editor;
const treeData = useMemo(() => fileNodeToTreeData(filteredTree), [filteredTree]);
// Flat file list for search results
const allFiles = useMemo(() => flattenFiles(fileTree), [fileTree]);
const searchResults = useMemo(() => {
if (!filterQuery.trim() || filterQuery.length < 2) return [];
const q = filterQuery.toLowerCase();
return allFiles
.filter(f => f.path.toLowerCase().includes(q) || f.name.toLowerCase().includes(q))
.slice(0, 20);
}, [filterQuery, allFiles]);
// Helper: insert HTML at textarea cursor position
const insertHtml = useCallback((html: string) => {
const ta = textareaRef.current;
if (!ta) {
// No textarea — append to content
onContentChange(fileContent + '\n' + html + '\n');
return;
}
const { selectionStart, value } = ta;
const before = value.substring(0, selectionStart);
const after = value.substring(selectionStart);
onContentChange(before + '\n' + html + '\n' + after);
}, [onContentChange, fileContent]);
const handleTreeSelect = useCallback(async (keys: React.Key[]) => {
if (keys.length === 0) return;
const path = keys[0] as string;
if (isDirectoryPath(path)) return;
if (dirty) {
Modal.confirm({
title: 'Unsaved Changes',
content: 'Save changes before switching files?',
okText: 'Save',
cancelText: 'Discard',
onOk: async () => {
await saveFile();
await loadFile(path);
setActiveTab('editor');
},
onCancel: async () => {
onContentChange(editor.fileContent);
await loadFile(path);
setActiveTab('editor');
},
});
return;
}
await loadFile(path);
setActiveTab('editor');
}, [dirty, saveFile, loadFile, isDirectoryPath, onContentChange, editor.fileContent]);
// Select file from search results
const handleSearchSelect = useCallback(async (path: string) => {
setSearchOpen(false);
setFilterQuery('');
await loadFile(path);
setActiveTab('editor');
}, [loadFile, setFilterQuery]);
const handleTabChange = useCallback((val: string | number) => {
const newTab = val as MobileTab;
if (activeTab === 'editor' && newTab === 'files' && dirty) {
Modal.confirm({
title: 'Unsaved Changes',
content: 'You have unsaved changes. Save before switching?',
okText: 'Save',
cancelText: 'Discard',
onOk: async () => { await saveFile(); setActiveTab(newTab); },
onCancel: () => setActiveTab(newTab),
});
return;
}
setActiveTab(newTab);
}, [activeTab, dirty, saveFile]);
const handleEditorChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
onContentChange(e.target.value);
}, [onContentChange]);
const handleFileInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
handleUploadFiles(e.target.files);
e.target.value = '';
}
}, [handleUploadFiles]);
const toggleExpand = useCallback((key: string) => {
setExpandedKeys(prev =>
prev.includes(key)
? prev.filter(k => k !== key)
: [...prev, key]
);
}, [setExpandedKeys]);
const getContextMenuItems = useCallback((nodePath: string, isDir: boolean): MenuProps['items'] => {
const items: MenuProps['items'] = [];
if (isDir) {
items.push(
{ key: 'newFile', icon: <FileAddOutlined />, label: 'New File', onClick: () => { setContextPath(nodePath); setModalInput(''); setModalType('newFile'); } },
{ key: 'newFolder', icon: <FolderAddOutlined />, label: 'New Folder', onClick: () => { setContextPath(nodePath); setModalInput(''); setModalType('newFolder'); } },
{ type: 'divider' },
);
}
items.push(
{ key: 'rename', icon: <EditOutlined />, label: 'Rename', onClick: () => { setContextPath(nodePath); setModalInput(nodePath.split('/').pop() || ''); setModalType('rename'); } },
{ key: 'delete', icon: <DeleteOutlined />, label: 'Delete', danger: true, onClick: () => handleDelete(nodePath) },
);
return items;
}, [setContextPath, setModalInput, setModalType, handleDelete]);
const addMenuItems: MenuProps['items'] = useMemo(() => [
{ key: 'newFile', icon: <FileAddOutlined />, label: 'New File', onClick: handleNewFileRoot },
{ key: 'newFolder', icon: <FolderAddOutlined />, label: 'New Folder', onClick: handleNewFolderRoot },
{ key: 'upload', icon: <UploadOutlined />, label: 'Upload', onClick: () => fileInputRef.current?.click() },
{ type: 'divider' as const },
{ key: 'refresh', icon: <ReloadOutlined />, label: 'Refresh', onClick: refreshTree },
], [handleNewFileRoot, handleNewFolderRoot, fileInputRef, refreshTree]);
// --- Insert handlers (mirror desktop logic but use insertHtml instead of Monaco) ---
const handleInsertRequest = useCallback((type: InsertRequestType) => {
switch (type) {
case 'video-card': setVideoPickerOpen(true); break;
case 'photo-insert': setPhotoInsertOpen(true); break;
case 'donate-button': setDonateInsertOpen(true); break;
case 'product-card': setProductInsertOpen(true); break;
case 'ad-insert': setAdPickerOpen(true); break;
case 'scheduling-poll': setPollInsertOpen(true); break;
case 'pricing-table': {
const appUrl = config
? `${window.location.protocol}//${config.domain.replace(/^([^.]+)/, 'app')}`
: window.location.origin;
insertHtml(`<div style="text-align: center; padding: 40px 20px; background: linear-gradient(135deg, #1a1a2e, #16213e); border-radius: 12px; margin: 16px 0;">\n <h2 style="color: #fff; margin: 12px 0;">Choose Your Plan</h2>\n <p style="color: rgba(255,255,255,0.8); margin-bottom: 24px;">Get access to exclusive content and features.</p>\n <a href="${appUrl}/pricing" style="display: inline-block; padding: 14px 36px; background: #722ed1; color: #fff; text-decoration: none; border-radius: 8px; font-weight: 600; font-size: 1.1rem;">View Plans</a>\n</div>`);
break;
}
}
}, [config, insertHtml]);
const handleVideoCardInsert = useCallback((video: PickerVideo) => {
const adminUrl = config
? `${window.location.protocol}//${config.domain.replace(/^([^.]+)/, 'app')}`
: window.location.origin;
const placeholderThumb = 'data:image/svg+xml,' + encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="480" height="270" viewBox="0 0 480 270"><rect fill="#0d1b2a" width="480" height="270"/><circle cx="240" cy="135" r="32" fill="rgba(157,78,221,0.6)"/><polygon points="230,118 258,135 230,152" fill="#fff"/></svg>'
);
const html = generateVideoCardHtml({
id: video.id, title: video.title, durationSeconds: video.durationSeconds || 0,
quality: '', viewCount: 0, thumbnailUrl: placeholderThumb,
}, { baseUrl: adminUrl });
insertHtml(html);
setVideoPickerOpen(false);
}, [config, insertHtml]);
const handlePhotoInsert = useCallback((result: PhotoInsertResult) => {
if (result.variant === 'single-photo' || result.variant === 'photo-card') {
setPendingPhotoVariant(result);
setPhotoPickerOpen(true);
return;
}
const album = result.album;
if (!album) return;
let html = '';
if (result.variant === 'album-grid') {
const cols = result.options.columns || 3;
const max = result.options.maxPhotos || 12;
const title = result.options.showTitle !== false ? 'true' : 'false';
html = `<div class="photo-album-block" data-album-id="${album.id}" data-columns="${cols}" data-max-photos="${max}" data-show-title="${title}">Loading album...</div>`;
} else if (result.variant === 'album-carousel') {
const max = result.options.maxPhotos || 20;
const title = result.options.showTitle !== false ? 'true' : 'false';
const auto = result.options.autoPlay ? 'true' : 'false';
html = `<div class="photo-album-carousel" data-album-id="${album.id}" data-max-photos="${max}" data-show-title="${title}" data-auto-play="${auto}">Loading carousel...</div>`;
}
if (html) insertHtml(html);
}, [insertHtml]);
const handlePhotoSelected = useCallback((photo: PickerPhoto) => {
const variant = pendingPhotoVariant?.variant || 'photo-card';
if (variant === 'single-photo') {
const opts = pendingPhotoVariant?.options || {};
const html = `<div class="photo-block" data-photo-id="${photo.id}" data-size="${opts.size || 'large'}" data-caption="" data-link-to-gallery="${opts.linkToGallery !== false ? 'true' : 'false'}" data-alignment="${opts.alignment || 'center'}">Loading...</div>`;
insertHtml(html);
} else {
const adminUrl = config
? `${window.location.protocol}//${config.domain.replace(/^([^.]+)/, 'app')}`
: window.location.origin;
const placeholderThumb = 'data:image/svg+xml,' + encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="480" height="320" viewBox="0 0 480 320"><rect fill="#0d1b2a" width="480" height="320"/><circle cx="240" cy="160" r="32" fill="rgba(46,125,50,0.6)"/></svg>'
);
const html = generatePhotoCardHtml({
id: photo.id, title: photo.title || photo.originalFilename || 'Untitled Photo',
description: photo.description || undefined, showMetadata: true,
format: photo.format || undefined, width: photo.width || undefined,
height: photo.height || undefined, viewCount: photo.viewCount || 0,
thumbnailUrl: placeholderThumb,
}, { baseUrl: adminUrl });
insertHtml(html);
}
setPhotoPickerOpen(false);
setPendingPhotoVariant(null);
}, [config, pendingPhotoVariant, insertHtml]);
const handleDonateInsert = useCallback((result: DonateInsertResult) => {
if (result.variant === 'simple') {
insertHtml('<div style="text-align: center; padding: 40px 20px; background: linear-gradient(135deg, #2d1b69, #1a1a2e); border-radius: 12px; margin: 16px 0;">\n <p style="font-size: 48px; margin: 0;">&#x2764;&#xFE0F;</p>\n <h2 style="color: #fff; margin: 12px 0;">Support Our Cause</h2>\n <p style="color: rgba(255,255,255,0.8); margin-bottom: 24px;">Your contribution helps us create lasting change.</p>\n <a href="/donate" style="display: inline-block; padding: 14px 36px; background: #eb2f96; color: #fff; text-decoration: none; border-radius: 8px; font-weight: 600;">Donate Now</a>\n</div>');
} else if (result.variant === 'set-amount') {
const cents = result.amount || 2500;
const dollars = (cents / 100).toFixed(0);
insertHtml(`<div data-amounts="${cents}" data-preselected="${cents}" style="text-align:center;padding:40px 20px;background:linear-gradient(135deg,#2d1b69,#1a1a2e);border-radius:12px;margin:16px 0;">\n <h2 style="color:#fff;">Donate $${dollars}</h2>\n <a href="/donate?amount=${cents}" style="display:inline-block;padding:14px 36px;background:#eb2f96;color:#fff;text-decoration:none;border-radius:8px;font-weight:600;">Donate $${dollars}</a>\n</div>`);
} else {
const cfg = result.config;
const title = cfg?.donationPageTitle || 'Support Our Cause';
insertHtml(`<div style="text-align:center;padding:40px 20px;background:linear-gradient(135deg,#2d1b69,#1a1a2e);border-radius:12px;margin:16px 0;">\n <h2 style="color:#fff;">${title}</h2>\n <a href="/donate" style="display:inline-block;padding:14px 36px;background:#eb2f96;color:#fff;text-decoration:none;border-radius:8px;font-weight:600;">Donate Now</a>\n</div>`);
}
}, [insertHtml]);
const handleProductInsert = useCallback((result: ProductInsertResult) => {
const p = result.product;
const priceStr = `$${(p.priceCAD / 100).toFixed(2)}`;
insertHtml(`<div data-product-id="${p.id}" style="text-align:center;padding:32px 20px;background:linear-gradient(135deg,#1a1a2e,#16213e);border-radius:12px;margin:16px 0;max-width:420px;margin-left:auto;margin-right:auto;">\n <h3 style="color:#fff;">${p.title}</h3>\n <p style="color:#fff;font-size:1.4rem;font-weight:700;">${priceStr}</p>\n <a href="/shop" style="display:inline-block;padding:14px 36px;background:#722ed1;color:#fff;text-decoration:none;border-radius:8px;font-weight:600;">Buy Now</a>\n</div>`);
}, [insertHtml]);
const handleAdInsert = useCallback((result: AdInsertResult) => {
const html = result.type === 'specific'
? `<div class="ad-specific-block" data-ad-id="${result.adId}" style="max-width:400px; margin:16px auto;">Loading ad...</div>`
: `<div class="ad-slot-block" data-placement="docs" data-variant="${result.variant || 'standard'}" style="max-width:400px; margin:16px auto;">Loading ad...</div>`;
insertHtml(html);
setAdPickerOpen(false);
}, [insertHtml]);
const handlePollInsert = useCallback((slug: string) => {
insertHtml(`<div class="scheduling-poll-block" data-poll-slug="${slug}" data-show-comments="true" data-title="Vote on a Meeting Time">Loading poll...</div>`);
setPollInsertOpen(false);
}, [insertHtml]);
if (loading) {
return <div style={{ textAlign: 'center', padding: 80 }}><Spin size="large" /></div>;
}
if (fetchError) {
return (
<Result
status="error"
title="Cannot Load Editor"
subTitle="Failed to connect to the documentation services."
extra={<Button type="primary" onClick={fetchData}>Retry</Button>}
/>
);
}
const isMarkdownFile = selectedFile?.endsWith('.md');
const isImage = selectedFile ? isImageFile(selectedFile) : false;
const showSearchResults = searchOpen && filterQuery.trim().length >= 2;
return (
<>
{contextHolder}
<style>{`
.mobile-docs-tree .ant-tree-treenode {
padding: 0 !important;
margin: 0 !important;
min-height: 40px !important;
line-height: 40px !important;
border-radius: 0 !important;
width: 100% !important;
}
.mobile-docs-tree .ant-tree-treenode:active {
background: rgba(255,255,255,0.08) !important;
}
.mobile-docs-tree .ant-tree-node-content-wrapper {
padding: 0 4px !important;
min-height: 40px !important;
line-height: 40px !important;
border-radius: 0 !important;
background: transparent !important;
}
.mobile-docs-tree .ant-tree-node-content-wrapper:hover {
background: transparent !important;
}
.mobile-docs-tree .ant-tree-node-content-wrapper.ant-tree-node-selected {
background: rgba(255,255,255,0.10) !important;
}
.mobile-docs-tree .ant-tree-switcher {
width: 24px !important;
height: 40px !important;
line-height: 40px !important;
}
.mobile-docs-tree .ant-tree-indent-unit {
width: 14px !important;
}
.mobile-docs-tree .ant-tree-list-holder-inner {
padding: 0 !important;
}
`}</style>
<div style={{ display: 'flex', flexDirection: 'column', height: 'calc(100dvh - 64px)' }}>
{/* Header */}
<div style={{
height: 40,
display: 'flex',
alignItems: 'center',
padding: '0 8px',
gap: 6,
borderBottom: `1px solid ${token.colorBorderSecondary}`,
background: token.colorBgContainer,
flexShrink: 0,
}}>
{searchOpen ? (
<>
<Input
prefix={<SearchOutlined style={{ color: token.colorTextQuaternary, fontSize: 12 }} />}
placeholder="Search files..."
allowClear
size="small"
value={filterQuery}
onChange={(e) => setFilterQuery(e.target.value)}
style={{ flex: 1 }}
autoFocus
/>
<Button
type="text"
size="small"
icon={<CloseOutlined style={{ fontSize: 12 }} />}
onClick={() => { setSearchOpen(false); setFilterQuery(''); }}
style={{ width: 28, height: 28, flexShrink: 0 }}
/>
</>
) : (
<>
<Segmented
size="small"
value={activeTab}
onChange={handleTabChange}
options={[
{ value: 'files', icon: <FolderOutlined /> },
{ value: 'editor', icon: <CodeOutlined /> },
{ value: 'preview', icon: <EyeOutlined /> },
]}
/>
<div style={{ flex: 1 }} />
<Button
type="text"
size="small"
icon={<SearchOutlined style={{ fontSize: 14 }} />}
onClick={() => setSearchOpen(true)}
style={{ width: 28, height: 28 }}
/>
{activeTab === 'files' && (
<Dropdown menu={{ items: addMenuItems }} trigger={['click']} placement="bottomRight">
<Button type="text" size="small" icon={<PlusOutlined style={{ fontSize: 14 }} />} style={{ width: 28, height: 28 }} />
</Dropdown>
)}
{isSuperAdmin && (
<Button type="text" size="small" icon={<BuildOutlined style={{ fontSize: 14 }} />} onClick={confirmAndBuild} loading={building} style={{ width: 28, height: 28 }} />
)}
</>
)}
</div>
<input
ref={fileInputRef}
type="file"
multiple
accept=".png,.jpg,.jpeg,.gif,.svg,.webp,.ico,.pdf,.zip,.md"
style={{ display: 'none' }}
onChange={handleFileInputChange}
/>
{/* Tab content */}
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
{/* Search results overlay */}
{showSearchResults ? (
<div style={{ flex: 1, overflow: 'auto' }}>
{searchResults.length === 0 ? (
<div style={{ padding: 24, textAlign: 'center', color: token.colorTextTertiary }}>
No files matching "{filterQuery}"
</div>
) : (
<List
size="small"
dataSource={searchResults}
renderItem={(item) => (
<List.Item
style={{ padding: '8px 12px', cursor: 'pointer' }}
onClick={() => handleSearchSelect(item.path)}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
<FileOutlined style={{ fontSize: 12, color: token.colorTextSecondary, flexShrink: 0 }} />
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 13, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{item.name.replace(/\.md$/, '')}
</div>
<div style={{ fontSize: 11, color: token.colorTextTertiary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{item.path}
</div>
</div>
</div>
</List.Item>
)}
/>
)}
</div>
) : (
<>
{/* FILES TAB */}
{activeTab === 'files' && (
<div style={{ flex: 1, overflow: 'auto' }} className="mobile-docs-tree">
<Tree
treeData={treeData}
showIcon={false}
showLine={false}
motion={false}
selectedKeys={selectedFile ? [selectedFile] : []}
expandedKeys={expandedKeys}
onExpand={(keys) => setExpandedKeys(keys)}
onSelect={(keys) => {
if (keys.length === 0) return;
const path = keys[0] as string;
if (isDirectoryPath(path)) return;
handleTreeSelect(keys);
}}
blockNode
titleRender={(nodeData) => {
const nodePath = nodeData.key as string;
const isDir = isDirectoryPath(nodePath);
return (
<div style={{ display: 'flex', alignItems: 'center', minHeight: 40 }}>
<span
onClick={(e) => {
if (isDir) { e.stopPropagation(); toggleExpand(nodePath); }
}}
style={{
flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
fontSize: 14, lineHeight: '40px',
color: isDir ? token.colorTextSecondary : token.colorText,
cursor: 'pointer',
}}
>
{nodeData.title as string}
</span>
<Dropdown menu={{ items: getContextMenuItems(nodePath, isDir) }} trigger={['click']} placement="bottomRight">
<Button type="text" size="small" icon={<EllipsisOutlined />} onClick={(e) => e.stopPropagation()} style={{ flexShrink: 0, width: 28, height: 40, opacity: 0.5 }} />
</Dropdown>
</div>
);
}}
/>
</div>
)}
{/* EDITOR TAB */}
{activeTab === 'editor' && (
<div style={{
display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0,
paddingBottom: isMarkdownFile ? 'calc(56px + env(safe-area-inset-bottom, 0px))' : 0,
}}>
{selectedFile && (
<div style={{
padding: '4px 10px',
borderBottom: `1px solid ${token.colorBorderSecondary}`,
display: 'flex', alignItems: 'center', gap: 6,
height: 28, flexShrink: 0,
}}>
<Typography.Text style={{ fontFamily: 'monospace', fontSize: 11, flex: 1, color: token.colorTextSecondary }} ellipsis>
{selectedFile}
</Typography.Text>
{dirty && (
<span style={{ width: 6, height: 6, borderRadius: '50%', background: token.colorWarning, flexShrink: 0 }} />
)}
</div>
)}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0, overflow: 'hidden' }}>
{fileLoading ? (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}><Spin /></div>
) : !selectedFile ? (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', color: token.colorTextTertiary }}>
<div style={{ textAlign: 'center', padding: 24 }}>
<FileMarkdownOutlined style={{ fontSize: 48, marginBottom: 16 }} />
<div>Select a file from the Files tab</div>
</div>
</div>
) : isImage ? (
<div style={{ padding: 16, textAlign: 'center', overflow: 'auto' }}>
<img src={`/mkdocs-proxy/${selectedFile}`} alt={selectedFile} style={{ maxWidth: '100%', maxHeight: 400, objectFit: 'contain', borderRadius: 4 }} />
</div>
) : (
<LineNumberedEditor
textareaRef={textareaRef}
value={fileContent}
onChange={handleEditorChange}
token={token}
/>
)}
</div>
{isMarkdownFile && selectedFile && !isImage && !fileLoading && (
<MobileFormattingToolbar
textareaRef={textareaRef}
dirty={dirty}
saving={saving}
onContentChange={onContentChange}
onSave={saveFile}
onInsertRequest={handleInsertRequest}
/>
)}
</div>
)}
{/* PREVIEW TAB */}
{activeTab === 'preview' && (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<div style={{
padding: '4px 10px', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
borderBottom: `1px solid ${token.colorBorderSecondary}`, height: 28, flexShrink: 0,
}}>
<Typography.Text type="secondary" style={{ fontSize: 11 }}>{selectedFile || 'Home'}</Typography.Text>
<Button type="text" size="small" icon={<ReloadOutlined style={{ fontSize: 12 }} />} onClick={() => previewIframeRef.current?.contentWindow?.location.reload()} style={{ width: 24, height: 24 }} />
</div>
<iframe ref={previewIframeRef} src="/mkdocs-proxy/" style={{ flex: 1, width: '100%', border: 'none' }} title="MkDocs Preview" />
</div>
)}
</>
)}
</div>
</div>
{/* File CRUD Modal */}
<Modal
title={modalType === 'newFile' ? 'New File' : modalType === 'newFolder' ? 'New Folder' : 'Rename'}
open={modalType !== null}
onOk={handleModalOk}
onCancel={() => setModalType(null)}
okText={modalType === 'rename' ? 'Rename' : 'Create'}
destroyOnHidden
>
<Input
placeholder={modalType === 'newFolder' ? 'Folder name' : 'File name (e.g. my-page.md)'}
value={modalInput}
onChange={(e) => setModalInput(e.target.value)}
onPressEnter={handleModalOk}
autoFocus
/>
{contextPath && (
<Typography.Text type="secondary" style={{ display: 'block', marginTop: 8, fontSize: 12 }}>
{modalType === 'rename' ? `Renaming: ${contextPath}` : `Inside: ${contextPath}/`}
</Typography.Text>
)}
</Modal>
{/* Insert modals — same as desktop */}
<VideoPickerModal open={videoPickerOpen} onClose={() => setVideoPickerOpen(false)} onSelect={handleVideoCardInsert} title="Insert Video Card" />
<PhotoInsertModal open={photoInsertOpen} onClose={() => setPhotoInsertOpen(false)} onInsert={handlePhotoInsert} />
<PhotoPickerModal open={photoPickerOpen} onClose={() => { setPhotoPickerOpen(false); setPendingPhotoVariant(null); }} onSelect={handlePhotoSelected} title={pendingPhotoVariant?.variant === 'single-photo' ? 'Select Photo' : 'Select Photo for Card'} />
<DonateInsertModal open={donateInsertOpen} onClose={() => setDonateInsertOpen(false)} onInsert={handleDonateInsert} />
<ProductInsertModal open={productInsertOpen} onClose={() => setProductInsertOpen(false)} onInsert={handleProductInsert} />
<AdPickerModal open={adPickerOpen} onCancel={() => setAdPickerOpen(false)} onInsert={handleAdInsert} />
<PollInsertModal open={pollInsertOpen} onCancel={() => setPollInsertOpen(false)} onInsert={handlePollInsert} />
</>
);
}

View File

@ -1,184 +0,0 @@
import { useState, useCallback } from 'react';
import { Button, Drawer, List, theme } from 'antd';
import {
BoldOutlined,
ItalicOutlined,
CodeOutlined,
LinkOutlined,
FontSizeOutlined,
EllipsisOutlined,
SaveOutlined,
} from '@ant-design/icons';
import {
insertAtCursor,
insertBlock,
cycleHeading,
applyResult,
type TextareaInsertResult,
} from '@/utils/textareaSnippets';
export type InsertRequestType = 'video-card' | 'photo-insert' | 'donate-button' | 'pricing-table' | 'product-card' | 'ad-insert' | 'scheduling-poll';
interface MobileFormattingToolbarProps {
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
dirty: boolean;
saving: boolean;
onContentChange: (value: string) => void;
onSave: () => void;
onInsertRequest?: (type: InsertRequestType) => void;
}
interface SnippetDef {
id: string;
label: string;
group: string;
run?: (ta: HTMLTextAreaElement) => TextareaInsertResult;
insertType?: InsertRequestType;
}
const MORE_SNIPPETS: SnippetDef[] = [
// Formatting
{ id: 'strikethrough', label: 'Strikethrough', group: 'Formatting', run: (ta) => insertAtCursor(ta, '~~', '~~') },
{ id: 'highlight', label: 'Highlight', group: 'Formatting', run: (ta) => insertAtCursor(ta, '==', '==') },
{ id: 'kbd', label: 'Keyboard Key', group: 'Formatting', run: (ta) => insertAtCursor(ta, '++', '++') },
// Headings
{ id: 'h1', label: 'Heading 1', group: 'Headings', run: (ta) => insertBlock(ta, '# $CURSOR') },
{ id: 'h2', label: 'Heading 2', group: 'Headings', run: (ta) => insertBlock(ta, '## $CURSOR') },
{ id: 'h3', label: 'Heading 3', group: 'Headings', run: (ta) => insertBlock(ta, '### $CURSOR') },
{ id: 'h4', label: 'Heading 4', group: 'Headings', run: (ta) => insertBlock(ta, '#### $CURSOR') },
// Admonitions
{ id: 'note', label: 'Note', group: 'Admonitions', run: (ta) => insertBlock(ta, '!!! note "Title"\n Content here') },
{ id: 'warning', label: 'Warning', group: 'Admonitions', run: (ta) => insertBlock(ta, '!!! warning "Title"\n Content here') },
{ id: 'tip', label: 'Tip', group: 'Admonitions', run: (ta) => insertBlock(ta, '!!! tip "Title"\n Content here') },
{ id: 'info', label: 'Info', group: 'Admonitions', run: (ta) => insertBlock(ta, '!!! info "Title"\n Content here') },
{ id: 'danger', label: 'Danger', group: 'Admonitions', run: (ta) => insertBlock(ta, '!!! danger "Title"\n Content here') },
{ id: 'success', label: 'Success', group: 'Admonitions', run: (ta) => insertBlock(ta, '!!! success "Title"\n Content here') },
{ id: 'collapsible', label: 'Collapsible', group: 'Admonitions', run: (ta) => insertBlock(ta, '???+ note "Title"\n Content here') },
// Code
{ id: 'code-block', label: 'Code Block', group: 'Code', run: (ta) => insertBlock(ta, '```python\n$CURSOR\n```') },
{ id: 'code-annotated', label: 'Annotated Code', group: 'Code', run: (ta) => insertBlock(ta, '```python\ncode # (1)!\n```\n\n1. Annotation') },
{ id: 'mermaid', label: 'Mermaid Diagram', group: 'Code', run: (ta) => insertBlock(ta, '```mermaid\ngraph LR\n A --> B\n```') },
// Insert — text snippets
{ id: 'image', label: 'Image', group: 'Insert', run: (ta) => insertBlock(ta, '![Alt text](image.png)') },
{ id: 'table', label: 'Table', group: 'Insert', run: (ta) => insertBlock(ta, '| Column 1 | Column 2 | Column 3 |\n| -------- | -------- | -------- |\n| Cell 1 | Cell 2 | Cell 3 |') },
{ id: 'tasklist', label: 'Task List', group: 'Insert', run: (ta) => insertBlock(ta, '- [ ] Task 1\n- [ ] Task 2\n- [x] Done') },
{ id: 'tabs', label: 'Tabs', group: 'Insert', run: (ta) => insertBlock(ta, '=== "Tab 1"\n\n Content\n\n=== "Tab 2"\n\n Content') },
{ id: 'button', label: 'Button', group: 'Insert', run: (ta) => insertBlock(ta, '[Text](url){ .md-button }') },
{ id: 'button-primary', label: 'Primary Button', group: 'Insert', run: (ta) => insertBlock(ta, '[Text](url){ .md-button .md-button--primary }') },
{ id: 'icon', label: 'Material Icon', group: 'Insert', run: (ta) => insertBlock(ta, ':material-icon-name:') },
{ id: 'math-block', label: 'Math Block', group: 'Insert', run: (ta) => insertBlock(ta, '$$\n$CURSOR\n$$') },
{ id: 'footnote', label: 'Footnote', group: 'Insert', run: (ta) => insertBlock(ta, '[^1]\n\n[^1]: Text') },
{ id: 'def-list', label: 'Definition List', group: 'Insert', run: (ta) => insertBlock(ta, 'Term\n: Definition') },
{ id: 'hr', label: 'Horizontal Rule', group: 'Insert', run: (ta) => insertBlock(ta, '---') },
// Insert — modal-based (open picker)
{ id: 'video-card', label: 'Video Card', group: 'Media & Widgets', insertType: 'video-card' },
{ id: 'photo-insert', label: 'Photo', group: 'Media & Widgets', insertType: 'photo-insert' },
{ id: 'donate-button', label: 'Donate Button', group: 'Media & Widgets', insertType: 'donate-button' },
{ id: 'pricing-table', label: 'Pricing Table', group: 'Media & Widgets', insertType: 'pricing-table' },
{ id: 'product-card', label: 'Product Card', group: 'Media & Widgets', insertType: 'product-card' },
{ id: 'ad-insert', label: 'Ad', group: 'Media & Widgets', insertType: 'ad-insert' },
{ id: 'scheduling-poll', label: 'Scheduling Poll', group: 'Media & Widgets', insertType: 'scheduling-poll' },
];
const GROUPS = [...new Set(MORE_SNIPPETS.map(s => s.group))];
export function MobileFormattingToolbar({
textareaRef,
dirty,
saving,
onContentChange,
onSave,
onInsertRequest,
}: MobileFormattingToolbarProps) {
const { token } = theme.useToken();
const [drawerOpen, setDrawerOpen] = useState(false);
const run = useCallback((fn: (ta: HTMLTextAreaElement) => TextareaInsertResult) => {
const ta = textareaRef.current;
if (!ta) return;
applyResult(ta, fn(ta), onContentChange);
}, [textareaRef, onContentChange]);
const btnStyle: React.CSSProperties = { minWidth: 44, height: 44 };
return (
<>
<div
style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
zIndex: 100,
display: 'flex',
alignItems: 'center',
gap: 2,
padding: '6px 8px',
paddingBottom: `max(6px, env(safe-area-inset-bottom))`,
background: token.colorBgElevated,
borderTop: `1px solid ${token.colorBorderSecondary}`,
boxShadow: '0 -2px 8px rgba(0,0,0,0.15)',
}}
>
<Button type="text" size="small" icon={<BoldOutlined />} onClick={() => run((ta) => insertAtCursor(ta, '**', '**'))} style={btnStyle} />
<Button type="text" size="small" icon={<ItalicOutlined />} onClick={() => run((ta) => insertAtCursor(ta, '*', '*'))} style={btnStyle} />
<Button type="text" size="small" icon={<FontSizeOutlined />} onClick={() => run(cycleHeading)} style={btnStyle} />
<Button type="text" size="small" icon={<LinkOutlined />} onClick={() => run((ta) => insertAtCursor(ta, '[', '](url)'))} style={btnStyle} />
<Button type="text" size="small" icon={<CodeOutlined />} onClick={() => run((ta) => insertAtCursor(ta, '`', '`'))} style={btnStyle} />
<Button type="text" size="small" icon={<EllipsisOutlined />} onClick={() => setDrawerOpen(true)} style={btnStyle} />
<div style={{ flex: 1 }} />
<Button
type={dirty ? 'primary' : 'default'}
size="small"
icon={<SaveOutlined />}
onClick={onSave}
loading={saving}
disabled={!dirty}
style={{ height: 44 }}
>
Save
</Button>
</div>
<Drawer
title="Insert Snippet"
placement="bottom"
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
height="60%"
styles={{ body: { padding: 0 } }}
>
{GROUPS.map(group => (
<div key={group}>
<div style={{ padding: '10px 16px 2px', fontSize: 11, fontWeight: 600, color: token.colorTextSecondary, textTransform: 'uppercase', letterSpacing: 0.5 }}>
{group}
</div>
<List
size="small"
dataSource={MORE_SNIPPETS.filter(s => s.group === group)}
renderItem={(item) => (
<List.Item
style={{ padding: '10px 16px', cursor: 'pointer' }}
onClick={() => {
if (item.insertType) {
onInsertRequest?.(item.insertType);
setDrawerOpen(false);
} else if (item.run) {
run(item.run);
setDrawerOpen(false);
setTimeout(() => textareaRef.current?.focus(), 300);
}
}}
>
{item.label}
</List.Item>
)}
/>
</div>
))}
</Drawer>
</>
);
}

View File

@ -1,63 +0,0 @@
import { useState, useEffect } from 'react';
import { Card, Typography, Tag, Space } from 'antd';
import { TrophyOutlined } from '@ant-design/icons';
import axios from 'axios';
const { Text } = Typography;
interface Story {
id: string;
title: string;
type: string;
milestoneValue: number | null;
publishedAt: string | null;
}
interface CampaignCelebrationProps {
campaignId: string;
}
export default function CampaignCelebration({ campaignId }: CampaignCelebrationProps) {
const [stories, setStories] = useState<Story[]>([]);
useEffect(() => {
if (!campaignId) return;
axios.get(`/api/social/stories/campaign/${campaignId}`, { params: { limit: 5 } })
.then(({ data }) => {
const published = (data.stories || []).filter(
(s: Story) => s.type === 'MILESTONE' || s.type === 'VICTORY',
);
setStories(published);
})
.catch(() => {});
}, [campaignId]);
if (stories.length === 0) return null;
return (
<Card
size="small"
style={{
background: 'linear-gradient(135deg, #fffbe6 0%, #fff1b8 100%)',
borderColor: '#d4a017',
marginBottom: 16,
}}
>
<Space direction="vertical" size={4} style={{ width: '100%' }}>
<Space>
<TrophyOutlined style={{ color: '#d4a017', fontSize: 18 }} />
<Text strong style={{ color: '#874d00' }}>Campaign Milestones</Text>
</Space>
<Space wrap>
{stories.map((s) => (
<Tag key={s.id} color="gold">
{s.milestoneValue
? `${s.milestoneValue.toLocaleString()} emails`
: s.title}
</Tag>
))}
</Space>
</Space>
</Card>
);
}

View File

@ -1,114 +0,0 @@
import { Card, Tag, Typography, Space, Badge } from 'antd';
import {
HomeOutlined,
MailOutlined,
ScheduleOutlined,
MessageOutlined,
UserAddOutlined,
ClockCircleOutlined,
TeamOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import { useNavigate } from 'react-router-dom';
export interface ChallengeMetricInfo {
icon: React.ReactNode;
label: string;
color: string;
}
export const METRIC_MAP: Record<string, ChallengeMetricInfo> = {
DOORS_KNOCKED: { icon: <HomeOutlined />, label: 'Doors Knocked', color: '#52c41a' },
EMAILS_SENT: { icon: <MailOutlined />, label: 'Emails Sent', color: '#1890ff' },
SHIFTS_ATTENDED: { icon: <ScheduleOutlined />, label: 'Shifts Attended', color: '#fa8c16' },
RESPONSES_SUBMITTED: { icon: <MessageOutlined />, label: 'Responses', color: '#722ed1' },
REFERRALS_MADE: { icon: <UserAddOutlined />, label: 'Referrals', color: '#eb2f96' },
};
export const STATUS_COLORS: Record<string, string> = {
DRAFT: 'default',
UPCOMING: 'blue',
ACTIVE: 'green',
COMPLETED: 'gold',
CANCELLED: 'red',
};
interface ChallengeCardProps {
challenge: {
id: string;
title: string;
metric: string;
status: string;
startsAt: string;
endsAt: string;
_count?: { teams: number };
teams?: unknown[];
};
myTeam?: { id: string; name: string } | null;
}
function CountdownTimer({ target, label }: { target: string; label: string }) {
const diff = dayjs(target).diff(dayjs(), 'second');
if (diff <= 0) return null;
const days = Math.floor(diff / 86400);
const hours = Math.floor((diff % 86400) / 3600);
return (
<span style={{ fontSize: 12, opacity: 0.8 }}>
<ClockCircleOutlined style={{ marginRight: 4 }} />
{label}: {days > 0 ? `${days}d ` : ''}{hours}h
</span>
);
}
export default function ChallengeCard({ challenge, myTeam }: ChallengeCardProps) {
const navigate = useNavigate();
const metric = (METRIC_MAP[challenge.metric] || METRIC_MAP.DOORS_KNOCKED)!;
const teamCount = challenge._count?.teams ?? challenge.teams?.length ?? 0;
const isActive = challenge.status === 'ACTIVE';
const isUpcoming = challenge.status === 'UPCOMING';
return (
<Card
hoverable
size="small"
onClick={() => navigate(`/volunteer/challenges/${challenge.id}`)}
style={{ cursor: 'pointer' }}
>
<Space direction="vertical" size={4} style={{ width: '100%' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<Typography.Text strong style={{ fontSize: 15 }}>
{challenge.title}
</Typography.Text>
<Tag color={STATUS_COLORS[challenge.status]}>{challenge.status}</Tag>
</div>
<Space size={8} wrap>
<Tag icon={metric.icon} color={metric.color}>
{metric.label}
</Tag>
<span style={{ fontSize: 12, opacity: 0.7 }}>
<TeamOutlined style={{ marginRight: 4 }} />
{teamCount} team{teamCount !== 1 ? 's' : ''}
</span>
</Space>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{dayjs(challenge.startsAt).format('MMM D')} - {dayjs(challenge.endsAt).format('MMM D, YYYY')}
</Typography.Text>
{isUpcoming && <CountdownTimer target={challenge.startsAt} label="Starts in" />}
{isActive && <CountdownTimer target={challenge.endsAt} label="Ends in" />}
{myTeam && (
<Badge
count={`My Team: ${myTeam.name}`}
style={{ backgroundColor: '#1890ff', fontSize: 11 }}
/>
)}
</Space>
</Card>
);
}

View File

@ -1,82 +0,0 @@
import { List, Typography, Tag, Space, Collapse } from 'antd';
import { TrophyOutlined, UserOutlined, CrownOutlined } from '@ant-design/icons';
interface TeamMember {
id: number;
score: number;
user: { id: string; name: string | null; email: string };
}
interface LeaderboardTeam {
id: string;
name: string;
score: number;
captainUserId: string;
members: TeamMember[];
}
interface ChallengeLeaderboardProps {
teams: LeaderboardTeam[];
myTeamId?: string | null;
}
const MEDAL_COLORS = ['#ffd700', '#c0c0c0', '#cd7f32'];
export default function ChallengeLeaderboard({ teams, myTeamId }: ChallengeLeaderboardProps) {
if (teams.length === 0) {
return <Typography.Text type="secondary">No teams yet</Typography.Text>;
}
const items = teams.map((team, idx) => ({
key: team.id,
label: (
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
width: '100%',
fontWeight: team.id === myTeamId ? 700 : 400,
color: team.id === myTeamId ? '#1890ff' : undefined,
}}
>
<Space>
{idx < 3 ? (
<TrophyOutlined style={{ color: MEDAL_COLORS[idx], fontSize: 16 }} />
) : (
<span style={{ display: 'inline-block', width: 16, textAlign: 'center', opacity: 0.5 }}>
{idx + 1}
</span>
)}
<span>{team.name}</span>
{team.id === myTeamId && <Tag color="blue" style={{ marginLeft: 4 }}>You</Tag>}
</Space>
<Space>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
<UserOutlined /> {team.members.length}
</Typography.Text>
<Typography.Text strong>{team.score}</Typography.Text>
</Space>
</div>
),
children: (
<List
size="small"
dataSource={team.members}
renderItem={(m: TeamMember) => (
<List.Item style={{ padding: '4px 0' }}>
<Space>
{m.user.id === team.captainUserId && (
<CrownOutlined style={{ color: '#faad14' }} />
)}
<span>{m.user.name || m.user.email}</span>
</Space>
<Typography.Text strong>{m.score}</Typography.Text>
</List.Item>
)}
/>
),
}));
return <Collapse items={items} ghost size="small" />;
}

View File

@ -1,6 +1,6 @@
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Card, Typography, theme } from 'antd'; import { Card, Typography, theme } from 'antd';
import { ScheduleOutlined, MailOutlined, EnvironmentOutlined, MessageOutlined, TrophyOutlined, StarOutlined, UserAddOutlined, FlagOutlined } from '@ant-design/icons'; import { ScheduleOutlined, MailOutlined, EnvironmentOutlined, MessageOutlined } from '@ant-design/icons';
import UserAvatar from './UserAvatar'; import UserAvatar from './UserAvatar';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime'; import relativeTime from 'dayjs/plugin/relativeTime';
@ -27,24 +27,6 @@ const TYPE_CONFIG: Record<string, { icon: React.ReactNode; color: string; getLin
color: '#722ed1', color: '#722ed1',
getLink: (meta) => `/campaigns/${meta.campaignId as string}`, getLink: (meta) => `/campaigns/${meta.campaignId as string}`,
}, },
impact_story: {
icon: <TrophyOutlined />,
color: '#faad14',
getLink: (meta) => `/campaign/${meta.campaignSlug as string}`,
},
volunteer_featured: {
icon: <StarOutlined />,
color: '#eb2f96',
getLink: () => `/wall-of-fame`,
},
referral_completed: {
icon: <UserAddOutlined />,
color: '#13c2c2',
},
challenge_completed: {
icon: <FlagOutlined />,
color: '#52c41a',
},
}; };
interface FeedItem { interface FeedItem {

View File

@ -1,74 +0,0 @@
import { Card, Tag, Typography } from 'antd';
import { TrophyOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
const { Text, Paragraph } = Typography;
interface ImpactStoryCardProps {
story: {
id: string;
title: string;
body: string;
type: 'MILESTONE' | 'VICTORY' | 'RESPONSE' | 'CUSTOM';
campaignTitle?: string;
milestoneValue?: number | null;
milestoneMetric?: string | null;
publishedAt?: string | null;
};
}
const typeLabels: Record<string, string> = {
MILESTONE: 'Milestone',
VICTORY: 'Victory',
RESPONSE: 'Response',
CUSTOM: 'Story',
};
export default function ImpactStoryCard({ story }: ImpactStoryCardProps) {
const isMilestone = story.type === 'MILESTONE';
return (
<Card
size="small"
style={{
borderColor: isMilestone ? '#d4a017' : undefined,
borderWidth: isMilestone ? 2 : 1,
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 8 }}>
<div>
{story.campaignTitle && (
<Tag color="blue" style={{ marginBottom: 4 }}>{story.campaignTitle}</Tag>
)}
<Tag color={isMilestone ? 'gold' : story.type === 'VICTORY' ? 'green' : 'default'}>
{isMilestone && <TrophyOutlined style={{ marginRight: 4 }} />}
{typeLabels[story.type]}
</Tag>
</div>
{story.milestoneValue && (
<Tag color="gold" style={{ fontWeight: 'bold', fontSize: 14 }}>
{story.milestoneValue.toLocaleString()}
</Tag>
)}
</div>
<Text strong style={{ fontSize: 15, display: 'block', marginBottom: 4 }}>
{story.title}
</Text>
<Paragraph
type="secondary"
ellipsis={{ rows: 3 }}
style={{ marginBottom: 4 }}
>
{story.body}
</Paragraph>
{story.publishedAt && (
<Text type="secondary" style={{ fontSize: 12 }}>
{dayjs(story.publishedAt).format('MMM D, YYYY')}
</Text>
)}
</Card>
);
}

View File

@ -1,131 +0,0 @@
import { Card, Button, Typography, Space, Tag, Progress, Tooltip, App } from 'antd';
import { CopyOutlined, ShareAltOutlined, StopOutlined } from '@ant-design/icons';
import { theme } from 'antd';
import dayjs from 'dayjs';
interface InviteCodeData {
id: string;
code: string;
maxUses: number;
usedCount: number;
expiresAt: string | null;
isActive: boolean;
note: string | null;
createdAt: string;
_count?: { referrals: number };
}
interface InviteCodeCardProps {
code: InviteCodeData;
onDeactivate: (id: string) => void;
deactivating?: boolean;
}
export default function InviteCodeCard({ code, onDeactivate, deactivating }: InviteCodeCardProps) {
const { token } = theme.useToken();
const { message } = App.useApp();
const isExpired = code.expiresAt ? dayjs(code.expiresAt).isBefore(dayjs()) : false;
const isMaxed = code.maxUses > 0 && code.usedCount >= code.maxUses;
const isUsable = code.isActive && !isExpired && !isMaxed;
const shareUrl = `${window.location.origin}/register?ref=${code.code}`;
const handleCopyCode = () => {
navigator.clipboard.writeText(code.code).then(() => {
message.success('Code copied');
});
};
const handleCopyLink = () => {
navigator.clipboard.writeText(shareUrl).then(() => {
message.success('Share link copied');
});
};
const usagePercent = code.maxUses > 0
? Math.round((code.usedCount / code.maxUses) * 100)
: 0;
let statusTag: React.ReactNode;
if (!code.isActive) {
statusTag = <Tag color="default">Deactivated</Tag>;
} else if (isExpired) {
statusTag = <Tag color="red">Expired</Tag>;
} else if (isMaxed) {
statusTag = <Tag color="orange">Max Uses Reached</Tag>;
} else {
statusTag = <Tag color="green">Active</Tag>;
}
return (
<Card
size="small"
style={{
borderColor: isUsable ? token.colorPrimary : token.colorBorderSecondary,
opacity: isUsable ? 1 : 0.7,
}}
>
<Space direction="vertical" style={{ width: '100%' }} size="small">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography.Text
strong
code
style={{ fontSize: 18, letterSpacing: 2 }}
>
{code.code}
</Typography.Text>
{statusTag}
</div>
{code.note && (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{code.note}
</Typography.Text>
)}
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
Used: {code.usedCount}{code.maxUses > 0 ? ` / ${code.maxUses}` : ' (unlimited)'}
</Typography.Text>
{code.expiresAt && (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{isExpired ? 'Expired' : `Expires ${dayjs(code.expiresAt).format('MMM D, YYYY')}`}
</Typography.Text>
)}
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
Created {dayjs(code.createdAt).format('MMM D, YYYY')}
</Typography.Text>
</div>
{code.maxUses > 0 && (
<Progress percent={usagePercent} size="small" status={isMaxed ? 'exception' : 'active'} />
)}
<Space size="small" wrap>
<Tooltip title="Copy code">
<Button size="small" icon={<CopyOutlined />} onClick={handleCopyCode}>
Copy Code
</Button>
</Tooltip>
<Tooltip title="Copy share link">
<Button size="small" icon={<ShareAltOutlined />} onClick={handleCopyLink}>
Share Link
</Button>
</Tooltip>
{code.isActive && (
<Button
size="small"
danger
icon={<StopOutlined />}
onClick={() => onDeactivate(code.id)}
loading={deactivating}
>
Deactivate
</Button>
)}
</Space>
</Space>
</Card>
);
}

View File

@ -1,118 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import { List, Typography, Switch, Space, Spin, Empty } from 'antd';
import { TrophyOutlined, CrownOutlined } from '@ant-design/icons';
import { api } from '@/lib/api';
import UserAvatar from '@/components/social/UserAvatar';
const { Text } = Typography;
interface LeaderboardEntry {
rank: number;
userId: string;
name: string | null;
email: string;
score: number;
}
interface PublicLeaderboardProps {
type: 'canvass' | 'shifts' | 'campaigns';
showOptIn?: boolean;
optedIn?: boolean;
onOptInChange?: (value: boolean) => void;
}
const SCORE_LABELS: Record<string, string> = {
canvass: 'doors',
shifts: 'shifts',
campaigns: 'campaigns',
};
function getMedalColor(rank: number): string | undefined {
if (rank === 1) return '#FFD700';
if (rank === 2) return '#C0C0C0';
if (rank === 3) return '#CD7F32';
return undefined;
}
export default function PublicLeaderboard({ type, showOptIn, optedIn, onOptInChange }: PublicLeaderboardProps) {
const [entries, setEntries] = useState<LeaderboardEntry[]>([]);
const [loading, setLoading] = useState(true);
const fetchLeaderboard = useCallback(async () => {
try {
setLoading(true);
const { data } = await api.get('/social/spotlight/leaderboard', {
params: { type, limit: 10 },
});
setEntries(data.leaderboard || []);
} catch {
// Silently fail for unauthenticated users
setEntries([]);
} finally {
setLoading(false);
}
}, [type]);
useEffect(() => {
fetchLeaderboard();
}, [fetchLeaderboard]);
if (loading) return <Spin style={{ display: 'block', margin: '24px auto' }} />;
if (entries.length === 0) {
return <Empty description="No leaderboard data yet" image={Empty.PRESENTED_IMAGE_SIMPLE} />;
}
return (
<div>
<List
dataSource={entries}
renderItem={(entry) => {
const medalColor = getMedalColor(entry.rank);
return (
<List.Item style={{ padding: '10px 0' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, width: '100%' }}>
<div style={{ width: 36, textAlign: 'center', flexShrink: 0 }}>
{medalColor ? (
<CrownOutlined style={{ fontSize: 22, color: medalColor }} />
) : (
<Text strong style={{ fontSize: 16 }}>#{entry.rank}</Text>
)}
</div>
<UserAvatar
userId={entry.userId}
name={entry.name}
email={entry.email}
size={36}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<Text strong ellipsis>
{entry.name || entry.email.split('@')[0]}
</Text>
</div>
<Text style={{ flexShrink: 0 }}>
<Text strong>{entry.score}</Text>{' '}
<Text type="secondary">{SCORE_LABELS[type]}</Text>
</Text>
</div>
</List.Item>
);
}}
/>
{showOptIn && onOptInChange && (
<div style={{ marginTop: 16, padding: '12px 0', borderTop: '1px solid rgba(255,255,255,0.1)' }}>
<Space>
<TrophyOutlined />
<Text>Show me on leaderboard</Text>
<Switch
checked={optedIn}
onChange={onOptInChange}
size="small"
/>
</Space>
</div>
)}
</div>
);
}

View File

@ -1,93 +0,0 @@
import { Card, Typography, Tag } from 'antd';
import { StarFilled } from '@ant-design/icons';
import UserAvatar from '@/components/social/UserAvatar';
const { Text, Paragraph, Title } = Typography;
interface SpotlightData {
id: string;
userId: string;
userName?: string | null;
headline?: string | null;
story?: string | null;
featuredMonth?: string | null;
stats?: { canvassVisits?: number; shiftSignups?: number; campaignEmails?: number } | null;
}
interface SpotlightCardProps {
spotlight: SpotlightData;
}
export default function SpotlightCard({ spotlight }: SpotlightCardProps) {
const monthLabel = spotlight.featuredMonth
? new Date(spotlight.featuredMonth + '-01').toLocaleDateString('en-US', {
month: 'long',
year: 'numeric',
})
: null;
return (
<Card
style={{
border: '2px solid #d4a017',
borderRadius: 12,
background: 'linear-gradient(135deg, rgba(212, 160, 23, 0.08), transparent)',
}}
>
<div style={{ display: 'flex', gap: 16, alignItems: 'flex-start' }}>
<UserAvatar
userId={spotlight.userId}
name={spotlight.userName}
size={64}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<Title level={5} style={{ margin: 0 }}>
{spotlight.userName || 'Anonymous Volunteer'}
</Title>
<Tag color="gold" icon={<StarFilled />}>Featured</Tag>
</div>
{monthLabel && (
<Text type="secondary" style={{ fontSize: 12 }}>{monthLabel}</Text>
)}
{spotlight.headline && (
<Title level={5} style={{ margin: '8px 0 4px', fontSize: 15 }}>
{spotlight.headline}
</Title>
)}
{spotlight.story && (
<Paragraph
ellipsis={{ rows: 3, expandable: true, symbol: 'Read more' }}
style={{ margin: 0, marginTop: 4 }}
>
{spotlight.story}
</Paragraph>
)}
{spotlight.stats && (
<div style={{ display: 'flex', gap: 16, marginTop: 8, flexWrap: 'wrap' }}>
{spotlight.stats.canvassVisits != null && spotlight.stats.canvassVisits > 0 && (
<Text type="secondary" style={{ fontSize: 12 }}>
{spotlight.stats.canvassVisits} doors knocked
</Text>
)}
{spotlight.stats.shiftSignups != null && spotlight.stats.shiftSignups > 0 && (
<Text type="secondary" style={{ fontSize: 12 }}>
{spotlight.stats.shiftSignups} shifts
</Text>
)}
{spotlight.stats.campaignEmails != null && spotlight.stats.campaignEmails > 0 && (
<Text type="secondary" style={{ fontSize: 12 }}>
{spotlight.stats.campaignEmails} emails sent
</Text>
)}
</div>
)}
</div>
</div>
</Card>
);
}

View File

@ -1,133 +0,0 @@
import { useState } from 'react';
import { Card, Input, Button, List, Space, Typography, Tag, App } from 'antd';
import { PlusOutlined, TeamOutlined, CrownOutlined, LoginOutlined } from '@ant-design/icons';
import { api } from '@/lib/api';
interface TeamInfo {
id: string;
name: string;
captainUserId: string;
captain: { id: string; name: string | null; email: string };
members: { id: number; userId: string }[];
}
interface TeamJoinCardProps {
challengeId: string;
teams: TeamInfo[];
maxTeamSize: number;
onTeamCreated: () => void;
onTeamJoined: () => void;
}
export default function TeamJoinCard({
challengeId,
teams,
maxTeamSize,
onTeamCreated,
onTeamJoined,
}: TeamJoinCardProps) {
const { message } = App.useApp();
const [teamName, setTeamName] = useState('');
const [creating, setCreating] = useState(false);
const [joiningId, setJoiningId] = useState<string | null>(null);
const handleCreate = async () => {
if (!teamName.trim()) return;
setCreating(true);
try {
await api.post(`/social/challenges/${challengeId}/teams`, { name: teamName.trim() });
message.success('Team created');
setTeamName('');
onTeamCreated();
} catch (err: any) {
message.error(err.response?.data?.error?.message || 'Failed to create team');
} finally {
setCreating(false);
}
};
const handleJoin = async (teamId: string) => {
setJoiningId(teamId);
try {
await api.post(`/social/challenges/${challengeId}/teams/${teamId}/join`);
message.success('Joined team');
onTeamJoined();
} catch (err: any) {
message.error(err.response?.data?.error?.message || 'Failed to join team');
} finally {
setJoiningId(null);
}
};
return (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Card size="small" title="Create a Team">
<Space.Compact style={{ width: '100%' }}>
<Input
placeholder="Team name"
value={teamName}
onChange={(e) => setTeamName(e.target.value)}
onPressEnter={handleCreate}
maxLength={100}
/>
<Button
type="primary"
icon={<PlusOutlined />}
loading={creating}
onClick={handleCreate}
disabled={!teamName.trim()}
>
Create
</Button>
</Space.Compact>
</Card>
{teams.length > 0 && (
<Card size="small" title="Join an Existing Team">
<List
size="small"
dataSource={teams}
renderItem={(team: TeamInfo) => {
const isFull = team.members.length >= maxTeamSize;
return (
<List.Item
actions={[
isFull ? (
<Tag color="red" key="full">Full</Tag>
) : (
<Button
key="join"
size="small"
type="primary"
icon={<LoginOutlined />}
loading={joiningId === team.id}
onClick={() => handleJoin(team.id)}
>
Join
</Button>
),
]}
>
<List.Item.Meta
avatar={<TeamOutlined style={{ fontSize: 18 }} />}
title={team.name}
description={
<Space size={8}>
<span>{team.members.length}/{maxTeamSize} members</span>
<span>
<CrownOutlined style={{ color: '#faad14', marginRight: 2 }} />
{team.captain.name || team.captain.email}
</span>
</Space>
}
/>
</List.Item>
);
}}
locale={{ emptyText: <Typography.Text type="secondary">No teams yet</Typography.Text> }}
/>
</Card>
)}
</Space>
);
}

View File

@ -1,526 +0,0 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import { message, Modal } from 'antd';
import { api } from '@/lib/api';
import type { FileNode, ServicesConfig } from '@/types/api';
// Tree cache constants
const TREE_CACHE_KEY = 'docs-tree-cache';
const TREE_CACHE_TIMESTAMP_KEY = 'docs-tree-cache-timestamp';
const TREE_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.ico']);
export function isImageFile(filePath: string): boolean {
const ext = filePath.substring(filePath.lastIndexOf('.')).toLowerCase();
return IMAGE_EXTENSIONS.has(ext);
}
export function filePathToMkDocsUrl(filePath: string): string {
let url = filePath.replace(/\.md$/, '');
if (url.endsWith('/index') || url === 'index') {
url = url.replace(/\/?index$/, '');
}
return '/mkdocs-proxy/' + url + (url ? '/' : '');
}
function getCachedTree(): FileNode[] | null {
try {
const cached = localStorage.getItem(TREE_CACHE_KEY);
const timestamp = localStorage.getItem(TREE_CACHE_TIMESTAMP_KEY);
if (!cached || !timestamp) return null;
const age = Date.now() - parseInt(timestamp, 10);
if (age > TREE_CACHE_TTL) {
localStorage.removeItem(TREE_CACHE_KEY);
localStorage.removeItem(TREE_CACHE_TIMESTAMP_KEY);
return null;
}
return JSON.parse(cached) as FileNode[];
} catch {
return null;
}
}
function setCachedTree(tree: FileNode[]): void {
try {
localStorage.setItem(TREE_CACHE_KEY, JSON.stringify(tree));
localStorage.setItem(TREE_CACHE_TIMESTAMP_KEY, Date.now().toString());
} catch { /* ignore */ }
}
function invalidateTreeCache(): void {
try {
localStorage.removeItem(TREE_CACHE_KEY);
localStorage.removeItem(TREE_CACHE_TIMESTAMP_KEY);
} catch { /* ignore */ }
}
/** Collect all directory keys for expand-all */
export function collectAllDirKeys(nodes: FileNode[]): string[] {
const keys: string[] = [];
for (const node of nodes) {
if (node.isDirectory) {
keys.push(node.path);
if (node.children) keys.push(...collectAllDirKeys(node.children));
}
}
return keys;
}
/** Filter tree to only show nodes matching the query (+ their parent dirs) */
export function filterTree(nodes: FileNode[], query: string): FileNode[] {
const q = query.toLowerCase();
const filtered: FileNode[] = [];
for (const node of nodes) {
if (node.isDirectory) {
const childMatches = node.children ? filterTree(node.children, query) : [];
if (childMatches.length > 0 || node.name.toLowerCase().includes(q)) {
filtered.push({ ...node, children: childMatches.length > 0 ? childMatches : node.children });
}
} else {
if (node.name.toLowerCase().includes(q)) {
filtered.push(node);
}
}
}
return filtered;
}
export interface UseDocsEditorReturn {
// State
fileTree: FileNode[];
config: ServicesConfig | null;
loading: boolean;
fetchError: boolean;
selectedFile: string | null;
fileContent: string;
dirty: boolean;
saving: boolean;
fileLoading: boolean;
filterQuery: string;
expandedKeys: React.Key[];
modalType: 'newFile' | 'newFolder' | 'rename' | null;
modalInput: string;
contextPath: string;
// Setters
setFilterQuery: (q: string) => void;
setExpandedKeys: React.Dispatch<React.SetStateAction<React.Key[]>>;
setModalType: (t: 'newFile' | 'newFolder' | 'rename' | null) => void;
setModalInput: (s: string) => void;
setContextPath: (s: string) => void;
setSelectedFile: (s: string | null) => void;
// Derived
filteredTree: FileNode[];
// Actions
fetchData: () => Promise<void>;
loadFile: (filePath: string) => Promise<void>;
saveFile: () => Promise<void>;
onContentChange: (value: string) => void;
handleDelete: (filePath: string) => void;
handleModalOk: () => Promise<void>;
handleNewFileRoot: () => void;
handleNewFolderRoot: () => void;
refreshTree: () => void;
handleUploadFiles: (files: FileList | File[]) => Promise<void>;
isDirectoryPath: (path: string) => boolean;
onTreeSelect: (keys: React.Key[]) => Promise<void>;
// Refs
previewIframeRef: React.RefObject<HTMLIFrameElement | null>;
fileInputRef: React.RefObject<HTMLInputElement | null>;
// Message context
contextHolder: React.ReactElement;
}
export function useDocsEditor(): UseDocsEditorReturn {
const location = useLocation();
const [messageApi, contextHolder] = message.useMessage();
const [fileTree, setFileTree] = useState<FileNode[]>(() => getCachedTree() || []);
const [config, setConfig] = useState<ServicesConfig | null>(null);
const [loading, setLoading] = useState(true);
const [fetchError, setFetchError] = useState(false);
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [fileContent, setFileContent] = useState<string>('');
const [originalContent, setOriginalContent] = useState<string>('');
const [dirty, setDirty] = useState(false);
const [saving, setSaving] = useState(false);
const [fileLoading, setFileLoading] = useState(false);
const [fileContentCache, setFileContentCache] = useState<Map<string, string>>(new Map());
const [filterQuery, setFilterQuery] = useState('');
const [expandedKeys, setExpandedKeys] = useState<React.Key[]>([]);
// Modal state
const [modalType, setModalType] = useState<'newFile' | 'newFolder' | 'rename' | null>(null);
const [modalInput, setModalInput] = useState('');
const [contextPath, setContextPath] = useState<string>('');
const previewIframeRef = useRef<HTMLIFrameElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// Fetch tree
const fetchTree = useCallback(async (showLoading = true, force = false) => {
try {
if (showLoading) setLoading(true);
setFetchError(false);
const url = force ? '/docs/files?force=true' : '/docs/files';
const res = await api.get<FileNode[]>(url);
setFileTree(res.data);
setCachedTree(res.data);
} catch {
setFetchError(true);
} finally {
if (showLoading) setLoading(false);
}
}, []);
const fetchConfig = useCallback(async () => {
try {
const res = await api.get<ServicesConfig>('/services/config');
setConfig(res.data);
} catch { /* ignore */ }
}, []);
const fetchData = useCallback(async () => {
const cached = getCachedTree();
if (cached) {
setFileTree(cached);
setLoading(false);
fetchTree(false);
} else {
setLoading(true);
setFetchError(false);
await fetchTree(true);
}
fetchConfig();
}, [fetchTree, fetchConfig]);
useEffect(() => { fetchData(); }, [fetchData]);
// Load file content
const loadFile = useCallback(async (filePath: string) => {
const cached = fileContentCache.get(filePath);
if (cached !== undefined) {
setFileContent(cached);
setOriginalContent(cached);
setSelectedFile(filePath);
setDirty(false);
if (previewIframeRef.current && filePath.endsWith('.md')) {
previewIframeRef.current.src = filePathToMkDocsUrl(filePath);
}
return;
}
setFileLoading(true);
try {
const res = await api.get<{ path: string; content: string }>(`/docs/files/${filePath}`);
const content = res.data.content;
setFileContentCache(prev => new Map(prev).set(filePath, content));
setFileContent(content);
setOriginalContent(content);
setSelectedFile(filePath);
setDirty(false);
if (previewIframeRef.current && filePath.endsWith('.md')) {
previewIframeRef.current.src = filePathToMkDocsUrl(filePath);
}
} catch {
messageApi.error('Failed to load file');
} finally {
setFileLoading(false);
}
}, [fileContentCache, messageApi]);
// Handle navigation state — auto-select a file
useEffect(() => {
const selectFile = (location.state as { selectFile?: string } | null)?.selectFile;
if (!selectFile || loading) return;
const parts = selectFile.split('/');
if (parts.length > 1) {
const parentKeys: string[] = [];
for (let i = 1; i < parts.length; i++) {
parentKeys.push(parts.slice(0, i).join('/'));
}
setExpandedKeys(prev => {
const set = new Set(prev.map(String));
for (const k of parentKeys) set.add(k);
return Array.from(set);
});
}
loadFile(selectFile);
window.history.replaceState({}, '');
}, [location.state, loading, loadFile]);
// Save file
const saveFile = useCallback(async () => {
if (!selectedFile || !dirty) return;
setSaving(true);
try {
await api.put(`/docs/files/${selectedFile}`, { content: fileContent });
setOriginalContent(fileContent);
setDirty(false);
setFileContentCache(prev => new Map(prev).set(selectedFile, fileContent));
messageApi.success('Saved');
setTimeout(() => {
if (previewIframeRef.current) {
previewIframeRef.current.contentWindow?.location.reload();
}
}, 500);
} catch {
messageApi.error('Failed to save');
} finally {
setSaving(false);
}
}, [selectedFile, dirty, fileContent, messageApi]);
// Ctrl+S keyboard shortcut
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
saveFile();
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [saveFile]);
const onContentChange = useCallback((value: string) => {
setFileContent(value);
setDirty(value !== originalContent);
}, [originalContent]);
const refreshTree = useCallback(() => {
invalidateTreeCache();
fetchTree(false, true);
}, [fetchTree]);
const isDirectoryPath = useCallback((path: string): boolean => {
function find(nodes: FileNode[]): boolean {
for (const node of nodes) {
if (node.path === path) return node.isDirectory;
if (node.isDirectory && node.children && find(node.children)) return true;
}
return false;
}
return find(fileTree);
}, [fileTree]);
const handleDelete = useCallback((filePath: string) => {
Modal.confirm({
title: 'Delete',
content: `Are you sure you want to delete "${filePath}"?`,
okText: 'Delete',
okButtonProps: { danger: true },
onOk: async () => {
try {
await api.delete(`/docs/files/${filePath}`);
messageApi.success('Deleted');
invalidateTreeCache();
setFileContentCache(prev => {
const next = new Map(prev);
next.delete(filePath);
return next;
});
if (selectedFile === filePath) {
setSelectedFile(null);
setFileContent('');
setOriginalContent('');
setDirty(false);
}
fetchTree();
} catch {
messageApi.error('Failed to delete');
}
},
});
}, [selectedFile, messageApi, fetchTree]);
const handleModalOk = useCallback(async () => {
if (!modalInput.trim()) return;
try {
if (modalType === 'newFile') {
const name = modalInput.endsWith('.md') ? modalInput : `${modalInput}.md`;
const path = contextPath ? `${contextPath}/${name}` : name;
await api.post(`/docs/files/${path}`, { content: `# ${modalInput.replace(/\.md$/, '')}\n` });
messageApi.success('File created');
invalidateTreeCache();
await Promise.all([fetchTree(), loadFile(path)]);
} else if (modalType === 'newFolder') {
const path = contextPath ? `${contextPath}/${modalInput}` : modalInput;
await api.post(`/docs/files/${path}`, { isDirectory: true });
messageApi.success('Folder created');
invalidateTreeCache();
fetchTree();
} else if (modalType === 'rename') {
const parentDir = contextPath.includes('/') ? contextPath.substring(0, contextPath.lastIndexOf('/')) : '';
const newPath = parentDir ? `${parentDir}/${modalInput}` : modalInput;
await api.post('/docs/files/rename', { from: contextPath, to: newPath });
messageApi.success('Renamed');
invalidateTreeCache();
setFileContentCache(prev => {
const next = new Map(prev);
const cached = next.get(contextPath);
next.delete(contextPath);
if (cached) next.set(newPath, cached);
return next;
});
if (selectedFile === contextPath) setSelectedFile(newPath);
fetchTree();
}
} catch {
messageApi.error('Operation failed');
}
setModalType(null);
}, [modalType, modalInput, contextPath, messageApi, fetchTree, loadFile, selectedFile]);
const handleNewFileRoot = useCallback(() => { setContextPath(''); setModalInput(''); setModalType('newFile'); }, []);
const handleNewFolderRoot = useCallback(() => { setContextPath(''); setModalInput(''); setModalType('newFolder'); }, []);
const selectImageFile = useCallback((filePath: string) => {
setSelectedFile(filePath);
setFileContent('');
setOriginalContent('');
setDirty(false);
if (previewIframeRef.current) {
previewIframeRef.current.src = '/mkdocs-proxy/';
}
}, []);
const onTreeSelect = useCallback(async (keys: React.Key[]) => {
if (keys.length === 0) return;
const path = keys[0] as string;
if (isImageFile(path)) {
if (dirty) {
Modal.confirm({
title: 'Unsaved Changes',
content: `Save changes to ${selectedFile} before switching?`,
okText: 'Save',
cancelText: 'Discard',
onOk: async () => { await saveFile(); selectImageFile(path); },
onCancel: () => { setDirty(false); selectImageFile(path); },
});
return;
}
selectImageFile(path);
return;
}
if (dirty) {
Modal.confirm({
title: 'Unsaved Changes',
content: `Save changes to ${selectedFile} before switching?`,
okText: 'Save',
cancelText: 'Discard',
onOk: async () => { await saveFile(); await loadFile(path); },
onCancel: () => { setDirty(false); loadFile(path); },
});
return;
}
await loadFile(path);
}, [dirty, selectedFile, saveFile, loadFile, selectImageFile]);
const handleUploadFiles = useCallback(async (files: FileList | File[]) => {
const fileArray = Array.from(files);
if (fileArray.length === 0) return;
let targetDir = '';
if (selectedFile) {
if (isDirectoryPath(selectedFile)) {
targetDir = selectedFile;
} else if (selectedFile.includes('/')) {
targetDir = selectedFile.substring(0, selectedFile.lastIndexOf('/'));
}
}
const hideLoading = messageApi.loading(`Uploading ${fileArray.length} file${fileArray.length > 1 ? 's' : ''}...`, 0);
let successCount = 0;
let lastMdPath: string | null = null;
for (const file of fileArray) {
try {
const formData = new FormData();
formData.append('file', file);
formData.append('path', targetDir);
const res = await api.post<{ success: boolean; path: string }>('/docs/upload', formData);
successCount++;
if (file.name.endsWith('.md')) {
lastMdPath = res.data.path;
}
} catch (err: unknown) {
const msg = (err as { response?: { data?: { error?: { message?: string } } } })?.response?.data?.error?.message || 'Upload failed';
messageApi.error(`Failed to upload ${file.name}: ${msg}`);
}
}
hideLoading();
if (successCount > 0) {
messageApi.success(`Uploaded ${successCount} file${successCount > 1 ? 's' : ''}`);
invalidateTreeCache();
await fetchTree(false, true);
if (lastMdPath) {
await loadFile(lastMdPath);
}
}
}, [selectedFile, isDirectoryPath, messageApi, fetchTree, loadFile]);
// Filtered tree
const filteredTree = useMemo(() => {
if (!filterQuery.trim()) return fileTree;
return filterTree(fileTree, filterQuery.trim());
}, [fileTree, filterQuery]);
// Sync expanded keys when filter changes
const expandedKeysForFilter = useMemo(() => {
if (filterQuery.trim()) return collectAllDirKeys(filteredTree);
return [];
}, [filterQuery, filteredTree]);
useEffect(() => {
setExpandedKeys(expandedKeysForFilter);
}, [expandedKeysForFilter]);
return {
fileTree,
config,
loading,
fetchError,
selectedFile,
fileContent,
dirty,
saving,
fileLoading,
filterQuery,
expandedKeys,
modalType,
modalInput,
contextPath,
setFilterQuery,
setExpandedKeys,
setModalType,
setModalInput,
setContextPath,
setSelectedFile,
filteredTree,
fetchData,
loadFile,
saveFile,
onContentChange,
handleDelete,
handleModalOk,
handleNewFileRoot,
handleNewFolderRoot,
refreshTree,
handleUploadFiles,
isDirectoryPath,
onTreeSelect,
previewIframeRef,
fileInputRef,
contextHolder,
};
}

View File

@ -1,289 +0,0 @@
/**
* Shared navigation defaults single source of truth.
*
* Consumed by PublicNavBar, AppLayout, NavigationSettingsPage, and PublicLayout footer.
* Eliminates the three duplicate DEFAULT_NAV_ITEMS arrays that previously caused sync bugs.
*/
import React from 'react';
import {
HomeOutlined,
SendOutlined,
EnvironmentOutlined,
BarChartOutlined,
CalendarOutlined,
ScheduleOutlined,
PlayCircleOutlined,
HeartOutlined,
DollarOutlined,
ShoppingOutlined,
LinkOutlined,
GlobalOutlined,
BookOutlined,
TagOutlined,
VideoCameraOutlined,
FileTextOutlined,
TrophyOutlined,
AppstoreOutlined,
WalletOutlined,
} from '@ant-design/icons';
import type { NavItem } from '@/types/api';
// ---------------------------------------------------------------------------
// Icon map — shared across all consumers
// ---------------------------------------------------------------------------
export const ICON_MAP: Record<string, React.ReactNode> = {
HomeOutlined: React.createElement(HomeOutlined),
SendOutlined: React.createElement(SendOutlined),
EnvironmentOutlined: React.createElement(EnvironmentOutlined),
BarChartOutlined: React.createElement(BarChartOutlined),
CalendarOutlined: React.createElement(CalendarOutlined),
ScheduleOutlined: React.createElement(ScheduleOutlined),
PlayCircleOutlined: React.createElement(PlayCircleOutlined),
HeartOutlined: React.createElement(HeartOutlined),
DollarOutlined: React.createElement(DollarOutlined),
ShoppingOutlined: React.createElement(ShoppingOutlined),
LinkOutlined: React.createElement(LinkOutlined),
GlobalOutlined: React.createElement(GlobalOutlined),
BookOutlined: React.createElement(BookOutlined),
TagOutlined: React.createElement(TagOutlined),
VideoCameraOutlined: React.createElement(VideoCameraOutlined),
FileTextOutlined: React.createElement(FileTextOutlined),
TrophyOutlined: React.createElement(TrophyOutlined),
AppstoreOutlined: React.createElement(AppstoreOutlined),
WalletOutlined: React.createElement(WalletOutlined),
};
// ---------------------------------------------------------------------------
// Default nav items — the canonical list with group nesting
// ---------------------------------------------------------------------------
export const DEFAULT_NAV_ITEMS: NavItem[] = [
{ id: 'home', label: 'Home', path: '/home', icon: 'HomeOutlined', enabled: true, order: 0, type: 'builtin' },
{ id: 'campaigns', label: 'Campaigns', path: '/campaigns', icon: 'SendOutlined', enabled: true, order: 1, type: 'builtin', featureFlag: 'enableInfluence' },
{ id: 'map', label: 'Map', path: '/map', icon: 'EnvironmentOutlined', enabled: true, order: 2, type: 'builtin', featureFlag: 'enableMap' },
{
id: 'scheduling', label: 'Scheduling', path: '', icon: 'AppstoreOutlined', enabled: true, order: 3, type: 'group',
children: [
{ id: 'shifts', label: 'Shifts', path: '/shifts', icon: 'ScheduleOutlined', enabled: true, order: 0, type: 'builtin', featureFlag: 'enableMap' },
{ id: 'events', label: 'Calendar', path: '/events', icon: 'CalendarOutlined', enabled: true, order: 1, type: 'builtin', featureFlag: 'enableEvents' },
{ id: 'polls', label: 'Polls', path: '/polls', icon: 'BarChartOutlined', enabled: true, order: 2, type: 'builtin', featureFlag: 'enableMeetingPlanner' },
{ id: 'tickets', label: 'Tickets', path: '/events/tickets', icon: 'TagOutlined', enabled: true, order: 3, type: 'builtin', featureFlag: 'enableTicketedEvents' },
{ id: 'meet', label: 'Meet', path: '/meet', icon: 'VideoCameraOutlined', enabled: true, order: 4, type: 'builtin', featureFlag: 'enableMeet' },
],
},
{ id: 'gallery', label: 'Gallery', path: '/gallery', icon: 'PlayCircleOutlined', enabled: true, order: 4, type: 'builtin', featureFlag: 'enableMediaFeatures' },
{
id: 'commerce', label: 'Commerce', path: '', icon: 'WalletOutlined', enabled: true, order: 5, type: 'group', featureFlag: 'enablePayments',
children: [
{ id: 'pricing', label: 'Pricing', path: '/pricing', icon: 'DollarOutlined', enabled: true, order: 0, type: 'builtin' },
{ id: 'shop', label: 'Shop', path: '/shop', icon: 'ShoppingOutlined', enabled: true, order: 1, type: 'builtin' },
{ id: 'donate', label: 'Donate', path: '/donate', icon: 'HeartOutlined', enabled: true, order: 2, type: 'builtin' },
],
},
{ id: 'wall-of-fame', label: 'Wall of Fame', path: '/wall-of-fame', icon: 'TrophyOutlined', enabled: true, order: 6, type: 'builtin', featureFlag: 'enableSocial' },
{ id: 'pages', label: 'Pages', path: '/pages', icon: 'FileTextOutlined', enabled: true, order: 7, type: 'builtin', featureFlag: 'enableLandingPages' },
{ id: 'landing', label: 'Website', path: '$landing', icon: 'GlobalOutlined', enabled: false, order: 8, type: 'builtin', external: true },
{ id: 'docs', label: 'Docs', path: '$docs', icon: 'BookOutlined', enabled: false, order: 9, type: 'builtin', external: true },
];
// ---------------------------------------------------------------------------
// Admin-specific overrides applied on top of shared defaults
// ---------------------------------------------------------------------------
export const ADMIN_NAV_OVERRIDES: Record<string, Partial<NavItem>> = {
home: { path: '/', external: true },
events: { label: 'Events', external: true },
};
// ---------------------------------------------------------------------------
// Feature flags
// ---------------------------------------------------------------------------
/** Flags that default to true (opt-out) — visible unless explicitly disabled */
const OPT_OUT_FLAGS = new Set([
'enableInfluence',
'enableMap',
'enableMediaFeatures',
'enableEvents',
]);
/** Build the feature flags record from settings */
export function buildFeatureFlags(settings: Record<string, any> | null | undefined): Record<string, boolean | undefined> {
if (!settings) return {};
return {
enableInfluence: settings.enableInfluence,
enableMap: settings.enableMap,
enableMediaFeatures: settings.enableMediaFeatures,
enablePayments: settings.enablePayments,
enableEvents: settings.enableEvents,
enableMeetingPlanner: settings.enableMeetingPlanner,
enableTicketedEvents: settings.enableTicketedEvents,
enableSocial: settings.enableSocial,
enableMeet: settings.enableMeet,
enableLandingPages: settings.enableLandingPages,
};
}
/** Check whether a single feature flag passes */
function flagPasses(flagName: string, flags: Record<string, boolean | undefined>): boolean {
if (OPT_OUT_FLAGS.has(flagName)) {
return flags[flagName] !== false;
}
return flags[flagName] === true;
}
// ---------------------------------------------------------------------------
// Merge: sync stored nav config with code-level defaults
// ---------------------------------------------------------------------------
/** Collect all IDs from items (top-level + children) */
function collectIds(items: NavItem[]): Set<string> {
const ids = new Set<string>();
for (const item of items) {
ids.add(item.id);
if (item.children) {
for (const child of item.children) ids.add(child.id);
}
}
return ids;
}
/**
* Merge stored nav config with code-level defaults.
* - Syncs icon/path for existing builtins (recursively for children).
* - Appends missing builtins/groups at end.
* - Adds missing children inside existing groups.
*/
export function mergeNavDefaults(stored: NavItem[]): NavItem[] {
const defaultMap = new Map<string, NavItem>();
for (const d of DEFAULT_NAV_ITEMS) {
if (d.type === 'builtin' || d.type === 'group') defaultMap.set(d.id, d);
if (d.children) {
for (const child of d.children) {
if (child.type === 'builtin') defaultMap.set(child.id, child);
}
}
}
const existingIds = collectIds(stored);
// Sync existing items
const synced = stored.map(item => {
const def = defaultMap.get(item.id);
if (!def) return item;
if (item.type === 'builtin' && def.type === 'builtin') {
return { ...item, icon: def.icon, path: def.path };
}
if (item.type === 'group' && def.type === 'group' && def.children) {
// Sync existing children and append missing ones
const childMap = new Map(def.children.map(c => [c.id, c]));
const syncedChildren = (item.children || []).map(child => {
const childDef = childMap.get(child.id);
return (childDef && child.type === 'builtin') ? { ...child, icon: childDef.icon, path: childDef.path } : child;
});
const childIds = new Set(syncedChildren.map(c => c.id));
const missingChildren = def.children.filter(c => !childIds.has(c.id) && !existingIds.has(c.id));
const children = missingChildren.length > 0 ? [...syncedChildren, ...missingChildren] : syncedChildren;
return { ...item, icon: def.icon, children };
}
return item;
});
// Append missing top-level items (groups + builtins not already present anywhere)
const syncedIds = collectIds(synced);
const missing = DEFAULT_NAV_ITEMS.filter(d =>
(d.type === 'builtin' || d.type === 'group') && !syncedIds.has(d.id)
);
return missing.length > 0 ? [...synced, ...missing] : synced;
}
// ---------------------------------------------------------------------------
// Filter: apply feature flags and visibility rules
// ---------------------------------------------------------------------------
/**
* Filter nav items by feature flags and enabled state.
* Groups are visible when: (a) group's own featureFlag passes, AND (b) at least one child passes.
*/
export function filterNavItems(items: NavItem[], featureFlags: Record<string, boolean | undefined>): NavItem[] {
return items
.filter(item => item.enabled)
.filter(item => {
if (item.type === 'group') {
// Group's own feature flag must pass (if set)
if (item.featureFlag && !flagPasses(item.featureFlag, featureFlags)) return false;
// At least one child must be visible
const visibleChildren = (item.children || [])
.filter(c => c.enabled)
.filter(c => !c.featureFlag || flagPasses(c.featureFlag, featureFlags));
return visibleChildren.length > 0;
}
if (!item.featureFlag) return true;
return flagPasses(item.featureFlag, featureFlags);
})
.map(item => {
if (item.type !== 'group' || !item.children) return item;
// Filter children within visible groups
const filteredChildren = item.children
.filter(c => c.enabled)
.filter(c => !c.featureFlag || flagPasses(c.featureFlag, featureFlags))
.sort((a, b) => a.order - b.order);
return { ...item, children: filteredChildren };
})
.sort((a, b) => a.order - b.order);
}
// ---------------------------------------------------------------------------
// Flatten: convert groups to flat list (for footer, Gancio, MkDocs)
// ---------------------------------------------------------------------------
/** Flatten groups into their children. Groups themselves are removed; children promoted to top level. */
export function flattenNavItems(items: NavItem[]): NavItem[] {
const result: NavItem[] = [];
for (const item of items) {
if (item.type === 'group' && item.children) {
for (const child of item.children) {
result.push(child);
}
} else {
result.push(item);
}
}
return result;
}
// ---------------------------------------------------------------------------
// Apply admin overrides to shared defaults
// ---------------------------------------------------------------------------
export function applyAdminOverrides(items: NavItem[]): NavItem[] {
return items.map(item => {
const override = ADMIN_NAV_OVERRIDES[item.id];
if (override) return { ...item, ...override };
if (item.type === 'group' && item.children) {
const children = item.children.map(child => {
const childOverride = ADMIN_NAV_OVERRIDES[child.id];
return childOverride ? { ...child, ...childOverride } : child;
});
return { ...item, children };
}
return item;
});
}
// ---------------------------------------------------------------------------
// Active route detection with group support
// ---------------------------------------------------------------------------
/** Check if a path matches any item or its children. Returns true if active. */
export function isItemActive(item: NavItem, currentPath: string): boolean {
if (item.type === 'group' && item.children) {
return item.children.some(child => child.path === currentPath || (child.path && currentPath.startsWith(child.path + '/')));
}
return item.path === currentPath || (item.path !== '' && currentPath.startsWith(item.path + '/'));
}

View File

@ -1,239 +0,0 @@
import { useState, useEffect } from 'react';
import { useNavigate, useOutletContext } from 'react-router-dom';
import {
Table,
Button,
Modal,
Form,
Input,
Select,
Tag,
Space,
Popconfirm,
message,
} from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import { api } from '@/lib/api';
import type { AdminCalendarView } from '@/types/api';
import type { AppOutletContext } from '@/components/AppLayout';
const ROLE_OPTIONS = [
{ label: 'Super Admin', value: 'SUPER_ADMIN' },
{ label: 'Influence Admin', value: 'INFLUENCE_ADMIN' },
{ label: 'Map Admin', value: 'MAP_ADMIN' },
{ label: 'User', value: 'USER' },
{ label: 'Temp', value: 'TEMP' },
];
const LAYER_TYPE_OPTIONS = [
{ label: 'Shifts', value: 'SHIFTS' },
{ label: 'Tickets', value: 'TICKETS' },
{ label: 'Polls', value: 'POLLS' },
{ label: 'Public Events', value: 'PUBLIC_EVENTS' },
];
const ROLE_COLORS: Record<string, string> = {
SUPER_ADMIN: 'red',
INFLUENCE_ADMIN: 'blue',
MAP_ADMIN: 'green',
USER: 'default',
TEMP: 'orange',
};
export default function AdminCalendarPage() {
const navigate = useNavigate();
const { setPageHeader } = useOutletContext<AppOutletContext>();
const [views, setViews] = useState<AdminCalendarView[]>([]);
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [editingView, setEditingView] = useState<AdminCalendarView | null>(null);
const [saving, setSaving] = useState(false);
const [form] = Form.useForm();
useEffect(() => {
setPageHeader({ title: 'Calendar Views', subtitle: 'Manage role-based shared calendar views' });
}, [setPageHeader]);
const fetchViews = async () => {
setLoading(true);
try {
const { data } = await api.get<{ views: AdminCalendarView[] }>('/admin/calendar/shared');
setViews(data.views);
} catch {
message.error('Failed to load calendar views');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchViews();
}, []);
const openCreate = () => {
setEditingView(null);
form.resetFields();
setModalOpen(true);
};
const openEdit = (view: AdminCalendarView) => {
setEditingView(view);
form.setFieldsValue({
name: view.name,
description: view.description,
autoIncludeRoles: view.autoIncludeRoles,
includedLayerTypes: view.includedLayerTypes,
});
setModalOpen(true);
};
const handleSave = async () => {
try {
const values = await form.validateFields();
setSaving(true);
if (editingView) {
await api.patch(`/admin/calendar/shared/${editingView.id}`, values);
message.success('View updated');
} else {
await api.post('/admin/calendar/shared', values);
message.success('View created');
}
setModalOpen(false);
fetchViews();
} catch {
// validation or API error
} finally {
setSaving(false);
}
};
const handleDelete = async (id: string) => {
try {
await api.delete(`/admin/calendar/shared/${id}`);
message.success('View deleted');
fetchViews();
} catch {
message.error('Failed to delete view');
}
};
const columns = [
{
title: 'Name',
dataIndex: 'name',
key: 'name',
},
{
title: 'Roles',
dataIndex: 'autoIncludeRoles',
key: 'roles',
render: (roles: string[]) => (
<Space size={4} wrap>
{roles.map((r) => (
<Tag key={r} color={ROLE_COLORS[r] || 'default'}>{r}</Tag>
))}
</Space>
),
},
{
title: 'Layer Types',
dataIndex: 'includedLayerTypes',
key: 'layerTypes',
render: (types: string[]) => (
<Space size={4} wrap>
{types.map((t) => (
<Tag key={t}>{t}</Tag>
))}
</Space>
),
},
{
title: 'Users',
dataIndex: 'userCount',
key: 'userCount',
width: 80,
},
{
title: 'Created',
dataIndex: 'createdAt',
key: 'createdAt',
width: 120,
render: (d: string) => dayjs(d).format('MMM D, YYYY'),
},
{
title: 'Actions',
key: 'actions',
width: 100,
render: (_: unknown, record: AdminCalendarView) => (
<Space size={4}>
<Button
type="text"
size="small"
icon={<EditOutlined />}
onClick={(e) => { e.stopPropagation(); openEdit(record); }}
/>
<Popconfirm
title="Delete this view?"
onConfirm={(e) => { e?.stopPropagation(); handleDelete(record.id); }}
onCancel={(e) => e?.stopPropagation()}
>
<Button
type="text"
size="small"
danger
icon={<DeleteOutlined />}
onClick={(e) => e.stopPropagation()}
/>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 16 }}>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
Create View
</Button>
</div>
<Table
dataSource={views}
columns={columns}
rowKey="id"
loading={loading}
pagination={false}
onRow={(record) => ({
onClick: () => navigate(`/app/scheduling/calendar-views/${record.id}`),
style: { cursor: 'pointer' },
})}
/>
<Modal
title={editingView ? 'Edit Calendar View' : 'Create Calendar View'}
open={modalOpen}
onOk={handleSave}
onCancel={() => setModalOpen(false)}
confirmLoading={saving}
destroyOnHidden
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="Name" rules={[{ required: true, message: 'Name is required' }]}>
<Input />
</Form.Item>
<Form.Item name="description" label="Description">
<Input.TextArea rows={2} />
</Form.Item>
<Form.Item name="autoIncludeRoles" label="Roles" initialValue={[]}>
<Select mode="multiple" options={ROLE_OPTIONS} placeholder="Select roles" />
</Form.Item>
<Form.Item name="includedLayerTypes" label="Layer Types" initialValue={[]}>
<Select mode="multiple" options={LAYER_TYPE_OPTIONS} placeholder="Select layer types" />
</Form.Item>
</Form>
</Modal>
</div>
);
}

View File

@ -1,308 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
Typography,
Button,
Grid,
Skeleton,
Empty,
List,
Tag,
Space,
Tabs,
Alert,
theme,
} from 'antd';
import {
ArrowLeftOutlined,
CalendarOutlined,
ClockCircleOutlined,
EnvironmentOutlined,
UserOutlined,
} from '@ant-design/icons';
import dayjs, { Dayjs } from 'dayjs';
import { api } from '@/lib/api';
import PersonalCalendarView from '@/components/calendar/PersonalCalendarView';
import type {
PersonalCalendarItem,
AdminCalendarView,
AdminCalendarUser,
AdminCalendarItem,
} from '@/types/api';
const { Title, Text } = Typography;
const ROLE_COLORS: Record<string, string> = {
SUPER_ADMIN: 'red',
INFLUENCE_ADMIN: 'blue',
MAP_ADMIN: 'green',
USER: 'default',
TEMP: 'orange',
};
export default function AdminCalendarViewPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const { token } = theme.useToken();
const [view, setView] = useState<AdminCalendarView | null>(null);
const [users, setUsers] = useState<AdminCalendarUser[]>([]);
const [items, setItems] = useState<AdminCalendarItem[]>([]);
const [truncated, setTruncated] = useState(false);
const [totalUsers, setTotalUsers] = useState(0);
const [loading, setLoading] = useState(true);
const [currentMonth, setCurrentMonth] = useState<Dayjs>(dayjs());
const [selectedDate, setSelectedDate] = useState<string | null>(null);
const fetchData = useCallback(async () => {
if (!id) return;
const startDate = currentMonth.startOf('month').subtract(7, 'day').format('YYYY-MM-DD');
const endDate = currentMonth.endOf('month').add(7, 'day').format('YYYY-MM-DD');
try {
const [viewsRes, itemsRes] = await Promise.all([
api.get<{ views: AdminCalendarView[] }>('/admin/calendar/shared'),
api.get<{
items: AdminCalendarItem[];
users: AdminCalendarUser[];
totalUsers: number;
truncated: boolean;
}>(`/admin/calendar/shared/${id}/items`, { params: { startDate, endDate } }),
]);
const found = viewsRes.data.views.find((v) => v.id === id);
if (found) setView(found);
setItems(itemsRes.data.items);
setUsers(itemsRes.data.users);
setTotalUsers(itemsRes.data.totalUsers);
setTruncated(itemsRes.data.truncated);
} catch {
navigate('/app/scheduling/calendar-views');
} finally {
setLoading(false);
}
}, [id, currentMonth, navigate]);
useEffect(() => {
setLoading(true);
fetchData();
}, [fetchData]);
const calendarItems: PersonalCalendarItem[] = items.map((item) => ({
id: item.id,
type: item.type as PersonalCalendarItem['type'],
layerId: item.layerId,
title: item.title,
date: item.date,
startTime: item.startTime,
endTime: item.endTime,
isAllDay: false,
location: item.location,
color: item.userColor,
itemType: item.itemType as PersonalCalendarItem['itemType'],
busyStatus: 'BUSY' as const,
showDetailsTo: 'EVERYONE' as const,
}));
const selectedDateItems = selectedDate
? items.filter((item) => item.date === selectedDate)
: [];
const handleItemClick = () => {};
if (loading) {
return (
<div style={{ padding: 24 }}>
<Skeleton active paragraph={{ rows: 10 }} />
</div>
);
}
if (!view) return null;
const usersPanel = (
<div>
<Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12 }}>
<UserOutlined /> Users ({totalUsers})
</Text>
{truncated && (
<Alert
type="warning"
message={`Showing ${users.length} of ${totalUsers} users`}
style={{ marginBottom: 12, fontSize: 12 }}
showIcon
/>
)}
<List
size="small"
dataSource={users}
renderItem={(u) => (
<List.Item style={{ padding: '6px 0' }}>
<Space size={8}>
<div
style={{
width: 10,
height: 10,
borderRadius: '50%',
background: u.color,
flexShrink: 0,
}}
/>
<Text style={{ fontSize: 13 }} ellipsis>
{u.name || u.email}
</Text>
<Tag color={ROLE_COLORS[u.role] || 'default'} style={{ fontSize: 10, margin: 0 }}>
{u.role}
</Tag>
</Space>
</List.Item>
)}
/>
</div>
);
const dateDetailPanel = selectedDate && (
<div>
<Text strong style={{ fontSize: 15, display: 'block', marginBottom: 12 }}>
{dayjs(selectedDate).format('ddd, MMM D')}
</Text>
{selectedDateItems.length === 0 ? (
<Empty description="No events" image={Empty.PRESENTED_IMAGE_SIMPLE} />
) : (
<List
size="small"
dataSource={selectedDateItems}
renderItem={(item) => (
<List.Item style={{ padding: '8px 0', flexDirection: 'column', alignItems: 'stretch' }}>
<List.Item.Meta
avatar={
<div
style={{
width: 4,
height: 32,
borderRadius: 2,
background: item.userColor || item.color,
flexShrink: 0,
}}
/>
}
title={
<Space size={4}>
<Text style={{ fontSize: 13 }} ellipsis>{item.title}</Text>
<Text type="secondary" style={{ fontSize: 11 }}>
({item.userName})
</Text>
</Space>
}
description={
<Space size={4} wrap style={{ fontSize: 11 }}>
<Tag icon={<ClockCircleOutlined />} style={{ fontSize: 11, margin: 0 }}>
{item.startTime?.slice(0, 5)} - {item.endTime?.slice(0, 5)}
</Tag>
{item.location && (
<Tag icon={<EnvironmentOutlined />} style={{ fontSize: 11, margin: 0 }}>
{item.location}
</Tag>
)}
</Space>
}
/>
</List.Item>
)}
/>
)}
</div>
);
if (isMobile) {
return (
<div style={{ padding: 12 }}>
<Space style={{ marginBottom: 12 }}>
<Button
type="text"
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/app/scheduling/calendar-views')}
/>
<Title level={5} style={{ margin: 0 }}>{view.name}</Title>
</Space>
{view.description && (
<Text type="secondary" style={{ display: 'block', marginBottom: 12, fontSize: 13 }}>
{view.description}
</Text>
)}
<Tabs
defaultActiveKey="calendar"
items={[
{
key: 'calendar',
label: 'Calendar',
children: (
<>
<PersonalCalendarView
items={calendarItems}
currentMonth={currentMonth}
selectedDate={selectedDate}
onDateSelect={setSelectedDate}
onItemClick={handleItemClick}
onMonthChange={setCurrentMonth}
/>
{dateDetailPanel}
</>
),
},
{ key: 'users', label: 'Users', children: usersPanel },
]}
/>
</div>
);
}
return (
<div style={{ padding: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 16 }}>
<Space>
<Button
type="text"
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/app/scheduling/calendar-views')}
/>
<CalendarOutlined style={{ fontSize: 18 }} />
<Title level={4} style={{ margin: 0 }}>{view.name}</Title>
</Space>
{view.description && (
<Text type="secondary" style={{ marginLeft: 16, fontSize: 13 }}>
{view.description}
</Text>
)}
</div>
<div style={{ display: 'flex', gap: 0 }}>
<div style={{ width: 220, flexShrink: 0, paddingRight: 16 }}>
{usersPanel}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<PersonalCalendarView
items={calendarItems}
currentMonth={currentMonth}
selectedDate={selectedDate}
onDateSelect={setSelectedDate}
onItemClick={handleItemClick}
onMonthChange={setCurrentMonth}
/>
</div>
{selectedDate && (
<div
style={{
width: 280,
flexShrink: 0,
padding: '0 0 0 16px',
borderLeft: `1px solid ${token.colorBorderSecondary}`,
}}
>
{dateDetailPanel}
</div>
)}
</div>
</div>
);
}

View File

@ -29,14 +29,11 @@ import {
QuestionCircleOutlined, QuestionCircleOutlined,
ExportOutlined, ExportOutlined,
QrcodeOutlined, QrcodeOutlined,
DatabaseOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table'; import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useOutletContext, useNavigate } from 'react-router-dom'; import { useOutletContext, useNavigate } from 'react-router-dom';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { buildServiceUrl } from '@/lib/service-url';
import { useAuthStore } from '@/stores/auth.store';
import type { AppOutletContext } from '@/components/AppLayout'; import type { AppOutletContext } from '@/components/AppLayout';
import type { import type {
Campaign, Campaign,
@ -47,7 +44,6 @@ import type {
CreateCampaignPayload, CreateCampaignPayload,
UpdateCampaignPayload, UpdateCampaignPayload,
Cut, Cut,
ServicesConfig,
} from '@/types/api'; } from '@/types/api';
import CampaignEmailsDrawer from '@/pages/CampaignEmailsDrawer'; import CampaignEmailsDrawer from '@/pages/CampaignEmailsDrawer';
import ExportContactsModal from '@/components/canvass/ExportContactsModal'; import ExportContactsModal from '@/components/canvass/ExportContactsModal';
@ -127,9 +123,6 @@ export default function CampaignsPage() {
const [createSelectedVideo, setCreateSelectedVideo] = useState<Video | null>(null); const [createSelectedVideo, setCreateSelectedVideo] = useState<Video | null>(null);
const [editSelectedVideo, setEditSelectedVideo] = useState<Video | null>(null); const [editSelectedVideo, setEditSelectedVideo] = useState<Video | null>(null);
const { settings: siteSettings } = useSettingsStore(); const { settings: siteSettings } = useSettingsStore();
const { user: currentUser } = useAuthStore();
const isSuperAdmin = currentUser?.role === 'SUPER_ADMIN';
const [nocodbUrl, setNocodbUrl] = useState<string | null>(null);
const screens = Grid.useBreakpoint(); const screens = Grid.useBreakpoint();
const isMobile = !screens.md; const isMobile = !screens.md;
@ -498,31 +491,14 @@ export default function CampaignsPage() {
const { setPageHeader } = useOutletContext<AppOutletContext>(); const { setPageHeader } = useOutletContext<AppOutletContext>();
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => {
if (isSuperAdmin) {
api.get<ServicesConfig>('/services/config')
.then(({ data }) => setNocodbUrl(buildServiceUrl(data.nocodbSubdomain, data.domain, data.nocodbPort)))
.catch(() => {});
}
}, [isSuperAdmin]);
const headerActions = useMemo(() => ( const headerActions = useMemo(() => (
<Space>
{isSuperAdmin && nocodbUrl && (
<Tooltip title="Browse campaigns in NocoDB for advanced filtering & export">
<Button icon={<DatabaseOutlined />} href={nocodbUrl} target="_blank" size="small">
Browse in NocoDB
</Button>
</Tooltip>
)}
<Button <Button
icon={<MailOutlined />} icon={<MailOutlined />}
onClick={() => navigate('/app/email-queue')} onClick={() => navigate('/app/email-queue')}
> >
Email Queue Email Queue
</Button> </Button>
</Space> ), [navigate]);
), [navigate, isSuperAdmin, nocodbUrl]);
useEffect(() => { useEffect(() => {
setPageHeader({ title: 'Campaigns', actions: headerActions }); setPageHeader({ title: 'Campaigns', actions: headerActions });

View File

@ -38,7 +38,6 @@ import {
LockOutlined, LockOutlined,
MessageOutlined, MessageOutlined,
CalendarOutlined, CalendarOutlined,
LineChartOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { import {
BarChart, Bar, XAxis, YAxis, Tooltip as RechartsTooltip, BarChart, Bar, XAxis, YAxis, Tooltip as RechartsTooltip,
@ -197,7 +196,6 @@ export default function DashboardPage() {
const [lastRefresh, setLastRefresh] = useState<Date | null>(null); const [lastRefresh, setLastRefresh] = useState<Date | null>(null);
const [activeView, setActiveView] = useState<'dashboard' | 'homepage'>('dashboard'); const [activeView, setActiveView] = useState<'dashboard' | 'homepage'>('dashboard');
const [homepageUrl, setHomepageUrl] = useState<string | null>(null); const [homepageUrl, setHomepageUrl] = useState<string | null>(null);
const [grafanaUrl, setGrafanaUrl] = useState<string | null>(null);
const [onboardingDismissed, setOnboardingDismissed] = useState(() => const [onboardingDismissed, setOnboardingDismissed] = useState(() =>
localStorage.getItem('cml-onboarding-dismissed') === 'true' localStorage.getItem('cml-onboarding-dismissed') === 'true'
); );
@ -218,7 +216,6 @@ export default function DashboardPage() {
api.get<ServicesStatus>('/services/status').then(({ data }) => setServices(data)).catch(() => {}), api.get<ServicesStatus>('/services/status').then(({ data }) => setServices(data)).catch(() => {}),
api.get<ServicesConfig>('/services/config').then(({ data }) => { api.get<ServicesConfig>('/services/config').then(({ data }) => {
setHomepageUrl(buildServiceUrl(data.homepageSubdomain, data.domain, data.homepagePort)); setHomepageUrl(buildServiceUrl(data.homepageSubdomain, data.domain, data.homepagePort));
setGrafanaUrl(buildServiceUrl(data.grafanaSubdomain, data.domain, data.grafanaPort));
}).catch(() => {}), }).catch(() => {}),
api.get<SystemInfo>('/dashboard/system').then(({ data }) => setSystemInfo(data)).catch(() => {}), api.get<SystemInfo>('/dashboard/system').then(({ data }) => setSystemInfo(data)).catch(() => {}),
api.get<ContainerInfo[]>('/dashboard/containers').then(({ data }) => setContainers(data)).catch(() => {}), api.get<ContainerInfo[]>('/dashboard/containers').then(({ data }) => setContainers(data)).catch(() => {}),
@ -442,9 +439,6 @@ export default function DashboardPage() {
{showInfluence && <QuickStat icon={<MailOutlined />} color="#faad14" value={summary.emails.sent} label="sent" onClick={() => navigate('/app/email-queue')} />} {showInfluence && <QuickStat icon={<MailOutlined />} color="#faad14" value={summary.emails.sent} label="sent" onClick={() => navigate('/app/email-queue')} />}
{showMedia && <QuickStat icon={<VideoCameraOutlined />} color="#13c2c2" value={summary.videos.published} label={`of ${summary.videos.total}`} onClick={() => navigate('/app/media/library')} />} {showMedia && <QuickStat icon={<VideoCameraOutlined />} color="#13c2c2" value={summary.videos.published} label={`of ${summary.videos.total}`} onClick={() => navigate('/app/media/library')} />}
{showMap && <QuickStat icon={<ScheduleOutlined />} color="#eb2f96" value={summary.shifts.upcoming} label={`${summary.shifts.open} open`} onClick={() => navigate('/app/map/shifts')} />} {showMap && <QuickStat icon={<ScheduleOutlined />} color="#eb2f96" value={summary.shifts.upcoming} label={`${summary.shifts.open} open`} onClick={() => navigate('/app/map/shifts')} />}
{isSuperAdmin && grafanaUrl && (
<QuickStat icon={<BarChartOutlined />} color="#f5222d" value="Metrics" label="" onClick={() => window.open(`${grafanaUrl}/d/changemaker-overview?from=now-24h&to=now`, '_blank')} />
)}
{/* Pending action tags */} {/* Pending action tags */}
{summary.responses.pending > 0 && ( {summary.responses.pending > 0 && (
<Tag color="orange" style={{ cursor: 'pointer', margin: '0 0 0 4px' }} onClick={() => navigate('/app/responses')}> <Tag color="orange" style={{ cursor: 'pointer', margin: '0 0 0 4px' }} onClick={() => navigate('/app/responses')}>
@ -521,16 +515,7 @@ export default function DashboardPage() {
</Flex> </Flex>
} }
size="small" size="small"
extra={ extra={<Button type="link" onClick={() => navigate('/app/campaigns')}>View</Button>}
<Space size={4}>
{grafanaUrl && (
<Tooltip title="View email trends in Grafana">
<Button type="text" size="small" icon={<LineChartOutlined />} onClick={() => window.open(`${grafanaUrl}/d/changemaker-overview?viewPanel=2&from=now-7d&to=now`, '_blank')} />
</Tooltip>
)}
<Button type="link" onClick={() => navigate('/app/campaigns')}>View</Button>
</Space>
}
> >
{summary && ( {summary && (
<Flex gap={8} align="flex-start"> <Flex gap={8} align="flex-start">
@ -577,16 +562,7 @@ export default function DashboardPage() {
</Flex> </Flex>
} }
size="small" size="small"
extra={ extra={<Button type="link" onClick={() => navigate('/app/map')}>View</Button>}
<Space size={4}>
{grafanaUrl && (
<Tooltip title="View canvass trends in Grafana">
<Button type="text" size="small" icon={<LineChartOutlined />} onClick={() => window.open(`${grafanaUrl}/d/changemaker-overview?viewPanel=10&from=now-7d&to=now`, '_blank')} />
</Tooltip>
)}
<Button type="link" onClick={() => navigate('/app/map')}>View</Button>
</Space>
}
> >
{summary && ( {summary && (
<Space direction="vertical" style={{ width: '100%' }} size={6}> <Space direction="vertical" style={{ width: '100%' }} size={6}>
@ -623,16 +599,7 @@ export default function DashboardPage() {
</Flex> </Flex>
} }
size="small" size="small"
extra={ extra={<Button type="link" onClick={() => navigate('/app/users')}>Manage</Button>}
<Space size={4}>
{grafanaUrl && (
<Tooltip title="View login trends in Grafana">
<Button type="text" size="small" icon={<LineChartOutlined />} onClick={() => window.open(`${grafanaUrl}/d/changemaker-overview?viewPanel=5&from=now-7d&to=now`, '_blank')} />
</Tooltip>
)}
<Button type="link" onClick={() => navigate('/app/users')}>Manage</Button>
</Space>
}
> >
{summary && ( {summary && (
<Space direction="vertical" style={{ width: '100%' }} size={6}> <Space direction="vertical" style={{ width: '100%' }} size={6}>

View File

@ -66,8 +66,6 @@ import type { editor as monacoEditor } from 'monaco-editor';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { buildServiceUrl } from '@/lib/service-url'; import { buildServiceUrl } from '@/lib/service-url';
import { useMkDocsBuild } from '@/hooks/useMkDocsBuild'; import { useMkDocsBuild } from '@/hooks/useMkDocsBuild';
import { useDocsEditor } from '@/hooks/useDocsEditor';
import { MobileDocsEditor } from '@/components/docs/MobileDocsEditor';
import type { FileNode, ServicesConfig } from '@/types/api'; import type { FileNode, ServicesConfig } from '@/types/api';
import type { AppOutletContext } from '@/components/AppLayout'; import type { AppOutletContext } from '@/components/AppLayout';
import { VideoPickerModal } from '@/components/media/VideoPickerModal'; import { VideoPickerModal } from '@/components/media/VideoPickerModal';
@ -548,12 +546,6 @@ function applySnippet(
ed.focus(); ed.focus();
} }
/** Wrapper component so useDocsEditor() hook only runs on mobile */
function MobileDocsEditorWrapper() {
const editor = useDocsEditor();
return <MobileDocsEditor editor={editor} />;
}
export default function DocsPage() { export default function DocsPage() {
const { setPageHeader } = useOutletContext<AppOutletContext>(); const { setPageHeader } = useOutletContext<AppOutletContext>();
const location = useLocation(); const location = useLocation();
@ -1584,7 +1576,9 @@ export default function DocsPage() {
}, [handleUploadFiles]); }, [handleUploadFiles]);
if (isMobile) { if (isMobile) {
return <MobileDocsEditorWrapper />; return (
<Result status="info" title="Desktop Required" subTitle="The documentation editor requires a desktop browser with a larger screen." />
);
} }
if (loading) { if (loading) {

View File

@ -1,13 +1,12 @@
import { useState, useEffect, useCallback, useMemo } from 'react'; import { useState, useEffect, useCallback, useMemo } from 'react';
import { useOutletContext } from 'react-router-dom'; import { useOutletContext } from 'react-router-dom';
import { import {
Button, Space, Badge, Table, Form, Input, DatePicker, Drawer, Button, Space, Badge, Table, Modal, Form, Input, DatePicker,
App, Popconfirm, Typography, Tag, Tooltip, Grid, Result, App, Popconfirm, Typography, Tag, Tooltip, Grid, Result,
} from 'antd'; } from 'antd';
import { import {
ReloadOutlined, PlusOutlined, VideoCameraOutlined, ReloadOutlined, PlusOutlined, VideoCameraOutlined,
CopyOutlined, DeleteOutlined, LoginOutlined, LinkOutlined, CopyOutlined, DeleteOutlined, LoginOutlined, LinkOutlined,
ThunderboltOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
@ -29,7 +28,6 @@ export default function JitsiMeetPage() {
const [config, setConfig] = useState<ServicesConfig | null>(null); const [config, setConfig] = useState<ServicesConfig | null>(null);
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const [fastMeetLoading, setFastMeetLoading] = useState(false);
const [form] = Form.useForm(); const [form] = Form.useForm();
const fetchStatus = useCallback(async () => { const fetchStatus = useCallback(async () => {
@ -132,32 +130,6 @@ export default function JitsiMeetPage() {
} }
}, [meetUrl, message]); }, [meetUrl, message]);
const handleFastMeeting = useCallback(async () => {
if (!meetUrl) {
message.error('Jitsi service not configured');
return;
}
setFastMeetLoading(true);
try {
const title = `Quick Meeting — ${dayjs().format('MMM D, h:mm A')}`;
const createRes = await api.post<Meeting>('/jitsi/meetings', { title });
const slug = createRes.data.slug;
const tokenRes = await api.post<{ token: string; jitsiRoom: string }>(`/jitsi/meetings/${slug}/token`);
const guestLink = `${window.location.origin}/meet/${slug}`;
await navigator.clipboard.writeText(guestLink);
message.success('Guest link copied — opening meeting...');
window.open(`${meetUrl}/${tokenRes.data.jitsiRoom}?jwt=${tokenRes.data.token}`, '_blank');
fetchMeetings();
} catch {
message.error('Failed to create fast meeting');
} finally {
setFastMeetLoading(false);
}
}, [meetUrl, message, fetchMeetings]);
const headerActions = useMemo(() => ( const headerActions = useMemo(() => (
<Space> <Space>
<Badge <Badge
@ -167,14 +139,11 @@ export default function JitsiMeetPage() {
<Button icon={<ReloadOutlined />} onClick={handleRefresh} size="small"> <Button icon={<ReloadOutlined />} onClick={handleRefresh} size="small">
Refresh Refresh
</Button> </Button>
<Button icon={<ThunderboltOutlined />} onClick={handleFastMeeting} loading={fastMeetLoading} size="small">
Fast Meeting
</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)} size="small"> <Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)} size="small">
New Meeting New Meeting
</Button> </Button>
</Space> </Space>
), [online, handleRefresh, handleFastMeeting, fastMeetLoading]); ), [online, handleRefresh]);
useEffect(() => { useEffect(() => {
setPageHeader({ title: 'Video Meet', actions: headerActions }); setPageHeader({ title: 'Video Meet', actions: headerActions });
@ -278,46 +247,25 @@ export default function JitsiMeetPage() {
}, },
]; ];
const drawerWidth = 480;
return ( return (
<> <>
<div
style={{
marginRight: createOpen ? drawerWidth : 0,
transition: 'margin-right 0.15s cubic-bezier(0.2, 0, 0, 1)',
}}
>
<Table <Table
dataSource={meetings} dataSource={meetings}
columns={columns} columns={columns}
rowKey="id" rowKey="id"
loading={loading} loading={loading}
pagination={false} pagination={false}
scroll={{ x: 'max-content' }}
locale={{ emptyText: 'No meetings yet. Create one to get started.' }} locale={{ emptyText: 'No meetings yet. Create one to get started.' }}
/> />
</div>
<Drawer <Modal
title="Create Meeting" title="Create Meeting"
open={createOpen} open={createOpen}
placement="right" onCancel={() => { setCreateOpen(false); form.resetFields(); }}
width={drawerWidth} onOk={() => form.submit()}
mask={false} confirmLoading={creating}
destroyOnHidden okText="Create"
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }} destroyOnClose
onClose={() => { setCreateOpen(false); form.resetFields(); }}
extra={
<Space>
<Button onClick={() => { setCreateOpen(false); form.resetFields(); }} disabled={creating}>
Cancel
</Button>
<Button type="primary" loading={creating} onClick={() => form.submit()}>
Create
</Button>
</Space>
}
> >
<Form form={form} layout="vertical" onFinish={handleCreate}> <Form form={form} layout="vertical" onFinish={handleCreate}>
<Form.Item <Form.Item
@ -334,11 +282,11 @@ export default function JitsiMeetPage() {
<DatePicker.RangePicker showTime format="YYYY-MM-DD HH:mm" style={{ width: '100%' }} /> <DatePicker.RangePicker showTime format="YYYY-MM-DD HH:mm" style={{ width: '100%' }} />
</Form.Item> </Form.Item>
</Form> </Form>
<Paragraph type="secondary" style={{ fontSize: 12 }}> <Paragraph type="secondary" style={{ fontSize: 12, marginTop: 8 }}>
A unique guest link will be generated. Share it with anyone they can join without an account. A unique guest link will be generated. Share it with anyone they can join without an account.
Authenticated users join as moderators with full meeting controls. Authenticated users join as moderators with full meeting controls.
</Paragraph> </Paragraph>
</Drawer> </Modal>
</> </>
); );
} }

View File

@ -241,7 +241,7 @@ export default function LandingPagesPage() {
settingsForm.setFieldsValue({ settingsForm.setFieldsValue({
title: page.title, title: page.title,
description: page.description, description: page.description,
listed: page.listed ?? false, listed: (page as any).listed ?? false,
mkdocsPath: page.mkdocsPath, mkdocsPath: page.mkdocsPath,
mkdocsExportMode: page.mkdocsExportMode, mkdocsExportMode: page.mkdocsExportMode,
mkdocsHideNav: page.mkdocsHideNav, mkdocsHideNav: page.mkdocsHideNav,
@ -284,7 +284,7 @@ export default function LandingPagesPage() {
render: (published: boolean, record: LandingPage) => ( render: (published: boolean, record: LandingPage) => (
<Space size={4}> <Space size={4}>
<Tag color={published ? 'green' : 'default'}>{published ? 'Published' : 'Draft'}</Tag> <Tag color={published ? 'green' : 'default'}>{published ? 'Published' : 'Draft'}</Tag>
{record.listed && <Tag color="blue">Listed</Tag>} {(record as any).listed && <Tag color="blue">Listed</Tag>}
</Space> </Space>
), ),
}, },

View File

@ -23,7 +23,6 @@ import {
InputNumber, InputNumber,
Tabs, Tabs,
Grid, Grid,
Tooltip,
} from 'antd'; } from 'antd';
import { import {
PlusOutlined, PlusOutlined,
@ -42,15 +41,12 @@ import {
ClockCircleOutlined, ClockCircleOutlined,
ScissorOutlined, ScissorOutlined,
EyeOutlined, EyeOutlined,
DatabaseOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table'; import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
import type { UploadFile } from 'antd/es/upload'; import type { UploadFile } from 'antd/es/upload';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useNavigate, useOutletContext } from 'react-router-dom'; import { useNavigate, useOutletContext } from 'react-router-dom';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { buildServiceUrl } from '@/lib/service-url';
import { useAuthStore } from '@/stores/auth.store';
import type { AppOutletContext } from '@/components/AppLayout'; import type { AppOutletContext } from '@/components/AppLayout';
import type { import type {
Location, Location,
@ -70,7 +66,6 @@ import type {
NarImportProgress, NarImportProgress,
LocationHistory, LocationHistory,
LocationHistoryResponse, LocationHistoryResponse,
ServicesConfig,
} from '@/types/api'; } from '@/types/api';
import { import {
LOCATION_HISTORY_ACTION_LABELS, LOCATION_HISTORY_ACTION_LABELS,
@ -106,9 +101,6 @@ function formatNarSize(bytes: number): string {
export default function LocationsPage() { export default function LocationsPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { user } = useAuthStore();
const isSuperAdmin = user?.role === 'SUPER_ADMIN';
const [nocodbUrl, setNocodbUrl] = useState<string | null>(null);
const [locations, setLocations] = useState<Location[]>([]); const [locations, setLocations] = useState<Location[]>([]);
const screens = Grid.useBreakpoint(); const screens = Grid.useBreakpoint();
const isMobile = !screens.md; const isMobile = !screens.md;
@ -989,26 +981,9 @@ export default function LocationsPage() {
const { setPageHeader } = useOutletContext<AppOutletContext>(); const { setPageHeader } = useOutletContext<AppOutletContext>();
useEffect(() => { useEffect(() => {
if (isSuperAdmin) { setPageHeader({ title: 'Map Locations' });
api.get<ServicesConfig>('/services/config')
.then(({ data }) => setNocodbUrl(buildServiceUrl(data.nocodbSubdomain, data.domain, data.nocodbPort)))
.catch(() => {});
}
}, [isSuperAdmin]);
useEffect(() => {
setPageHeader({
title: 'Map Locations',
actions: isSuperAdmin && nocodbUrl ? (
<Tooltip title="Browse locations in NocoDB for advanced filtering & export">
<Button icon={<DatabaseOutlined />} href={nocodbUrl} target="_blank" size="small">
Browse in NocoDB
</Button>
</Tooltip>
) : undefined,
});
return () => setPageHeader(null); return () => setPageHeader(null);
}, [setPageHeader, isSuperAdmin, nocodbUrl]); }, [setPageHeader]);
const anyDrawerOpen = createDrawerOpen || editDrawerOpen || importDrawerOpen || bulkGeocodeDrawerOpen; const anyDrawerOpen = createDrawerOpen || editDrawerOpen || importDrawerOpen || bulkGeocodeDrawerOpen;
const activeDrawerWidth = isMobile ? 0 : (createDrawerOpen ? 600 : editDrawerOpen ? 700 : importDrawerOpen ? 700 : bulkGeocodeDrawerOpen ? 600 : 0); const activeDrawerWidth = isMobile ? 0 : (createDrawerOpen ? 600 : editDrawerOpen ? 700 : importDrawerOpen ? 700 : bulkGeocodeDrawerOpen ? 600 : 0);

View File

@ -1,7 +1,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import { Card, Form, Input, Button, Alert, Typography, Segmented, Modal, App } from 'antd'; import { Card, Form, Input, Button, Alert, Typography, Segmented, Modal, App } from 'antd';
import { MailOutlined, LockOutlined, UserOutlined, CheckCircleOutlined, GiftOutlined } from '@ant-design/icons'; import { MailOutlined, LockOutlined, UserOutlined, CheckCircleOutlined } from '@ant-design/icons';
import { useAuthStore } from '@/stores/auth.store'; import { useAuthStore } from '@/stores/auth.store';
import { useSettingsStore } from '@/stores/settings.store'; import { useSettingsStore } from '@/stores/settings.store';
import { isAdmin } from '@/utils/roles'; import { isAdmin } from '@/utils/roles';
@ -31,17 +31,8 @@ export default function LoginPage() {
const [resendLoading, setResendLoading] = useState(false); const [resendLoading, setResendLoading] = useState(false);
const redirectTo = searchParams.get('redirect'); const redirectTo = searchParams.get('redirect');
const refCode = searchParams.get('ref') || '';
const showRegister = settings?.enablePublicRegistration !== false; const showRegister = settings?.enablePublicRegistration !== false;
// Auto-switch to register mode when ref code is present
useEffect(() => {
if (refCode && showRegister) {
setMode('register');
registerForm.setFieldValue('inviteCode', refCode);
}
}, [refCode, showRegister]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => { useEffect(() => {
if (isAuthenticated && user) { if (isAuthenticated && user) {
if (redirectTo) { if (redirectTo) {
@ -71,9 +62,9 @@ export default function LoginPage() {
} }
}; };
const handleRegister = async (values: { name: string; email: string; password: string; inviteCode?: string }) => { const handleRegister = async (values: { name: string; email: string; password: string }) => {
try { try {
const result = await register(values.name, values.email, values.password, values.inviteCode); const result = await register(values.name, values.email, values.password);
if (result?.requiresVerification) { if (result?.requiresVerification) {
// Don't navigate — show the verification message // Don't navigate — show the verification message
return; return;
@ -273,13 +264,6 @@ export default function LoginPage() {
<Input.Password prefix={<LockOutlined />} placeholder="Password (12+ chars)" /> <Input.Password prefix={<LockOutlined />} placeholder="Password (12+ chars)" />
</Form.Item> </Form.Item>
<Form.Item
name="inviteCode"
extra="Have an invite code? Enter it here"
>
<Input prefix={<GiftOutlined />} placeholder="Invite Code (optional)" />
</Form.Item>
<Form.Item <Form.Item
name="confirmPassword" name="confirmPassword"
dependencies={['password']} dependencies={['password']}

View File

@ -9,30 +9,61 @@ import {
Tag, Tag,
Modal, Modal,
Tooltip, Tooltip,
Select,
Badge,
message, message,
Spin, Spin,
Form, Form,
} from 'antd'; } from 'antd';
import { import {
SaveOutlined, SaveOutlined,
HomeOutlined,
EnvironmentOutlined,
CalendarOutlined,
ScheduleOutlined,
PlayCircleOutlined,
HeartOutlined,
DollarOutlined,
ShoppingOutlined,
LinkOutlined, LinkOutlined,
ArrowUpOutlined, ArrowUpOutlined,
ArrowDownOutlined, ArrowDownOutlined,
DeleteOutlined, DeleteOutlined,
PlusOutlined, PlusOutlined,
FolderOutlined, GlobalOutlined,
FolderAddOutlined, BookOutlined,
SendOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useSettingsStore } from '@/stores/settings.store'; import { useSettingsStore } from '@/stores/settings.store';
import type { AppOutletContext } from '@/components/AppLayout'; import type { AppOutletContext } from '@/components/AppLayout';
import type { NavItem } from '@/types/api'; import type { NavItem } from '@/types/api';
import {
DEFAULT_NAV_ITEMS, const NAV_ICON_MAP: Record<string, React.ReactNode> = {
ICON_MAP, HomeOutlined: <HomeOutlined />,
mergeNavDefaults, SendOutlined: <SendOutlined />,
} from '@/lib/nav-defaults'; EnvironmentOutlined: <EnvironmentOutlined />,
CalendarOutlined: <CalendarOutlined />,
ScheduleOutlined: <ScheduleOutlined />,
PlayCircleOutlined: <PlayCircleOutlined />,
HeartOutlined: <HeartOutlined />,
DollarOutlined: <DollarOutlined />,
ShoppingOutlined: <ShoppingOutlined />,
LinkOutlined: <LinkOutlined />,
GlobalOutlined: <GlobalOutlined />,
BookOutlined: <BookOutlined />,
};
const DEFAULT_NAV_ITEMS: NavItem[] = [
{ id: 'home', label: 'Home', path: '/', icon: 'HomeOutlined', enabled: true, order: 0, type: 'builtin', external: true },
{ id: 'campaigns', label: 'Campaigns', path: '/campaigns', icon: 'SendOutlined', enabled: true, order: 1, type: 'builtin', featureFlag: 'enableInfluence' },
{ id: 'map', label: 'Map', path: '/map', icon: 'EnvironmentOutlined', enabled: true, order: 2, type: 'builtin', featureFlag: 'enableMap' },
{ id: 'shifts', label: 'Shifts', path: '/shifts', icon: 'ScheduleOutlined', enabled: true, order: 3, type: 'builtin', featureFlag: 'enableMap' },
{ id: 'events', label: 'Events', path: '/events', icon: 'CalendarOutlined', enabled: true, order: 4, type: 'builtin', featureFlag: 'enableEvents', external: true },
{ id: 'gallery', label: 'Gallery', path: '/gallery', icon: 'PlayCircleOutlined', enabled: true, order: 5, type: 'builtin', featureFlag: 'enableMediaFeatures' },
{ id: 'pricing', label: 'Pricing', path: '/pricing', icon: 'DollarOutlined', enabled: true, order: 6, type: 'builtin', featureFlag: 'enablePayments' },
{ id: 'shop', label: 'Shop', path: '/shop', icon: 'ShoppingOutlined', enabled: true, order: 7, type: 'builtin', featureFlag: 'enablePayments' },
{ id: 'donate', label: 'Donate', path: '/donate', icon: 'HeartOutlined', enabled: true, order: 8, type: 'builtin', featureFlag: 'enablePayments' },
{ id: 'landing', label: 'Website', path: '$landing', icon: 'GlobalOutlined', enabled: false, order: 9, type: 'builtin', external: true },
{ id: 'docs', label: 'Docs', path: '$docs', icon: 'BookOutlined', enabled: false, order: 10, type: 'builtin', external: true },
];
export default function NavigationSettingsPage() { export default function NavigationSettingsPage() {
const { setPageHeader } = useOutletContext<AppOutletContext>(); const { setPageHeader } = useOutletContext<AppOutletContext>();
@ -56,7 +87,16 @@ export default function NavigationSettingsPage() {
useEffect(() => { useEffect(() => {
if (settings?.navConfig?.items) { if (settings?.navConfig?.items) {
setNavItems(mergeNavDefaults(settings.navConfig.items)); // Merge missing builtin defaults and sync icons so code-level changes propagate
const stored = settings.navConfig.items;
const defaultMap = new Map(DEFAULT_NAV_ITEMS.filter(d => d.type === 'builtin').map(d => [d.id, d]));
const synced = stored.map((item: NavItem) => {
const def = defaultMap.get(item.id);
return (def && item.type === 'builtin') ? { ...item, icon: def.icon } : item;
});
const ids = new Set(synced.map((i: NavItem) => i.id));
const missing = DEFAULT_NAV_ITEMS.filter(d => d.type === 'builtin' && !ids.has(d.id));
setNavItems(missing.length > 0 ? [...synced, ...missing] : synced);
} }
}, [settings]); }, [settings]);
@ -73,89 +113,37 @@ export default function NavigationSettingsPage() {
} }
}; };
// --- Toggle enable/disable (works for top-level and children) ---
const toggleNavItem = (itemId: string, enabled: boolean) => { const toggleNavItem = (itemId: string, enabled: boolean) => {
setNavItems(prev => prev.map(item => { setNavItems(prev => prev.map(item => item.id === itemId ? { ...item, enabled } : item));
if (item.id === itemId) return { ...item, enabled };
if (item.children) {
const children = item.children.map(c => c.id === itemId ? { ...c, enabled } : c);
return { ...item, children };
}
return item;
}));
setDirty(true); setDirty(true);
}; };
// --- Reorder: scoped to sibling context ---
const moveNavItem = (itemId: string, direction: 'up' | 'down') => { const moveNavItem = (itemId: string, direction: 'up' | 'down') => {
setNavItems(prev => { setNavItems(prev => {
// Check if item is top-level const items = [...prev].sort((a, b) => a.order - b.order);
const topIdx = prev.findIndex(i => i.id === itemId); const idx = items.findIndex(i => i.id === itemId);
if (topIdx >= 0) { if (idx < 0) return prev;
const sorted = [...prev].sort((a, b) => a.order - b.order);
const idx = sorted.findIndex(i => i.id === itemId);
const swapIdx = direction === 'up' ? idx - 1 : idx + 1; const swapIdx = direction === 'up' ? idx - 1 : idx + 1;
if (swapIdx < 0 || swapIdx >= sorted.length) return prev; if (swapIdx < 0 || swapIdx >= items.length) return prev;
const tempOrder = sorted[idx]!.order; const tempOrder = items[idx]!.order;
sorted[idx] = { ...sorted[idx]!, order: sorted[swapIdx]!.order }; items[idx] = { ...items[idx]!, order: items[swapIdx]!.order };
sorted[swapIdx] = { ...sorted[swapIdx]!, order: tempOrder }; items[swapIdx] = { ...items[swapIdx]!, order: tempOrder };
return sorted.sort((a, b) => a.order - b.order); items.sort((a, b) => a.order - b.order);
} return items;
// Check children
return prev.map(item => {
if (!item.children) return item;
const childIdx = item.children.findIndex(c => c.id === itemId);
if (childIdx < 0) return item;
const sorted = [...item.children].sort((a, b) => a.order - b.order);
const idx = sorted.findIndex(c => c.id === itemId);
const swapIdx = direction === 'up' ? idx - 1 : idx + 1;
if (swapIdx < 0 || swapIdx >= sorted.length) return item;
const tempOrder = sorted[idx]!.order;
sorted[idx] = { ...sorted[idx]!, order: sorted[swapIdx]!.order };
sorted[swapIdx] = { ...sorted[swapIdx]!, order: tempOrder };
return { ...item, children: sorted.sort((a, b) => a.order - b.order) };
});
}); });
setDirty(true); setDirty(true);
}; };
// --- Update a field on any item (top-level or child) ---
const updateNavItemField = (itemId: string, field: 'label' | 'path', value: string) => { const updateNavItemField = (itemId: string, field: 'label' | 'path', value: string) => {
setNavItems(prev => prev.map(item => { setNavItems(prev => prev.map(item => item.id === itemId ? { ...item, [field]: value } : item));
if (item.id === itemId) return { ...item, [field]: value };
if (item.children) {
const children = item.children.map(c => c.id === itemId ? { ...c, [field]: value } : c);
return { ...item, children };
}
return item;
}));
setDirty(true); setDirty(true);
}; };
// --- Delete an item ---
const deleteNavItem = (itemId: string) => { const deleteNavItem = (itemId: string) => {
setNavItems(prev => { setNavItems(prev => prev.filter(item => item.id !== itemId));
// If it's a group, move children to top-level first
const group = prev.find(i => i.id === itemId && i.type === 'group');
if (group && group.children) {
const maxOrder = prev.reduce((max, i) => Math.max(max, i.order), -1);
const promotedChildren = group.children.map((c, idx) => ({ ...c, order: maxOrder + 1 + idx }));
return [...prev.filter(i => i.id !== itemId), ...promotedChildren];
}
// Remove from top-level
const withoutTop = prev.filter(i => i.id !== itemId);
if (withoutTop.length < prev.length) return withoutTop;
// Remove from children
return prev.map(item => {
if (!item.children) return item;
const filtered = item.children.filter(c => c.id !== itemId);
return filtered.length !== item.children.length ? { ...item, children: filtered } : item;
});
});
setDirty(true); setDirty(true);
}; };
// --- Add custom link ---
const addCustomNavLink = () => { const addCustomNavLink = () => {
if (!customLinkLabel.trim() || !customLinkPath.trim()) return; if (!customLinkLabel.trim() || !customLinkPath.trim()) return;
const maxOrder = navItems.reduce((max, item) => Math.max(max, item.order), -1); const maxOrder = navItems.reduce((max, item) => Math.max(max, item.order), -1);
@ -175,69 +163,6 @@ export default function NavigationSettingsPage() {
setDirty(true); setDirty(true);
}; };
// --- Add group ---
const addGroup = () => {
const maxOrder = navItems.reduce((max, item) => Math.max(max, item.order), -1);
setNavItems(prev => [...prev, {
id: `group-${Date.now()}`,
label: 'New Group',
path: '',
icon: 'FolderOutlined',
enabled: true,
order: maxOrder + 1,
type: 'group',
children: [],
}]);
setDirty(true);
};
// --- Move item to/from group ---
const moveItemToGroup = (itemId: string, targetGroupId: string | null) => {
setNavItems(prev => {
// Extract item from wherever it currently is
let extractedItem: NavItem | null = null;
let items = prev.map(item => {
if (item.id === itemId) {
extractedItem = item;
return null; // Mark for removal
}
if (item.children) {
const child = item.children.find(c => c.id === itemId);
if (child) {
extractedItem = child;
return { ...item, children: item.children.filter(c => c.id !== itemId) };
}
}
return item;
}).filter(Boolean) as NavItem[];
if (!extractedItem) return prev;
const extracted: NavItem = extractedItem;
if (targetGroupId === null) {
// Move to top level
const maxOrder = items.reduce((max, i) => Math.max(max, i.order), -1);
return [...items, { ...extracted, order: maxOrder + 1 }];
}
// Move into target group
return items.map(item => {
if (item.id === targetGroupId) {
const maxChildOrder = (item.children || []).reduce((max, c) => Math.max(max, c.order), -1);
return {
...item,
children: [...(item.children || []), { ...extracted, order: maxChildOrder + 1 }],
};
}
return item;
});
});
setDirty(true);
};
// Get all groups for the "Move to Group" dropdown
const groups = navItems.filter(i => i.type === 'group');
if (loading) { if (loading) {
return ( return (
<div style={{ textAlign: 'center', padding: 80 }}> <div style={{ textAlign: 'center', padding: 80 }}>
@ -246,40 +171,30 @@ export default function NavigationSettingsPage() {
); );
} }
// Helper to find which group an item belongs to (null = top-level) return (
const findParentGroupId = (itemId: string): string | null => { <div style={{ maxWidth: 700 }}>
for (const item of navItems) { <Alert
if (item.children?.some(c => c.id === itemId)) return item.id; type="info"
} message="Configure the navigation bar shown on all public pages, the admin header, Gancio events page, and MkDocs site."
return null; showIcon
}; style={{ marginBottom: 24 }}
/>
const renderItemRow = (item: NavItem, _idx: number, siblings: NavItem[], indent: boolean) => {
const isGroup = item.type === 'group';
const sorted = [...siblings].sort((a, b) => a.order - b.order);
const sortedIdx = sorted.findIndex(i => i.id === item.id);
const parentGroupId = indent ? findParentGroupId(item.id) : null;
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 16 }}>
{[...navItems].sort((a, b) => a.order - b.order).map((item, idx) => {
const sorted = [...navItems].sort((a, b) => a.order - b.order);
return ( return (
<div <div
key={item.id} key={item.id}
style={{ style={{
display: 'grid', display: 'grid',
gridTemplateColumns: indent gridTemplateColumns: '40px 32px 1fr 1.5fr auto 90px',
? '40px 32px 1fr 1fr auto auto 90px'
: '40px 32px 1fr 1.5fr auto auto 90px',
gap: 8, gap: 8,
alignItems: 'center', alignItems: 'center',
padding: '8px 12px', padding: '8px 12px',
paddingLeft: indent ? 52 : 12, background: item.enabled ? 'rgba(255,255,255,0.04)' : 'rgba(255,255,255,0.01)',
background: isGroup
? 'rgba(100,150,255,0.06)'
: item.enabled ? 'rgba(255,255,255,0.04)' : 'rgba(255,255,255,0.01)',
borderRadius: 6, borderRadius: 6,
border: isGroup border: '1px solid rgba(255,255,255,0.08)',
? '1px solid rgba(100,150,255,0.15)'
: '1px solid rgba(255,255,255,0.08)',
borderLeft: indent ? '3px solid rgba(100,150,255,0.3)' : undefined,
opacity: item.enabled ? 1 : 0.5, opacity: item.enabled ? 1 : 0.5,
}} }}
> >
@ -289,28 +204,22 @@ export default function NavigationSettingsPage() {
onChange={(checked) => toggleNavItem(item.id, checked)} onChange={(checked) => toggleNavItem(item.id, checked)}
/> />
<span style={{ fontSize: 16, display: 'flex', alignItems: 'center', justifyContent: 'center' }}> <span style={{ fontSize: 16, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{isGroup ? <FolderOutlined /> : (ICON_MAP[item.icon] || <LinkOutlined />)} {NAV_ICON_MAP[item.icon] || <LinkOutlined />}
</span> </span>
<Input <Input
size="small" size="small"
value={item.label} value={item.label}
onChange={(e) => updateNavItemField(item.id, 'label', e.target.value)} onChange={(e) => updateNavItemField(item.id, 'label', e.target.value)}
/> />
{isGroup ? (
<Badge count={item.children?.length ?? 0} size="small" offset={[8, 0]} color="blue">
<span style={{ fontSize: 12, color: 'rgba(255,255,255,0.45)', fontStyle: 'italic' }}>Group {item.children?.length ?? 0} items</span>
</Badge>
) : (
<Tooltip title={item.path.startsWith('$') ? 'Auto-resolved based on environment' : undefined}> <Tooltip title={item.path.startsWith('$') ? 'Auto-resolved based on environment' : undefined}>
<Input <Input
size="small" size="small"
value={item.path} value={item.path}
onChange={(e) => updateNavItemField(item.id, 'path', e.target.value)} onChange={(e) => updateNavItemField(item.id, 'path', e.target.value)}
disabled={item.path.startsWith('$') || isGroup} disabled={item.path.startsWith('$')}
style={{ fontFamily: 'monospace', fontSize: 12 }} style={{ fontFamily: 'monospace', fontSize: 12 }}
/> />
</Tooltip> </Tooltip>
)}
<Space size={2}> <Space size={2}>
<Tooltip title="Move up"> <Tooltip title="Move up">
<Button <Button
@ -318,7 +227,7 @@ export default function NavigationSettingsPage() {
size="small" size="small"
icon={<ArrowUpOutlined />} icon={<ArrowUpOutlined />}
onClick={() => moveNavItem(item.id, 'up')} onClick={() => moveNavItem(item.id, 'up')}
disabled={sortedIdx === 0} disabled={idx === 0}
style={{ width: 24, height: 24 }} style={{ width: 24, height: 24 }}
/> />
</Tooltip> </Tooltip>
@ -328,12 +237,12 @@ export default function NavigationSettingsPage() {
size="small" size="small"
icon={<ArrowDownOutlined />} icon={<ArrowDownOutlined />}
onClick={() => moveNavItem(item.id, 'down')} onClick={() => moveNavItem(item.id, 'down')}
disabled={sortedIdx === sorted.length - 1} disabled={idx === sorted.length - 1}
style={{ width: 24, height: 24 }} style={{ width: 24, height: 24 }}
/> />
</Tooltip> </Tooltip>
{(item.type === 'custom' || item.type === 'group') && ( {item.type === 'custom' && (
<Tooltip title={isGroup ? 'Delete group (children move to top level)' : 'Delete'}> <Tooltip title="Delete">
<Button <Button
type="text" type="text"
size="small" size="small"
@ -345,21 +254,6 @@ export default function NavigationSettingsPage() {
</Tooltip> </Tooltip>
)} )}
</Space> </Space>
{/* Move to Group dropdown — only for non-group items */}
{!isGroup ? (
<Select
size="small"
value={parentGroupId ?? '__top__'}
onChange={(val) => moveItemToGroup(item.id, val === '__top__' ? null : val)}
style={{ width: 90, fontSize: 11 }}
popupMatchSelectWidth={false}
>
<Select.Option value="__top__">(Top Level)</Select.Option>
{groups.map(g => (
<Select.Option key={g.id} value={g.id}>{g.label}</Select.Option>
))}
</Select>
) : (
<div style={{ textAlign: 'right' }}> <div style={{ textAlign: 'right' }}>
{item.featureFlag ? ( {item.featureFlag ? (
<Tooltip title={`Controlled by ${item.featureFlag}`}> <Tooltip title={`Controlled by ${item.featureFlag}`}>
@ -368,40 +262,14 @@ export default function NavigationSettingsPage() {
</Tag> </Tag>
</Tooltip> </Tooltip>
) : ( ) : (
<Tag color="geekblue" style={{ margin: 0, fontSize: 10 }}>group</Tag> <Tag color={item.type === 'builtin' ? 'blue' : 'purple'} style={{ margin: 0, fontSize: 10 }}>
{item.type}
</Tag>
)} )}
</div> </div>
)}
{!isGroup && (
<div style={{ display: 'none' }}>
{/* Placeholder — tag column handled by the Select above */}
</div>
)}
</div> </div>
); );
}; })}
const sorted = [...navItems].sort((a, b) => a.order - b.order);
return (
<div style={{ maxWidth: 800 }}>
<Alert
type="info"
message="Configure the navigation bar shown on all public pages, the admin header, Gancio events page, and MkDocs site. Groups appear as dropdowns on desktop and collapsible sections on mobile."
showIcon
style={{ marginBottom: 24 }}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 16 }}>
{sorted.map((item, idx) => (
<div key={item.id}>
{renderItemRow(item, idx, sorted, false)}
{/* Render children indented below their group */}
{item.type === 'group' && item.children && [...item.children].sort((a, b) => a.order - b.order).map((child, childIdx) =>
renderItemRow(child, childIdx, item.children!, true)
)}
</div>
))}
</div> </div>
<Space> <Space>
@ -411,12 +279,6 @@ export default function NavigationSettingsPage() {
> >
Add Custom Link Add Custom Link
</Button> </Button>
<Button
icon={<FolderAddOutlined />}
onClick={addGroup}
>
Add Group
</Button>
</Space> </Space>
<div style={{ marginTop: 24 }}> <div style={{ marginTop: 24 }}>

File diff suppressed because it is too large Load Diff

View File

@ -1,55 +0,0 @@
import { useRef } from 'react';
import { Typography, Space } from 'antd';
import { CalendarOutlined } from '@ant-design/icons';
import UnifiedCalendar from '@/components/calendar/UnifiedCalendar';
import type { UnifiedCalendarItem } from '@/types/api';
import { useNavigate } from 'react-router-dom';
const { Title, Text } = Typography;
export default function SchedulingCalendarPage() {
const navigate = useNavigate();
const addEventRef = useRef<(() => void) | null>(null);
const handleShiftClick = (item: UnifiedCalendarItem) => {
if (item.shiftId) {
navigate('/app/map/shifts');
}
};
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16, flexWrap: 'wrap', gap: 8 }}>
<Title level={3} style={{ margin: 0 }}>
<CalendarOutlined style={{ marginRight: 8 }} />
Scheduling Calendar
</Title>
{/* Legend */}
<Space size={12} wrap>
<Space size={4}>
<div style={{ width: 12, height: 12, borderRadius: 2, background: '#1890ff' }} />
<Text type="secondary" style={{ fontSize: 13 }}>Shifts</Text>
</Space>
<Space size={4}>
<div style={{ width: 12, height: 12, borderRadius: 2, background: '#fa8c16' }} />
<Text type="secondary" style={{ fontSize: 13 }}>Polls</Text>
</Space>
<Space size={4}>
<div style={{ width: 12, height: 12, borderRadius: 2, background: '#722ed1' }} />
<Text type="secondary" style={{ fontSize: 13 }}>Ticketed Events</Text>
</Space>
<Space size={4}>
<div style={{ width: 12, height: 12, borderRadius: 2, background: '#52c41a' }} />
<Text type="secondary" style={{ fontSize: 13 }}>Community Events</Text>
</Space>
</Space>
</div>
<UnifiedCalendar
onShiftSignup={handleShiftClick}
onAddEvent={addEventRef}
/>
</div>
);
}

View File

@ -528,9 +528,6 @@ export default function SettingsPage() {
<Form.Item label="Social Connections" name="enableSocial" valuePropName="checked" extra="Volunteer friend connections, activity feeds, and discovery" style={{ marginBottom: 12 }}> <Form.Item label="Social Connections" name="enableSocial" valuePropName="checked" extra="Volunteer friend connections, activity feeds, and discovery" style={{ marginBottom: 12 }}>
<Switch /> <Switch />
</Form.Item> </Form.Item>
<Form.Item label="Social Calendar" name="enableSocialCalendar" valuePropName="checked" extra="Personal calendar with layers, shared views, and .ics integration" style={{ marginBottom: 12 }}>
<Switch />
</Form.Item>
<Form.Item label="Auto-sync People to Map" name="autoSyncPeopleToMap" valuePropName="checked" extra="Adding a contact address auto-creates a map location with geocoding" style={{ marginBottom: 0 }}> <Form.Item label="Auto-sync People to Map" name="autoSyncPeopleToMap" valuePropName="checked" extra="Adding a contact address auto-creates a map location with geocoding" style={{ marginBottom: 0 }}>
<Switch /> <Switch />
</Form.Item> </Form.Item>
@ -543,13 +540,7 @@ export default function SettingsPage() {
size="small" size="small"
title={<Space><DollarOutlined /> Commerce</Space>} title={<Space><DollarOutlined /> Commerce</Space>}
> >
<Form.Item label="Payments (Stripe)" name="enablePayments" valuePropName="checked" extra="Subscriptions, products, and donations" style={{ marginBottom: 12 }}> <Form.Item label="Payments (Stripe)" name="enablePayments" valuePropName="checked" extra="Subscriptions, products, and donations" style={{ marginBottom: 0 }}>
<Switch />
</Form.Item>
<Form.Item label="Ticketed Events" name="enableTicketedEvents" valuePropName="checked" extra="Create events with tiered tickets, QR check-in, and Stripe payments" style={{ marginBottom: 12 }}>
<Switch />
</Form.Item>
<Form.Item label="Require Event Approval" name="requireEventApproval" valuePropName="checked" extra="Non-admin users need admin approval before publishing events" style={{ marginBottom: 0 }}>
<Switch /> <Switch />
</Form.Item> </Form.Item>
</Card> </Card>

View File

@ -41,13 +41,10 @@ import {
SettingOutlined, SettingOutlined,
UserAddOutlined, UserAddOutlined,
ContactsOutlined, ContactsOutlined,
DatabaseOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table'; import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { buildServiceUrl } from '@/lib/service-url';
import { useAuthStore } from '@/stores/auth.store';
import { getUserRoles } from '@/utils/roles'; import { getUserRoles } from '@/utils/roles';
import type { import type {
User, User,
@ -65,7 +62,6 @@ import type {
LinkedContactResponse, LinkedContactResponse,
Contact, Contact,
SupportLevel, SupportLevel,
ServicesConfig,
} from '@/types/api'; } from '@/types/api';
import { SUPPORT_LEVEL_LABELS, SUPPORT_LEVEL_COLORS, CONTACT_SOURCE_LABELS, CONTACT_SOURCE_COLORS } from '@/types/api'; import { SUPPORT_LEVEL_LABELS, SUPPORT_LEVEL_COLORS, CONTACT_SOURCE_LABELS, CONTACT_SOURCE_COLORS } from '@/types/api';
@ -106,9 +102,6 @@ const statusOptions: { value: UserStatus; label: string }[] = [
export default function UsersPage() { export default function UsersPage() {
const { setPageHeader } = useOutletContext<AppOutletContext>(); const { setPageHeader } = useOutletContext<AppOutletContext>();
const { user: currentUser } = useAuthStore();
const isSuperAdmin = currentUser?.role === 'SUPER_ADMIN';
const [nocodbUrl, setNocodbUrl] = useState<string | null>(null);
const [users, setUsers] = useState<User[]>([]); const [users, setUsers] = useState<User[]>([]);
const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0, totalPages: 0 }); const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0, totalPages: 0 });
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -136,26 +129,9 @@ export default function UsersPage() {
const isMobile = !screens.md; const isMobile = !screens.md;
useEffect(() => { useEffect(() => {
if (isSuperAdmin) { setPageHeader({ title: 'Users' });
api.get<ServicesConfig>('/services/config')
.then(({ data }) => setNocodbUrl(buildServiceUrl(data.nocodbSubdomain, data.domain, data.nocodbPort)))
.catch(() => {});
}
}, [isSuperAdmin]);
useEffect(() => {
setPageHeader({
title: 'Users',
actions: isSuperAdmin && nocodbUrl ? (
<Tooltip title="Browse users in NocoDB for advanced filtering & export">
<Button icon={<DatabaseOutlined />} href={nocodbUrl} target="_blank" size="small">
Browse in NocoDB
</Button>
</Tooltip>
) : undefined,
});
return () => setPageHeader(null); return () => setPageHeader(null);
}, [setPageHeader, isSuperAdmin, nocodbUrl]); }, [setPageHeader]);
const getActiveDrawerWidth = () => { const getActiveDrawerWidth = () => {
if (createDrawerOpen) return 520; if (createDrawerOpen) return 520;

View File

@ -1,308 +0,0 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import {
Typography, Button, Input, Card, Space, message, Result, Statistic,
Row, Col, Divider, Spin,
} from 'antd';
import {
ArrowLeftOutlined, CameraOutlined, KeyOutlined,
CheckCircleOutlined, CloseCircleOutlined, WarningOutlined,
} from '@ant-design/icons';
import { api } from '@/lib/api';
import { useParams, useNavigate } from 'react-router-dom';
const { Title, Text } = Typography;
interface ScanResult {
type: 'success' | 'warning' | 'error';
title: string;
subtitle: string;
ticket?: Record<string, unknown>;
}
export default function CheckInScannerPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [event, setEvent] = useState<Record<string, unknown> | null>(null);
const [stats, setStats] = useState<{ totalTickets: number; checkedIn: number; remaining: number } | null>(null);
const [scanResult, setScanResult] = useState<ScanResult | null>(null);
const [manualCode, setManualCode] = useState('');
const [manualEmail, setManualEmail] = useState('');
const [processing, setProcessing] = useState(false);
const [mode, setMode] = useState<'camera' | 'manual'>('manual');
const scannerRef = useRef<unknown>(null);
const scannerContainerRef = useRef<HTMLDivElement>(null);
const fetchEvent = useCallback(async () => {
if (!id) return;
try {
const { data } = await api.get(`/ticketed-events/admin/${id}`);
setEvent(data);
} catch {
message.error('Failed to load event');
}
}, [id]);
const fetchStats = useCallback(async () => {
if (!id) return;
try {
const { data } = await api.get(`/ticketed-events/checkin/event/${id}/stats`);
setStats(data);
} catch { /* silent */ }
}, [id]);
useEffect(() => {
fetchEvent();
fetchStats();
const interval = setInterval(fetchStats, 5000); // Refresh stats every 5s
return () => clearInterval(interval);
}, [fetchEvent, fetchStats]);
const handleQrScan = useCallback(async (decodedText: string) => {
if (processing) return;
setProcessing(true);
setScanResult(null);
try {
// Extract token from URL or use raw text
let token = decodedText;
try {
const url = new URL(decodedText);
const t = url.searchParams.get('token');
if (t) token = t;
} catch { /* not a URL, use raw text */ }
// Validate first
const { data: validation } = await api.post('/ticketed-events/checkin/validate', { token });
if (!validation.valid) {
setScanResult({
type: validation.error === 'Already checked in' ? 'warning' : 'error',
title: validation.error,
subtitle: validation.ticket
? `${validation.ticket.ticketCode}${validation.ticket.holderName || validation.ticket.holderEmail}`
: '',
ticket: validation.ticket,
});
setProcessing(false);
return;
}
// Confirm check-in
const { data: result } = await api.post('/ticketed-events/checkin/confirm', { token });
setScanResult({
type: 'success',
title: 'Checked In!',
subtitle: `${result.ticket.ticketCode}${result.ticket.holderName || result.ticket.holderEmail}`,
ticket: result.ticket,
});
fetchStats();
} catch (err: unknown) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message || 'Scan failed';
setScanResult({ type: 'error', title: msg, subtitle: '' });
} finally {
setProcessing(false);
}
}, [processing, fetchStats]);
const startCamera = useCallback(async () => {
if (!scannerContainerRef.current) return;
try {
const { Html5QrcodeScanner } = await import('html5-qrcode');
const scanner = new Html5QrcodeScanner('qr-reader', {
fps: 10,
qrbox: { width: 250, height: 250 },
}, false);
scanner.render(
(decodedText: string) => handleQrScan(decodedText),
() => { /* error callback — ignore decode failures */ },
);
scannerRef.current = scanner;
} catch {
message.error('Failed to start camera. Make sure html5-qrcode is installed.');
}
}, [handleQrScan]);
const stopCamera = useCallback(() => {
if (scannerRef.current) {
(scannerRef.current as { clear: () => Promise<void> }).clear().catch(() => {});
scannerRef.current = null;
}
}, []);
useEffect(() => {
if (mode === 'camera') {
startCamera();
} else {
stopCamera();
}
return () => stopCamera();
}, [mode, startCamera, stopCamera]);
const handleManualCheckin = async (type: 'code' | 'email') => {
if (!id) return;
setProcessing(true);
setScanResult(null);
try {
const body: Record<string, string> = { eventId: id };
if (type === 'code') {
body.ticketCode = manualCode.trim().toUpperCase();
} else {
body.holderEmail = manualEmail.trim();
}
const { data } = await api.post('/ticketed-events/checkin/manual', body);
setScanResult({
type: 'success',
title: 'Checked In!',
subtitle: `${data.ticket.ticketCode}${data.ticket.holderName || data.ticket.holderEmail}`,
ticket: data.ticket,
});
setManualCode('');
setManualEmail('');
fetchStats();
} catch (err: unknown) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message || 'Check-in failed';
setScanResult({ type: 'error', title: msg, subtitle: '' });
} finally {
setProcessing(false);
}
};
const resultColors: Record<string, string> = {
success: '#52c41a',
warning: '#faad14',
error: '#ff4d4f',
};
return (
<div style={{
minHeight: '100vh',
background: '#0d1117',
color: '#fff',
padding: '16px',
}}>
<div style={{ maxWidth: 600, margin: '0 auto' }}>
<Space style={{ marginBottom: 16 }}>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate(`/app/events/${id}`)}>Back</Button>
<Title level={4} style={{ margin: 0, color: '#fff' }}>
{(event as Record<string, unknown>)?.title as string || 'Check-in Scanner'}
</Title>
</Space>
{/* Live stats */}
{stats && (
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={8}>
<Card size="small" style={{ background: '#161b22', border: '1px solid #30363d' }}>
<Statistic title={<span style={{ color: '#8b949e' }}>Checked In</span>} value={stats.checkedIn} valueStyle={{ color: '#58a6ff' }} />
</Card>
</Col>
<Col span={8}>
<Card size="small" style={{ background: '#161b22', border: '1px solid #30363d' }}>
<Statistic title={<span style={{ color: '#8b949e' }}>Remaining</span>} value={stats.remaining} valueStyle={{ color: '#f0883e' }} />
</Card>
</Col>
<Col span={8}>
<Card size="small" style={{ background: '#161b22', border: '1px solid #30363d' }}>
<Statistic title={<span style={{ color: '#8b949e' }}>Total</span>} value={stats.totalTickets} valueStyle={{ color: '#8b949e' }} />
</Card>
</Col>
</Row>
)}
{/* Scan result */}
{scanResult && (
<Card
style={{
marginBottom: 16,
background: '#161b22',
borderColor: resultColors[scanResult.type],
borderWidth: 2,
}}
>
<Result
icon={
scanResult.type === 'success' ? <CheckCircleOutlined style={{ color: '#52c41a' }} /> :
scanResult.type === 'warning' ? <WarningOutlined style={{ color: '#faad14' }} /> :
<CloseCircleOutlined style={{ color: '#ff4d4f' }} />
}
title={<span style={{ color: resultColors[scanResult.type] }}>{scanResult.title}</span>}
subTitle={<span style={{ color: '#8b949e' }}>{scanResult.subtitle}</span>}
/>
</Card>
)}
{/* Mode toggle */}
<Space style={{ marginBottom: 16 }}>
<Button
type={mode === 'camera' ? 'primary' : 'default'}
icon={<CameraOutlined />}
onClick={() => setMode('camera')}
>
Camera
</Button>
<Button
type={mode === 'manual' ? 'primary' : 'default'}
icon={<KeyOutlined />}
onClick={() => setMode('manual')}
>
Manual
</Button>
</Space>
{mode === 'camera' && (
<Card style={{ background: '#161b22', border: '1px solid #30363d', marginBottom: 16 }}>
<div id="qr-reader" ref={scannerContainerRef} style={{ width: '100%' }} />
{processing && <div style={{ textAlign: 'center', marginTop: 16 }}><Spin tip="Processing..." /></div>}
</Card>
)}
{mode === 'manual' && (
<Card style={{ background: '#161b22', border: '1px solid #30363d' }}>
<Title level={5} style={{ color: '#c9d1d9' }}>Enter Ticket Code</Title>
<Space.Compact style={{ width: '100%', marginBottom: 16 }}>
<Input
placeholder="ABCD-1234"
value={manualCode}
onChange={e => setManualCode(e.target.value)}
onPressEnter={() => manualCode && handleManualCheckin('code')}
style={{ textTransform: 'uppercase' }}
/>
<Button
type="primary"
onClick={() => handleManualCheckin('code')}
loading={processing}
disabled={!manualCode.trim()}
>
Check In
</Button>
</Space.Compact>
<Divider style={{ borderColor: '#30363d' }}>
<Text style={{ color: '#8b949e' }}>or</Text>
</Divider>
<Title level={5} style={{ color: '#c9d1d9' }}>Look up by Email</Title>
<Space.Compact style={{ width: '100%' }}>
<Input
placeholder="attendee@example.com"
value={manualEmail}
onChange={e => setManualEmail(e.target.value)}
onPressEnter={() => manualEmail && handleManualCheckin('email')}
/>
<Button
type="primary"
onClick={() => handleManualCheckin('email')}
loading={processing}
disabled={!manualEmail.trim()}
>
Check In
</Button>
</Space.Compact>
</Card>
)}
</div>
</div>
);
}

View File

@ -1,295 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import {
Typography, Card, Row, Col, Statistic, Table, Tag, Button, Space, message,
Tabs, Input, Popconfirm, Descriptions, Badge,
} from 'antd';
import {
ArrowLeftOutlined, CheckCircleOutlined, UserOutlined, DollarOutlined,
MailOutlined, ScanOutlined, CopyOutlined, TagOutlined, VideoCameraOutlined,
EnvironmentOutlined, LinkOutlined,
} from '@ant-design/icons';
import { api } from '@/lib/api';
import { useParams, useNavigate } from 'react-router-dom';
import dayjs from 'dayjs';
const { Title, Text } = Typography;
export default function EventDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [event, setEvent] = useState<Record<string, unknown> | null>(null);
const [stats, setStats] = useState<Record<string, unknown> | null>(null);
const [tickets, setTickets] = useState<Record<string, unknown>[]>([]);
const [ticketPagination, setTicketPagination] = useState({ page: 1, limit: 20, total: 0 });
const [checkIns, setCheckIns] = useState<Record<string, unknown>[]>([]);
const [ticketSearch, setTicketSearch] = useState('');
const [loading, setLoading] = useState(false);
const [joiningMeeting, setJoiningMeeting] = useState(false);
const fetchEvent = useCallback(async () => {
if (!id) return;
try {
const [eventRes, statsRes] = await Promise.all([
api.get(`/ticketed-events/admin/${id}`),
api.get(`/ticketed-events/admin/${id}/stats`),
]);
setEvent(eventRes.data);
setStats(statsRes.data);
} catch {
message.error('Failed to load event');
}
}, [id]);
const fetchTickets = useCallback(async () => {
if (!id) return;
setLoading(true);
try {
const params: Record<string, string | number> = {
page: ticketPagination.page,
limit: ticketPagination.limit,
};
if (ticketSearch) params.search = ticketSearch;
const { data } = await api.get(`/ticketed-events/admin/${id}/tickets`, { params });
setTickets(data.tickets);
setTicketPagination(p => ({ ...p, total: data.pagination.total }));
} catch {
message.error('Failed to load tickets');
} finally {
setLoading(false);
}
}, [id, ticketPagination.page, ticketPagination.limit, ticketSearch]);
const fetchCheckIns = useCallback(async () => {
if (!id) return;
try {
const { data } = await api.get(`/ticketed-events/admin/${id}/checkins`, { params: { limit: 50 } });
setCheckIns(data.checkIns);
} catch { /* silent */ }
}, [id]);
useEffect(() => { fetchEvent(); }, [fetchEvent]);
useEffect(() => { fetchTickets(); }, [fetchTickets]);
useEffect(() => { fetchCheckIns(); }, [fetchCheckIns]);
const handleResend = async (ticketId: string) => {
try {
await api.post(`/ticketed-events/admin/${id}/resend-ticket/${ticketId}`);
message.success('Ticket email resent');
} catch {
message.error('Failed to resend');
}
};
const handleCancelTicket = async (ticketId: string) => {
try {
await api.post(`/ticketed-events/admin/${id}/tickets/${ticketId}/cancel`);
message.success('Ticket cancelled');
fetchTickets();
fetchEvent();
} catch (err: unknown) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message || 'Failed to cancel';
message.error(msg);
}
};
const handleJoinAsModerator = async () => {
setJoiningMeeting(true);
try {
const { data } = await api.post(`/ticketed-events/admin/${id}/meeting-token`);
window.open(data.jitsiUrl, '_blank');
} catch {
message.error('Failed to generate meeting token');
} finally {
setJoiningMeeting(false);
}
};
if (!event) return null;
const e = event as Record<string, unknown>;
const s = stats as Record<string, unknown> | null;
const ticketColumns = [
{ title: 'Code', dataIndex: 'ticketCode', key: 'code', render: (v: string) => <Text code>{v}</Text> },
{ title: 'Holder', dataIndex: 'holderName', key: 'holder', render: (v: string, r: Record<string, unknown>) => v || (r.holderEmail as string) },
{ title: 'Email', dataIndex: 'holderEmail', key: 'email', responsive: ['lg' as const] },
{ title: 'Tier', key: 'tier', render: (_: unknown, r: Record<string, unknown>) => String((r.tier as Record<string, unknown>)?.name || '—') },
{
title: 'Status', dataIndex: 'status', key: 'status',
render: (s: string) => {
const colors: Record<string, string> = { VALID: 'green', CHECKED_IN: 'blue', CANCELLED: 'red', REFUNDED: 'orange' };
return <Tag color={colors[s]}>{s}</Tag>;
},
},
{ title: 'Issued', dataIndex: 'issuedAt', key: 'issued', render: (d: string) => dayjs(d).format('MMM D, HH:mm'), responsive: ['md' as const] },
{
title: 'Actions', key: 'actions',
render: (_: unknown, record: Record<string, unknown>) => (
<Space size="small">
<Button size="small" icon={<MailOutlined />} onClick={() => handleResend(record.id as string)}>Resend</Button>
{record.status === 'VALID' && (
<Popconfirm title="Cancel this ticket?" onConfirm={() => handleCancelTicket(record.id as string)}>
<Button size="small" danger>Cancel</Button>
</Popconfirm>
)}
</Space>
),
},
];
const checkInColumns = [
{ title: 'Ticket', key: 'ticket', render: (_: unknown, r: Record<string, unknown>) => {
const t = r.ticket as Record<string, unknown>;
return `${t?.ticketCode}${t?.holderName || t?.holderEmail}`;
}},
{ title: 'Method', dataIndex: 'method', key: 'method' },
{ title: 'By', key: 'by', render: (_: unknown, r: Record<string, unknown>) => (r.checkedInBy as Record<string, unknown>)?.name || 'System' },
{ title: 'Time', dataIndex: 'checkedInAt', key: 'time', render: (d: string) => dayjs(d).format('MMM D, HH:mm:ss') },
];
return (
<div>
<Space style={{ marginBottom: 16 }}>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/app/events')}>Back</Button>
<Title level={3} style={{ margin: 0 }}>{e.title as string}</Title>
<Tag color={
e.status === 'PUBLISHED' ? 'success' : e.status === 'DRAFT' ? 'default' : e.status === 'CANCELLED' ? 'error' : 'warning'
}>{(e.status as string).replace('_', ' ')}</Tag>
</Space>
<Descriptions bordered size="small" style={{ marginBottom: 24 }}>
<Descriptions.Item label="Date">{dayjs(e.date as string).format('MMMM D, YYYY')}</Descriptions.Item>
<Descriptions.Item label="Time">{e.startTime as string} {e.endTime as string}</Descriptions.Item>
<Descriptions.Item label="Format">{
e.eventFormat === 'ONLINE' ? <Tag icon={<VideoCameraOutlined />} color="purple">Online</Tag>
: e.eventFormat === 'HYBRID' ? <Tag color="geekblue"><EnvironmentOutlined /> + <VideoCameraOutlined /> Hybrid</Tag>
: <Tag icon={<EnvironmentOutlined />}>In-Person</Tag>
}</Descriptions.Item>
<Descriptions.Item label="Venue">{(e.venueName as string) || '—'}</Descriptions.Item>
<Descriptions.Item label="Visibility"><Tag>{e.visibility as string}</Tag></Descriptions.Item>
<Descriptions.Item label="Slug">{e.slug as string}</Descriptions.Item>
<Descriptions.Item label="Actions">
<Space>
<Button size="small" icon={<CopyOutlined />} onClick={() => {
navigator.clipboard.writeText(`${window.location.origin}/event/${e.slug}`);
message.success('Link copied');
}}>Copy Link</Button>
{e.status === 'PUBLISHED' && (
<Button size="small" type="primary" icon={<ScanOutlined />}
onClick={() => navigate(`/app/events/${id}/checkin`)}>
Check-in Scanner
</Button>
)}
</Space>
</Descriptions.Item>
</Descriptions>
{/* Meeting Room card for ONLINE/HYBRID events */}
{(e.eventFormat === 'ONLINE' || e.eventFormat === 'HYBRID') && !!e.meeting && (
<Card size="small" title={<><VideoCameraOutlined /> Meeting Room</>} style={{ marginBottom: 24 }}>
<Space direction="vertical" style={{ width: '100%' }}>
<Space>
<Badge status={(e.meeting as Record<string, unknown>).isActive ? 'success' : 'default'}
text={(e.meeting as Record<string, unknown>).isActive ? 'Active' : 'Inactive'} />
</Space>
<Space wrap>
<Button type="primary" icon={<VideoCameraOutlined />}
loading={joiningMeeting} onClick={handleJoinAsModerator}>
Join as Moderator
</Button>
<Button icon={<LinkOutlined />} onClick={() => {
navigator.clipboard.writeText(`${window.location.origin}/event/${e.slug}#meeting`);
message.success('Guest link copied');
}}>
Copy Guest Link
</Button>
</Space>
</Space>
</Card>
)}
{s && (
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col xs={12} sm={6}>
<Card size="small"><Statistic title="Tickets Sold" value={s.totalTickets as number} prefix={<TagOutlined />} /></Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small"><Statistic title="Revenue" value={(s.totalRevenue as number) / 100} prefix={<DollarOutlined />} precision={2} /></Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small"><Statistic title="Checked In" value={s.checkedIn as number} prefix={<CheckCircleOutlined />} /></Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small"><Statistic title="Capacity" value={e.maxAttendees ? `${e.currentAttendees}/${e.maxAttendees}` : `${e.currentAttendees}`} prefix={<UserOutlined />} /></Card>
</Col>
</Row>
)}
{s && (s.tierStats as unknown[])?.length > 0 && (
<Card size="small" title="Tier Breakdown" style={{ marginBottom: 24 }}>
<Table
dataSource={s.tierStats as Record<string, unknown>[]}
rowKey="id"
pagination={false}
size="small"
columns={[
{ title: 'Tier', dataIndex: 'name', key: 'name' },
{ title: 'Type', dataIndex: 'tierType', key: 'type', render: (v: string) => <Tag>{v}</Tag> },
{ title: 'Price', dataIndex: 'priceCAD', key: 'price', render: (v: number) => v > 0 ? `$${(v / 100).toFixed(2)}` : 'Free' },
{ title: 'Sold', key: 'sold', render: (_: unknown, r: Record<string, unknown>) => {
const max = r.maxQuantity as number | null;
return max ? `${r.soldCount}/${max}` : String(r.soldCount);
}},
{ title: 'Active', dataIndex: 'isActive', key: 'active', render: (v: boolean) => v ? <Badge status="success" text="Yes" /> : <Badge status="default" text="No" /> },
]}
/>
</Card>
)}
<Tabs items={[
{
key: 'tickets',
label: `Tickets (${ticketPagination.total})`,
children: (
<>
<Input
placeholder="Search by name, email, or code..."
prefix={<UserOutlined />}
value={ticketSearch}
onChange={e => setTicketSearch(e.target.value)}
style={{ width: 300, marginBottom: 16 }}
allowClear
/>
<Table
dataSource={tickets}
columns={ticketColumns}
rowKey="id"
loading={loading}
size="small"
pagination={{
current: ticketPagination.page,
pageSize: ticketPagination.limit,
total: ticketPagination.total,
onChange: (page, pageSize) => setTicketPagination(p => ({ ...p, page, limit: pageSize })),
}}
/>
</>
),
},
{
key: 'checkins',
label: `Check-ins (${checkIns.length})`,
children: (
<Table
dataSource={checkIns}
columns={checkInColumns}
rowKey="id"
size="small"
pagination={{ pageSize: 20 }}
/>
),
},
]} />
</div>
);
}

View File

@ -1,513 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import {
Typography, Table, Button, Space, Tag, Input, Select, message, Drawer, Form,
DatePicker, TimePicker, InputNumber, Divider, Card, Row, Col, Popconfirm,
Tooltip, Radio, Alert,
} from 'antd';
import {
PlusOutlined, SearchOutlined, EditOutlined, EyeOutlined, DeleteOutlined,
CheckCircleOutlined, CloseCircleOutlined, CopyOutlined, ScanOutlined,
TagOutlined, VideoCameraOutlined, EnvironmentOutlined,
} from '@ant-design/icons';
import { api } from '@/lib/api';
import dayjs from 'dayjs';
import { useNavigate } from 'react-router-dom';
const { Title } = Typography;
interface TicketTier {
id: string;
name: string;
tierType: 'PAID' | 'FREE' | 'DONATION';
priceCAD: number;
maxQuantity: number | null;
soldCount: number;
maxPerOrder: number;
sortOrder: number;
isActive: boolean;
}
interface TicketedEvent {
id: string;
slug: string;
title: string;
description: string | null;
date: string;
startTime: string;
endTime: string;
doorsOpenTime: string | null;
eventFormat: 'IN_PERSON' | 'ONLINE' | 'HYBRID';
venueName: string | null;
venueAddress: string | null;
status: string;
visibility: string;
maxAttendees: number | null;
currentAttendees: number;
coverImageUrl: string | null;
organizerName: string | null;
ticketTiers: TicketTier[];
_count: { tickets: number; checkIns: number };
createdAt: string;
}
const STATUS_COLORS: Record<string, string> = {
DRAFT: 'default',
PENDING_APPROVAL: 'warning',
PUBLISHED: 'success',
CANCELLED: 'error',
COMPLETED: 'blue',
};
const VISIBILITY_COLORS: Record<string, string> = {
PUBLIC: 'green',
UNLISTED: 'orange',
PRIVATE: 'red',
};
export default function TicketedEventsPage() {
const navigate = useNavigate();
const [events, setEvents] = useState<TicketedEvent[]>([]);
const [loading, setLoading] = useState(false);
const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0 });
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<string | undefined>();
const [drawerOpen, setDrawerOpen] = useState(false);
const [editingEvent, setEditingEvent] = useState<TicketedEvent | null>(null);
const [form] = Form.useForm();
const [saving, setSaving] = useState(false);
const [enableMeet, setEnableMeet] = useState(false);
const watchedFormat = Form.useWatch('eventFormat', form);
const fetchEvents = useCallback(async () => {
setLoading(true);
try {
const params: Record<string, string | number> = {
page: pagination.page,
limit: pagination.limit,
};
if (search) params.search = search;
if (statusFilter) params.status = statusFilter;
const { data } = await api.get('/ticketed-events/admin', { params });
setEvents(data.events);
setPagination(p => ({ ...p, total: data.pagination.total }));
} catch {
message.error('Failed to load events');
} finally {
setLoading(false);
}
}, [pagination.page, pagination.limit, search, statusFilter]);
useEffect(() => { fetchEvents(); }, [fetchEvents]);
useEffect(() => {
api.get('/settings').then(({ data }) => setEnableMeet(!!data.enableMeet)).catch(() => {});
}, []);
const handleCreate = () => {
setEditingEvent(null);
form.resetFields();
form.setFieldsValue({
eventFormat: 'IN_PERSON',
visibility: 'PUBLIC',
tiers: [{ name: 'General Admission', tierType: 'FREE', priceCAD: 0, maxPerOrder: 10, sortOrder: 0 }],
});
setDrawerOpen(true);
};
const handleEdit = (event: TicketedEvent) => {
setEditingEvent(event);
form.setFieldsValue({
...event,
date: dayjs(event.date),
startTime: dayjs(event.startTime, 'HH:mm'),
endTime: dayjs(event.endTime, 'HH:mm'),
doorsOpenTime: event.doorsOpenTime ? dayjs(event.doorsOpenTime, 'HH:mm') : undefined,
});
setDrawerOpen(true);
};
const handleSave = async () => {
try {
const values = await form.validateFields();
setSaving(true);
const payload = {
...values,
date: values.date.format('YYYY-MM-DD'),
startTime: values.startTime.format('HH:mm'),
endTime: values.endTime.format('HH:mm'),
doorsOpenTime: values.doorsOpenTime?.format('HH:mm') || undefined,
};
if (editingEvent) {
const { tiers, ...eventData } = payload;
await api.put(`/api/ticketed-events/admin/${editingEvent.id}`, eventData);
} else {
await api.post('/ticketed-events/admin', payload);
}
message.success(editingEvent ? 'Event updated' : 'Event created');
setDrawerOpen(false);
fetchEvents();
} catch {
message.error('Failed to save event');
} finally {
setSaving(false);
}
};
const handlePublish = async (id: string) => {
try {
await api.post(`/api/ticketed-events/admin/${id}/publish`);
message.success('Event published');
fetchEvents();
} catch (err: unknown) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message || 'Failed to publish';
message.error(msg);
}
};
const handleCancel = async (id: string) => {
try {
await api.post(`/api/ticketed-events/admin/${id}/cancel`);
message.success('Event cancelled');
fetchEvents();
} catch {
message.error('Failed to cancel event');
}
};
const handleDelete = async (id: string) => {
try {
await api.delete(`/api/ticketed-events/admin/${id}`);
message.success('Event deleted');
fetchEvents();
} catch (err: unknown) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message || 'Failed to delete';
message.error(msg);
}
};
const handleApprove = async (id: string) => {
try {
await api.post(`/api/ticketed-events/admin/${id}/approve`);
message.success('Event approved and published');
fetchEvents();
} catch {
message.error('Failed to approve event');
}
};
const copyLink = (slug: string) => {
navigator.clipboard.writeText(`${window.location.origin}/event/${slug}`);
message.success('Link copied');
};
const columns = [
{
title: 'Title',
dataIndex: 'title',
key: 'title',
render: (text: string, record: TicketedEvent) => (
<a onClick={() => navigate(`/app/events/${record.id}`)}>{text}</a>
),
},
{
title: 'Date',
dataIndex: 'date',
key: 'date',
render: (d: string, r: TicketedEvent) => (
<span>{dayjs(d).format('MMM D, YYYY')} {r.startTime}</span>
),
sorter: true,
},
{
title: 'Format',
dataIndex: 'eventFormat',
key: 'format',
responsive: ['md' as const],
render: (f: string) => {
if (f === 'ONLINE') return <Tag icon={<VideoCameraOutlined />} color="purple">Online</Tag>;
if (f === 'HYBRID') return <Tag color="geekblue"><EnvironmentOutlined /> + <VideoCameraOutlined /></Tag>;
return <Tag icon={<EnvironmentOutlined />}>In-Person</Tag>;
},
},
{
title: 'Venue',
dataIndex: 'venueName',
key: 'venue',
responsive: ['lg' as const],
},
{
title: 'Status',
dataIndex: 'status',
key: 'status',
render: (s: string) => <Tag color={STATUS_COLORS[s]}>{s.replace('_', ' ')}</Tag>,
},
{
title: 'Visibility',
dataIndex: 'visibility',
key: 'visibility',
responsive: ['md' as const],
render: (v: string) => <Tag color={VISIBILITY_COLORS[v]}>{v}</Tag>,
},
{
title: 'Tickets',
key: 'tickets',
render: (_: unknown, r: TicketedEvent) => {
const sold = r._count.tickets;
const max = r.maxAttendees;
return max ? `${sold} / ${max}` : `${sold}`;
},
},
{
title: 'Actions',
key: 'actions',
render: (_: unknown, record: TicketedEvent) => (
<Space size="small">
<Tooltip title="Edit"><Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)} /></Tooltip>
<Tooltip title="Detail"><Button size="small" icon={<EyeOutlined />} onClick={() => navigate(`/app/events/${record.id}`)} /></Tooltip>
{record.status === 'DRAFT' && (
<Tooltip title="Publish"><Button size="small" type="primary" icon={<CheckCircleOutlined />} onClick={() => handlePublish(record.id)} /></Tooltip>
)}
{record.status === 'PENDING_APPROVAL' && (
<Tooltip title="Approve"><Button size="small" type="primary" icon={<CheckCircleOutlined />} onClick={() => handleApprove(record.id)} /></Tooltip>
)}
{record.status === 'PUBLISHED' && (
<>
<Tooltip title="Copy Link"><Button size="small" icon={<CopyOutlined />} onClick={() => copyLink(record.slug)} /></Tooltip>
<Tooltip title="Check-in Scanner"><Button size="small" icon={<ScanOutlined />} onClick={() => navigate(`/app/events/${record.id}/checkin`)} /></Tooltip>
<Popconfirm title="Cancel this event?" onConfirm={() => handleCancel(record.id)}>
<Tooltip title="Cancel"><Button size="small" danger icon={<CloseCircleOutlined />} /></Tooltip>
</Popconfirm>
</>
)}
{record.status === 'DRAFT' && (
<Popconfirm title="Delete this draft?" onConfirm={() => handleDelete(record.id)}>
<Tooltip title="Delete"><Button size="small" danger icon={<DeleteOutlined />} /></Tooltip>
</Popconfirm>
)}
</Space>
),
},
];
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<Title level={3} style={{ margin: 0 }}>
<TagOutlined style={{ marginRight: 8 }} />
Ticketed Events
</Title>
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>
Create Event
</Button>
</div>
<Space style={{ marginBottom: 16 }} wrap>
<Input
placeholder="Search events..."
prefix={<SearchOutlined />}
value={search}
onChange={e => setSearch(e.target.value)}
style={{ width: 240 }}
allowClear
/>
<Select
placeholder="Filter by status"
value={statusFilter}
onChange={setStatusFilter}
allowClear
style={{ width: 180 }}
options={[
{ value: 'DRAFT', label: 'Draft' },
{ value: 'PENDING_APPROVAL', label: 'Pending Approval' },
{ value: 'PUBLISHED', label: 'Published' },
{ value: 'CANCELLED', label: 'Cancelled' },
{ value: 'COMPLETED', label: 'Completed' },
]}
/>
</Space>
<Table
dataSource={events}
columns={columns}
rowKey="id"
loading={loading}
pagination={{
current: pagination.page,
pageSize: pagination.limit,
total: pagination.total,
showSizeChanger: true,
onChange: (page, pageSize) => setPagination(p => ({ ...p, page, limit: pageSize })),
}}
/>
<Drawer
title={editingEvent ? 'Edit Event' : 'Create Event'}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
width={640}
extra={
<Button type="primary" onClick={handleSave} loading={saving}>
{editingEvent ? 'Update' : 'Create'}
</Button>
}
>
<Form form={form} layout="vertical">
<Form.Item name="title" label="Title" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="description" label="Description">
<Input.TextArea rows={3} />
</Form.Item>
<Row gutter={16}>
<Col span={8}>
<Form.Item name="date" label="Date" rules={[{ required: true }]}>
<DatePicker style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="startTime" label="Start Time" rules={[{ required: true }]}>
<TimePicker format="HH:mm" style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="endTime" label="End Time" rules={[{ required: true }]}>
<TimePicker format="HH:mm" style={{ width: '100%' }} />
</Form.Item>
</Col>
</Row>
<Form.Item name="doorsOpenTime" label="Doors Open Time">
<TimePicker format="HH:mm" style={{ width: '100%' }} />
</Form.Item>
<Divider>Event Format</Divider>
<Form.Item name="eventFormat" label="Format" initialValue="IN_PERSON">
<Radio.Group>
<Radio.Button value="IN_PERSON"><EnvironmentOutlined /> In-Person</Radio.Button>
<Tooltip title={!enableMeet ? 'Enable Jitsi Meet in Settings first' : undefined}>
<Radio.Button value="ONLINE" disabled={!enableMeet}><VideoCameraOutlined /> Online</Radio.Button>
</Tooltip>
<Tooltip title={!enableMeet ? 'Enable Jitsi Meet in Settings first' : undefined}>
<Radio.Button value="HYBRID" disabled={!enableMeet}><EnvironmentOutlined /> + <VideoCameraOutlined /> Hybrid</Radio.Button>
</Tooltip>
</Radio.Group>
</Form.Item>
{watchedFormat === 'ONLINE' && (
<Alert type="info" message="A meeting room will be auto-created when you save this event." showIcon style={{ marginBottom: 16 }} />
)}
{watchedFormat === 'HYBRID' && (
<Alert type="info" message="A meeting room will be auto-created alongside your venue." showIcon style={{ marginBottom: 16 }} />
)}
{watchedFormat !== 'ONLINE' && (
<>
<Divider>Venue</Divider>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="venueName" label="Venue Name">
<Input />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="venueAddress" label="Venue Address">
<Input />
</Form.Item>
</Col>
</Row>
</>
)}
<Divider>Settings</Divider>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="visibility" label="Visibility" initialValue="PUBLIC">
<Select options={[
{ value: 'PUBLIC', label: 'Public' },
{ value: 'UNLISTED', label: 'Unlisted' },
{ value: 'PRIVATE', label: 'Private (Invite Only)' },
]} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="maxAttendees" label="Max Attendees">
<InputNumber min={1} style={{ width: '100%' }} placeholder="Unlimited" />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="organizerName" label="Organizer Name">
<Input />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="organizerEmail" label="Organizer Email">
<Input type="email" />
</Form.Item>
</Col>
</Row>
<Form.Item name="coverImageUrl" label="Cover Image URL">
<Input placeholder="https://..." />
</Form.Item>
{!editingEvent && (
<>
<Divider>Ticket Tiers</Divider>
<Form.List name="tiers">
{(fields, { add, remove }) => (
<>
{fields.map(({ key, name, ...rest }) => (
<Card key={key} size="small" style={{ marginBottom: 12 }}
extra={fields.length > 1 && <Button size="small" danger onClick={() => remove(name)}>Remove</Button>}
>
<Row gutter={12}>
<Col span={12}>
<Form.Item {...rest} name={[name, 'name']} label="Tier Name" rules={[{ required: true }]}>
<Input placeholder="e.g. General Admission" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item {...rest} name={[name, 'tierType']} label="Type" rules={[{ required: true }]}>
<Select options={[
{ value: 'FREE', label: 'Free' },
{ value: 'PAID', label: 'Paid' },
{ value: 'DONATION', label: 'Donation' },
]} />
</Form.Item>
</Col>
</Row>
<Row gutter={12}>
<Col span={8}>
<Form.Item {...rest} name={[name, 'priceCAD']} label="Price (cents)">
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item {...rest} name={[name, 'maxQuantity']} label="Max Quantity">
<InputNumber min={1} style={{ width: '100%' }} placeholder="Unlimited" />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item {...rest} name={[name, 'maxPerOrder']} label="Max Per Order" initialValue={10}>
<InputNumber min={1} max={100} style={{ width: '100%' }} />
</Form.Item>
</Col>
</Row>
</Card>
))}
<Button type="dashed" onClick={() => add({ tierType: 'FREE', priceCAD: 0, maxPerOrder: 10, sortOrder: fields.length })} block icon={<PlusOutlined />}>
Add Tier
</Button>
</>
)}
</Form.List>
</>
)}
</Form>
</Drawer>
</div>
);
}

View File

@ -1,347 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import {
Table,
Button,
Modal,
Form,
Input,
Select,
Tag,
Space,
Tabs,
message,
Popconfirm,
InputNumber,
} from 'antd';
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
CheckCircleOutlined,
StopOutlined,
} from '@ant-design/icons';
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
import dayjs from 'dayjs';
import { api } from '@/lib/api';
const { TextArea } = Input;
interface Campaign {
id: string;
title: string;
}
interface ImpactStory {
id: string;
campaignId: string;
type: 'MILESTONE' | 'VICTORY' | 'RESPONSE' | 'CUSTOM';
status: 'DRAFT' | 'PUBLISHED' | 'ARCHIVED';
title: string;
body: string;
coverImageUrl: string | null;
milestoneValue: number | null;
milestoneMetric: string | null;
publishedAt: string | null;
createdAt: string;
campaign: { title: string };
createdBy: { name: string | null; email: string } | null;
}
const typeColors: Record<string, string> = {
MILESTONE: 'gold',
VICTORY: 'green',
RESPONSE: 'blue',
CUSTOM: 'default',
};
const statusColors: Record<string, string> = {
DRAFT: 'default',
PUBLISHED: 'green',
ARCHIVED: 'red',
};
export default function ImpactStoriesPage() {
const [stories, setStories] = useState<ImpactStory[]>([]);
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
const [loading, setLoading] = useState(false);
const [pagination, setPagination] = useState({ current: 1, pageSize: 20, total: 0 });
const [selectedCampaignId, setSelectedCampaignId] = useState<string | undefined>();
const [statusFilter, setStatusFilter] = useState<string | undefined>();
const [modalOpen, setModalOpen] = useState(false);
const [editingStory, setEditingStory] = useState<ImpactStory | null>(null);
const [form] = Form.useForm();
const fetchCampaigns = useCallback(async () => {
try {
const { data } = await api.get('/influence/campaigns', { params: { limit: 100 } });
setCampaigns(data.campaigns || []);
} catch {
// ignore
}
}, []);
const fetchStories = useCallback(async (page = 1, limit = 20) => {
setLoading(true);
try {
const params: Record<string, unknown> = { page, limit };
if (selectedCampaignId) params.campaignId = selectedCampaignId;
if (statusFilter) params.status = statusFilter;
const { data } = await api.get('/social/stories', { params });
setStories(data.stories || []);
setPagination({
current: data.pagination?.page || 1,
pageSize: data.pagination?.limit || 20,
total: data.pagination?.total || 0,
});
} catch {
message.error('Failed to load stories');
} finally {
setLoading(false);
}
}, [selectedCampaignId, statusFilter]);
useEffect(() => {
fetchCampaigns();
}, [fetchCampaigns]);
useEffect(() => {
fetchStories();
}, [fetchStories]);
const handleTableChange = (pag: TablePaginationConfig) => {
fetchStories(pag.current, pag.pageSize);
};
const openCreate = () => {
setEditingStory(null);
form.resetFields();
if (selectedCampaignId) {
form.setFieldsValue({ campaignId: selectedCampaignId });
}
setModalOpen(true);
};
const openEdit = (story: ImpactStory) => {
setEditingStory(story);
form.setFieldsValue({
campaignId: story.campaignId,
type: story.type,
title: story.title,
body: story.body,
coverImageUrl: story.coverImageUrl || undefined,
milestoneValue: story.milestoneValue,
milestoneMetric: story.milestoneMetric,
});
setModalOpen(true);
};
const handleSave = async () => {
try {
const values = await form.validateFields();
if (editingStory) {
await api.put(`/social/stories/${editingStory.id}`, values);
message.success('Story updated');
} else {
await api.post('/social/stories', values);
message.success('Story created');
}
setModalOpen(false);
fetchStories(pagination.current, pagination.pageSize);
} catch (err: any) {
if (err?.response?.data?.error?.message) {
message.error(err.response.data.error.message);
}
}
};
const handlePublish = async (id: string) => {
try {
await api.post(`/social/stories/${id}/publish`);
message.success('Story published and participants notified');
fetchStories(pagination.current, pagination.pageSize);
} catch {
message.error('Failed to publish story');
}
};
const handleArchive = async (id: string) => {
try {
await api.post(`/social/stories/${id}/archive`);
message.success('Story archived');
fetchStories(pagination.current, pagination.pageSize);
} catch {
message.error('Failed to archive story');
}
};
const handleDelete = async (id: string) => {
try {
await api.delete(`/social/stories/${id}`);
message.success('Story deleted');
fetchStories(pagination.current, pagination.pageSize);
} catch {
message.error('Failed to delete story');
}
};
const storyType = Form.useWatch('type', form);
const columns: ColumnsType<ImpactStory> = [
{
title: 'Title',
dataIndex: 'title',
ellipsis: true,
},
{
title: 'Campaign',
dataIndex: ['campaign', 'title'],
ellipsis: true,
},
{
title: 'Type',
dataIndex: 'type',
width: 120,
render: (type: string) => <Tag color={typeColors[type]}>{type}</Tag>,
},
{
title: 'Status',
dataIndex: 'status',
width: 110,
render: (status: string) => <Tag color={statusColors[status]}>{status}</Tag>,
},
{
title: 'Milestone',
dataIndex: 'milestoneValue',
width: 100,
render: (val: number | null) => val ? <Tag color="gold">{val}</Tag> : '-',
},
{
title: 'Published',
dataIndex: 'publishedAt',
width: 140,
render: (date: string | null) => date ? dayjs(date).format('MMM D, YYYY') : '-',
},
{
title: 'Actions',
width: 200,
render: (_: unknown, record: ImpactStory) => (
<Space size="small">
<Button size="small" icon={<EditOutlined />} onClick={() => openEdit(record)} />
{record.status === 'DRAFT' && (
<Popconfirm
title="Publish this story?"
description="This will notify campaign participants."
onConfirm={() => handlePublish(record.id)}
>
<Button size="small" type="primary" icon={<CheckCircleOutlined />} />
</Popconfirm>
)}
{record.status === 'PUBLISHED' && (
<Popconfirm title="Archive this story?" onConfirm={() => handleArchive(record.id)}>
<Button size="small" icon={<StopOutlined />} />
</Popconfirm>
)}
<Popconfirm title="Delete this story?" onConfirm={() => handleDelete(record.id)}>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
},
];
const tabItems = [
{ key: 'all', label: 'All' },
{ key: 'DRAFT', label: 'Draft' },
{ key: 'PUBLISHED', label: 'Published' },
{ key: 'ARCHIVED', label: 'Archived' },
];
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
<Space wrap>
<Select
placeholder="Filter by campaign"
allowClear
style={{ width: 260 }}
value={selectedCampaignId}
onChange={(val) => setSelectedCampaignId(val)}
options={campaigns.map((c) => ({ value: c.id, label: c.title }))}
/>
</Space>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
New Story
</Button>
</div>
<Tabs
activeKey={statusFilter || 'all'}
onChange={(key) => setStatusFilter(key === 'all' ? undefined : key)}
items={tabItems}
/>
<Table
rowKey="id"
columns={columns}
dataSource={stories}
loading={loading}
pagination={{
current: pagination.current,
pageSize: pagination.pageSize,
total: pagination.total,
showSizeChanger: true,
showTotal: (total) => `${total} stories`,
}}
onChange={handleTableChange}
size="small"
/>
<Modal
title={editingStory ? 'Edit Story' : 'Create Story'}
open={modalOpen}
onCancel={() => setModalOpen(false)}
onOk={handleSave}
width={640}
destroyOnHidden
>
<Form form={form} layout="vertical">
<Form.Item name="campaignId" label="Campaign" rules={[{ required: true }]}>
<Select
placeholder="Select campaign"
options={campaigns.map((c) => ({ value: c.id, label: c.title }))}
/>
</Form.Item>
<Form.Item name="type" label="Type" rules={[{ required: true }]}>
<Select
options={[
{ value: 'MILESTONE', label: 'Milestone' },
{ value: 'VICTORY', label: 'Victory' },
{ value: 'RESPONSE', label: 'Response' },
{ value: 'CUSTOM', label: 'Custom' },
]}
/>
</Form.Item>
<Form.Item name="title" label="Title" rules={[{ required: true, max: 200 }]}>
<Input />
</Form.Item>
<Form.Item name="body" label="Body" rules={[{ required: true, max: 5000 }]}>
<TextArea rows={5} />
</Form.Item>
<Form.Item name="coverImageUrl" label="Cover Image URL">
<Input placeholder="https://..." />
</Form.Item>
{storyType === 'MILESTONE' && (
<>
<Form.Item name="milestoneValue" label="Milestone Value">
<InputNumber min={1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="milestoneMetric" label="Milestone Metric">
<Input placeholder="e.g. emails_sent" />
</Form.Item>
</>
)}
</Form>
</Modal>
</div>
);
}

View File

@ -45,7 +45,6 @@ import { usePostalCode } from '@/hooks/usePostalCode';
import RelatedContent from '@/components/public/RelatedContent'; import RelatedContent from '@/components/public/RelatedContent';
import { VideoPlayer } from '@/components/media/VideoPlayer'; import { VideoPlayer } from '@/components/media/VideoPlayer';
import FriendsCampaignBadge from '@/components/social/FriendsCampaignBadge'; import FriendsCampaignBadge from '@/components/social/FriendsCampaignBadge';
import CampaignCelebration from '@/components/social/CampaignCelebration';
const { Title, Text, Paragraph } = Typography; const { Title, Text, Paragraph } = Typography;
@ -284,9 +283,6 @@ export default function CampaignPage() {
{campaign?.id && <FriendsCampaignBadge campaignId={campaign.id} />} {campaign?.id && <FriendsCampaignBadge campaignId={campaign.id} />}
</div> </div>
{/* Campaign Milestones / Impact Stories */}
{campaign?.id && <CampaignCelebration campaignId={campaign.id} />}
{/* Cover Video */} {/* Cover Video */}
{campaign.coverVideoId && siteSettings?.enableMediaFeatures !== false && ( {campaign.coverVideoId && siteSettings?.enableMediaFeatures !== false && (
<div style={{ marginBottom: 24, borderRadius: 12, overflow: 'hidden' }}> <div style={{ marginBottom: 24, borderRadius: 12, overflow: 'hidden' }}>

View File

@ -88,10 +88,6 @@ export default function EventsPage() {
<div style={{ width: 12, height: 12, borderRadius: 2, background: '#52c41a' }} /> <div style={{ width: 12, height: 12, borderRadius: 2, background: '#52c41a' }} />
<Text style={{ color: 'rgba(255,255,255,0.65)', fontSize: 13 }}>Events</Text> <Text style={{ color: 'rgba(255,255,255,0.65)', fontSize: 13 }}>Events</Text>
</Space> </Space>
<Space size={4}>
<div style={{ width: 12, height: 12, borderRadius: 2, background: '#722ed1' }} />
<Text style={{ color: 'rgba(255,255,255,0.65)', fontSize: 13 }}>Ticketed</Text>
</Space>
</Space> </Space>
{/* Submit button — opens side panel with form for tomorrow */} {/* Submit button — opens side panel with form for tomorrow */}

View File

@ -141,9 +141,11 @@ export default function HomePage() {
<Button type="primary" size="large" icon={<SendOutlined />}>Browse Campaigns</Button> <Button type="primary" size="large" icon={<SendOutlined />}>Browse Campaigns</Button>
</Link> </Link>
)} )}
<Link to="/volunteer"> {data.enabledModules.map && (
<Link to="/shifts">
<Button size="large" icon={<ScheduleOutlined />}>Volunteer</Button> <Button size="large" icon={<ScheduleOutlined />}>Volunteer</Button>
</Link> </Link>
)}
</Space> </Space>
</div> </div>

View File

@ -1,199 +0,0 @@
import { useState, useEffect } from 'react';
import {
Typography, Card, Button, Spin, Result, Space, Tag, Grid, Divider,
} from 'antd';
import {
CalendarOutlined, ClockCircleOutlined, EnvironmentOutlined,
QrcodeOutlined, CheckCircleOutlined,
} from '@ant-design/icons';
import axios from 'axios';
import dayjs from 'dayjs';
import { useParams, useNavigate } from 'react-router-dom';
const { Title, Text } = Typography;
const apiBase = '/api';
interface TicketDetail {
id: string;
ticketCode: string;
holderEmail: string;
holderName: string | null;
status: string;
tier: { name: string; tierType: string; priceCAD: number };
event: {
title: string;
slug: string;
date: string;
startTime: string;
endTime: string;
doorsOpenTime: string | null;
venueName: string | null;
venueAddress: string | null;
};
qrUrl: string;
}
export default function TicketConfirmationPage() {
const { slug, ticketCode } = useParams<{ slug: string; ticketCode: string }>();
const navigate = useNavigate();
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const [ticket, setTicket] = useState<TicketDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!slug || !ticketCode) return;
fetchTicket();
}, [slug, ticketCode]); // eslint-disable-line react-hooks/exhaustive-deps
const fetchTicket = async () => {
setLoading(true);
try {
const { data } = await axios.get(`${apiBase}/ticketed-events/${slug}/ticket/${ticketCode}`);
setTicket(data);
} catch (err: unknown) {
const status = (err as { response?: { status?: number } })?.response?.status;
setError(status === 404 ? 'Ticket not found' : 'Failed to load ticket');
} finally {
setLoading(false);
}
};
const generateIcsUrl = () => {
if (!ticket) return '';
const e = ticket.event;
const dateStr = dayjs(e.date).format('YYYYMMDD');
const start = `${dateStr}T${e.startTime.replace(':', '')}00`;
const end = `${dateStr}T${e.endTime.replace(':', '')}00`;
const ics = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'BEGIN:VEVENT',
`DTSTART:${start}`,
`DTEND:${end}`,
`SUMMARY:${e.title}`,
e.venueName ? `LOCATION:${e.venueName}${e.venueAddress ? ' - ' + e.venueAddress : ''}` : '',
`DESCRIPTION:Ticket: ${ticket.ticketCode}`,
'END:VEVENT',
'END:VCALENDAR',
].filter(Boolean).join('\r\n');
return `data:text/calendar;charset=utf-8,${encodeURIComponent(ics)}`;
};
if (loading) {
return (
<div style={{ textAlign: 'center', padding: 80 }}>
<Spin size="large" />
</div>
);
}
if (error || !ticket) {
return (
<Result
status="404"
title={error || 'Ticket Not Found'}
extra={<Button onClick={() => navigate(`/event/${slug}`)}>View Event</Button>}
/>
);
}
const statusColors: Record<string, string> = {
VALID: 'green',
CHECKED_IN: 'blue',
CANCELLED: 'red',
REFUNDED: 'orange',
};
return (
<div style={{
maxWidth: 500,
margin: '0 auto',
padding: isMobile ? '16px 8px' : '24px 16px',
}}>
<Card style={{ textAlign: 'center' }}>
<CheckCircleOutlined style={{ fontSize: 48, color: '#52c41a', marginBottom: 16 }} />
<Title level={3} style={{ marginBottom: 4 }}>Your Ticket</Title>
<Text type="secondary">{ticket.event.title}</Text>
<Divider />
{/* QR Code */}
<div style={{ marginBottom: 24 }}>
<img
src={ticket.qrUrl}
alt={`QR Code for ${ticket.ticketCode}`}
style={{ width: 250, height: 250 }}
/>
</div>
{/* Ticket code */}
<Title level={4} style={{ fontFamily: 'monospace', letterSpacing: 2, marginBottom: 4 }}>
{ticket.ticketCode}
</Title>
<Tag color={statusColors[ticket.status]}>{ticket.status}</Tag>
<Divider />
{/* Event details */}
<Space direction="vertical" style={{ width: '100%', textAlign: 'left' }}>
<div>
<CalendarOutlined style={{ marginRight: 8 }} />
<Text>{dayjs(ticket.event.date).format('MMMM D, YYYY')}</Text>
</div>
<div>
<ClockCircleOutlined style={{ marginRight: 8 }} />
<Text>
{ticket.event.startTime} {ticket.event.endTime}
{ticket.event.doorsOpenTime && ` (Doors: ${ticket.event.doorsOpenTime})`}
</Text>
</div>
{ticket.event.venueName && (
<div>
<EnvironmentOutlined style={{ marginRight: 8 }} />
<Text>{ticket.event.venueName}</Text>
{ticket.event.venueAddress && (
<Text type="secondary" style={{ display: 'block', marginLeft: 22 }}>
{ticket.event.venueAddress}
</Text>
)}
</div>
)}
<div>
<QrcodeOutlined style={{ marginRight: 8 }} />
<Text>{ticket.tier.name}</Text>
{ticket.tier.tierType === 'PAID' && (
<Text type="secondary"> ${(ticket.tier.priceCAD / 100).toFixed(2)}</Text>
)}
</div>
</Space>
<Divider />
{/* Holder info */}
<Text type="secondary" style={{ display: 'block', marginBottom: 16 }}>
{ticket.holderName || ticket.holderEmail}
</Text>
{/* Actions */}
<Space direction="vertical" style={{ width: '100%' }}>
<Button
type="primary"
block
href={generateIcsUrl()}
download={`${ticket.ticketCode}.ics`}
icon={<CalendarOutlined />}
>
Add to Calendar
</Button>
<Button block onClick={() => navigate(`/event/${ticket.event.slug}`)}>
View Event
</Button>
</Space>
</Card>
</div>
);
}

View File

@ -1,435 +0,0 @@
import { useState, useEffect } from 'react';
import {
Typography, Card, Button, Row, Col, Tag, Spin, Result, Grid, Space,
Input, message, Divider,
} from 'antd';
import {
CalendarOutlined, ClockCircleOutlined, EnvironmentOutlined,
TagOutlined, UserOutlined, LockOutlined, VideoCameraOutlined,
} from '@ant-design/icons';
import axios from 'axios';
import dayjs from 'dayjs';
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
import { useAuthStore } from '@/stores/auth.store';
const { Title, Text, Paragraph } = Typography;
const apiBase = '/api';
interface TicketTier {
id: string;
name: string;
description: string | null;
tierType: 'PAID' | 'FREE' | 'DONATION';
priceCAD: number;
minDonationCAD: number | null;
maxQuantity: number | null;
soldCount: number;
maxPerOrder: number;
isActive: boolean;
salesStartAt: string | null;
salesEndAt: string | null;
}
interface EventDetail {
id: string;
slug: string;
title: string;
description: string | null;
richDescription: string | null;
date: string;
startTime: string;
endTime: string;
doorsOpenTime: string | null;
eventFormat: 'IN_PERSON' | 'ONLINE' | 'HYBRID';
hasMeeting: boolean;
venueName: string | null;
venueAddress: string | null;
coverImageUrl: string | null;
maxAttendees: number | null;
currentAttendees: number;
organizerName: string | null;
visibility: string;
ticketTiers: TicketTier[];
}
export default function TicketedEventDetailPage() {
const { slug } = useParams<{ slug: string }>();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const { user, isAuthenticated } = useAuthStore();
const [event, setEvent] = useState<EventDetail | null>(null);
const [loading, setLoading] = useState(true);
const [inviteCode, setInviteCode] = useState(searchParams.get('code') || '');
const [needsInviteCode, setNeedsInviteCode] = useState(false);
const [checkingOut, setCheckingOut] = useState<string | null>(null);
// Guest info for non-authenticated users
const [guestEmail, setGuestEmail] = useState('');
const [guestName, setGuestName] = useState('');
// Meeting access
const [meetingTicketCode, setMeetingTicketCode] = useState('');
const [meetingAccess, setMeetingAccess] = useState<{ jitsiRoom: string; domain: string; eventTitle: string } | null>(null);
const [meetingLoading, setMeetingLoading] = useState(false);
const [meetingError, setMeetingError] = useState('');
useEffect(() => {
if (!slug) return;
fetchEvent();
}, [slug]); // eslint-disable-line react-hooks/exhaustive-deps
const fetchEvent = async () => {
setLoading(true);
try {
const params: Record<string, string> = {};
if (inviteCode) params.inviteCode = inviteCode;
const { data } = await axios.get(`${apiBase}/ticketed-events/${slug}`, { params });
if (data.requiresInviteCode) {
setNeedsInviteCode(true);
setEvent(null);
} else {
setEvent(data);
setNeedsInviteCode(false);
}
} catch (err: unknown) {
const status = (err as { response?: { status?: number } })?.response?.status;
if (status === 404) {
setEvent(null);
}
} finally {
setLoading(false);
}
};
const handleCheckout = async (tier: TicketTier) => {
const email = isAuthenticated ? user?.email : guestEmail.trim();
const name = isAuthenticated ? user?.name : guestName.trim();
if (!email) {
message.warning('Please enter your email address');
return;
}
setCheckingOut(tier.id);
try {
if (tier.tierType === 'FREE') {
await axios.post(`${apiBase}/ticketed-events/${slug}/register`, {
tierId: tier.id,
holderEmail: email,
holderName: name || undefined,
});
message.success('Registration successful! Check your email for your ticket.');
fetchEvent();
} else {
const { data } = await axios.post(`${apiBase}/ticketed-events/${slug}/checkout`, {
tierId: tier.id,
quantity: 1,
buyerEmail: email,
buyerName: name || undefined,
});
window.location.href = data.url;
}
} catch (err: unknown) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message || 'Failed to process';
message.error(msg);
} finally {
setCheckingOut(null);
}
};
const isTierAvailable = (tier: TicketTier) => {
if (!tier.isActive) return false;
if (tier.maxQuantity && tier.soldCount >= tier.maxQuantity) return false;
if (tier.salesStartAt && dayjs().isBefore(dayjs(tier.salesStartAt))) return false;
if (tier.salesEndAt && dayjs().isAfter(dayjs(tier.salesEndAt))) return false;
return true;
};
const isSoldOut = event?.maxAttendees
? event.currentAttendees >= event.maxAttendees
: false;
const handleGetMeetingLink = async () => {
if (!meetingTicketCode.trim()) {
setMeetingError('Please enter your ticket code');
return;
}
setMeetingLoading(true);
setMeetingError('');
setMeetingAccess(null);
try {
const { data } = await axios.get(`${apiBase}/ticketed-events/${slug}/meeting-access`, {
params: { ticketCode: meetingTicketCode.trim() },
});
setMeetingAccess(data);
} catch (err: unknown) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message || 'Invalid ticket code';
setMeetingError(msg);
} finally {
setMeetingLoading(false);
}
};
if (loading) {
return (
<div style={{ textAlign: 'center', padding: 80 }}>
<Spin size="large" />
</div>
);
}
if (needsInviteCode) {
return (
<div style={{ maxWidth: 400, margin: '0 auto', padding: isMobile ? '24px 16px' : 48 }}>
<Result
icon={<LockOutlined style={{ color: '#faad14' }} />}
title="Private Event"
subTitle="This event requires an invite code to view."
/>
<Space.Compact style={{ width: '100%' }}>
<Input
placeholder="Enter invite code"
value={inviteCode}
onChange={e => setInviteCode(e.target.value)}
onPressEnter={fetchEvent}
/>
<Button type="primary" onClick={fetchEvent}>Submit</Button>
</Space.Compact>
</div>
);
}
if (!event) {
return (
<Result
status="404"
title="Event Not Found"
subTitle="This event may have been removed or doesn't exist."
extra={<Button onClick={() => navigate('/events')}>Browse Events</Button>}
/>
);
}
return (
<div style={{ maxWidth: 800, margin: '0 auto', padding: isMobile ? '16px 8px' : '24px 16px' }}>
{/* Cover image */}
{event.coverImageUrl && (
<div style={{
marginBottom: 24,
borderRadius: 12,
overflow: 'hidden',
maxHeight: 400,
}}>
<img
src={event.coverImageUrl}
alt={event.title}
style={{ width: '100%', objectFit: 'cover' }}
/>
</div>
)}
{/* Title + date */}
<Title level={2} style={{ marginBottom: 8 }}>{event.title}</Title>
<Space wrap style={{ marginBottom: 24 }}>
<Tag icon={<CalendarOutlined />} color="blue">
{dayjs(event.date).format('MMMM D, YYYY')}
</Tag>
<Tag icon={<ClockCircleOutlined />}>
{event.startTime} {event.endTime}
{event.doorsOpenTime && ` (Doors: ${event.doorsOpenTime})`}
</Tag>
{event.eventFormat === 'ONLINE' ? (
<Tag icon={<VideoCameraOutlined />} color="purple">Online Event</Tag>
) : event.eventFormat === 'HYBRID' ? (
<>
{event.venueName && <Tag icon={<EnvironmentOutlined />} color="green">{event.venueName}</Tag>}
<Tag icon={<VideoCameraOutlined />} color="purple">Also Online</Tag>
</>
) : (
event.venueName && <Tag icon={<EnvironmentOutlined />} color="green">{event.venueName}</Tag>
)}
{event.maxAttendees && (
<Tag icon={<UserOutlined />}>
{event.currentAttendees}/{event.maxAttendees} attendees
</Tag>
)}
</Space>
{/* Description */}
{event.richDescription ? (
<Card style={{ marginBottom: 24 }}>
<div dangerouslySetInnerHTML={{ __html: event.richDescription }} />
</Card>
) : event.description ? (
<Card style={{ marginBottom: 24 }}>
<Paragraph>{event.description}</Paragraph>
</Card>
) : null}
{/* Venue details */}
{event.venueAddress && (
<Card size="small" style={{ marginBottom: 24 }}>
<Space>
<EnvironmentOutlined />
<div>
<Text strong>{event.venueName}</Text>
<br />
<Text type="secondary">{event.venueAddress}</Text>
</div>
</Space>
</Card>
)}
{/* Organizer */}
{event.organizerName && (
<Text type="secondary" style={{ display: 'block', marginBottom: 24 }}>
Organized by {event.organizerName}
</Text>
)}
<Divider />
{/* Ticket tiers */}
<Title level={4} style={{ marginBottom: 16 }}>
<TagOutlined /> Tickets
</Title>
{isSoldOut && (
<Result
status="warning"
title="Sold Out"
subTitle="This event has reached capacity."
style={{ marginBottom: 24 }}
/>
)}
{/* Guest info form for non-authenticated users */}
{!isAuthenticated && !isSoldOut && (
<Card size="small" style={{ marginBottom: 16 }}>
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
Enter your details to get tickets:
</Text>
<Row gutter={[12, 12]}>
<Col xs={24} sm={12}>
<Input
placeholder="Email *"
value={guestEmail}
onChange={e => setGuestEmail(e.target.value)}
/>
</Col>
<Col xs={24} sm={12}>
<Input
placeholder="Name (optional)"
value={guestName}
onChange={e => setGuestName(e.target.value)}
/>
</Col>
</Row>
</Card>
)}
<Row gutter={[16, 16]}>
{event.ticketTiers.map(tier => {
const available = isTierAvailable(tier) && !isSoldOut;
const remaining = tier.maxQuantity ? tier.maxQuantity - tier.soldCount : null;
return (
<Col xs={24} sm={12} key={tier.id}>
<Card
style={{
height: '100%',
opacity: available ? 1 : 0.6,
}}
>
<Title level={5} style={{ marginBottom: 4 }}>{tier.name}</Title>
{tier.description && (
<Paragraph type="secondary" style={{ fontSize: 13 }}>{tier.description}</Paragraph>
)}
<div style={{ marginBottom: 12 }}>
{tier.tierType === 'FREE' ? (
<Tag color="green">Free</Tag>
) : tier.tierType === 'DONATION' ? (
<Tag color="orange">
Donation {tier.minDonationCAD ? `(min $${(tier.minDonationCAD / 100).toFixed(2)})` : ''}
</Tag>
) : (
<Text strong style={{ fontSize: 18 }}>
${(tier.priceCAD / 100).toFixed(2)}
</Text>
)}
</div>
{remaining !== null && (
<Text type="secondary" style={{ display: 'block', marginBottom: 8, fontSize: 12 }}>
{remaining > 0 ? `${remaining} remaining` : 'Sold out'}
</Text>
)}
<Button
type="primary"
block
disabled={!available}
loading={checkingOut === tier.id}
onClick={() => handleCheckout(tier)}
>
{!available
? (remaining === 0 ? 'Sold Out' : 'Unavailable')
: tier.tierType === 'FREE'
? 'Register Free'
: `Get Tickets — $${(tier.priceCAD / 100).toFixed(2)}`
}
</Button>
</Card>
</Col>
);
})}
</Row>
{/* Online Access card for ONLINE/HYBRID events */}
{event.eventFormat !== 'IN_PERSON' && event.hasMeeting && (
<>
<Divider />
<Card
title={<><VideoCameraOutlined style={{ marginRight: 8 }} />Online Access</>}
style={{ marginBottom: 24 }}
>
<Paragraph type="secondary">
Enter your ticket code to get your meeting link.
</Paragraph>
<Space.Compact style={{ width: '100%', maxWidth: 400, marginBottom: 16 }}>
<Input
placeholder="Enter ticket code"
value={meetingTicketCode}
onChange={e => setMeetingTicketCode(e.target.value)}
onPressEnter={handleGetMeetingLink}
/>
<Button type="primary" onClick={handleGetMeetingLink} loading={meetingLoading}>
Get Link
</Button>
</Space.Compact>
{meetingError && (
<Text type="danger" style={{ display: 'block', marginBottom: 12 }}>{meetingError}</Text>
)}
{meetingAccess && (
<Button
type="primary"
size="large"
block
style={{ maxWidth: 400 }}
icon={<VideoCameraOutlined />}
onClick={() => window.open(`https://${meetingAccess.domain}/${meetingAccess.jitsiRoom}`, '_blank')}
>
Join Meeting
</Button>
)}
</Card>
</>
)}
</div>
);
}

View File

@ -1,247 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import {
Typography, Row, Col, Spin, Empty, Tabs, Pagination, Grid, message, theme,
} from 'antd';
import {
StarFilled, TrophyOutlined, EnvironmentOutlined, CalendarOutlined, MailOutlined,
} from '@ant-design/icons';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth.store';
import SpotlightCard from '@/components/social/SpotlightCard';
import PublicLeaderboard from '@/components/social/PublicLeaderboard';
const { Title, Text } = Typography;
interface FeaturedSpotlight {
id: string;
userId: string;
headline: string | null;
story: string | null;
featuredMonth: string | null;
user: { id: string; name: string | null };
}
export default function WallOfFamePage() {
const { token: themeToken } = theme.useToken();
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const { user } = useAuthStore();
const isLoggedIn = !!user;
const [featured, setFeatured] = useState<FeaturedSpotlight[]>([]);
const [wallOfFame, setWallOfFame] = useState<FeaturedSpotlight[]>([]);
const [wallPage, setWallPage] = useState(1);
const [wallTotal, setWallTotal] = useState(0);
const [loadingFeatured, setLoadingFeatured] = useState(true);
const [loadingWall, setLoadingWall] = useState(true);
const [optedIn, setOptedIn] = useState(true);
const [activeTab, setActiveTab] = useState('canvass');
const fetchFeatured = useCallback(async () => {
try {
setLoadingFeatured(true);
const { data } = await api.get('/social/spotlight/featured');
setFeatured(data.spotlights || []);
} catch {
// Silently handle auth errors for unauthenticated users
} finally {
setLoadingFeatured(false);
}
}, []);
const fetchWallOfFame = useCallback(async (page: number) => {
try {
setLoadingWall(true);
const { data } = await api.get('/social/spotlight/wall-of-fame', {
params: { page, limit: 12 },
});
setWallOfFame(data.spotlights || []);
setWallTotal(data.pagination?.total || 0);
} catch {
// Silently handle
} finally {
setLoadingWall(false);
}
}, []);
const fetchOptInStatus = useCallback(async () => {
if (!isLoggedIn) return;
try {
const { data } = await api.get('/social/spotlight/opt-in-status');
setOptedIn(data.showOnLeaderboard ?? true);
} catch {
// Ignore
}
}, [isLoggedIn]);
useEffect(() => {
fetchFeatured();
fetchWallOfFame(1);
fetchOptInStatus();
}, [fetchFeatured, fetchWallOfFame, fetchOptInStatus]);
const handleOptInChange = async (value: boolean) => {
try {
await api.post(value ? '/social/spotlight/opt-in' : '/social/spotlight/opt-out');
setOptedIn(value);
message.success(value ? 'You will appear on the leaderboard' : 'You have been removed from the leaderboard');
} catch {
message.error('Failed to update preference');
}
};
const handleWallPageChange = (page: number) => {
setWallPage(page);
fetchWallOfFame(page);
};
const leaderboardTabs = [
{
key: 'canvass',
label: (
<span><EnvironmentOutlined style={{ marginRight: 4 }} />Canvass</span>
),
children: (
<PublicLeaderboard
type="canvass"
showOptIn={isLoggedIn}
optedIn={optedIn}
onOptInChange={handleOptInChange}
/>
),
},
{
key: 'shifts',
label: (
<span><CalendarOutlined style={{ marginRight: 4 }} />Shifts</span>
),
children: (
<PublicLeaderboard
type="shifts"
showOptIn={isLoggedIn}
optedIn={optedIn}
onOptInChange={handleOptInChange}
/>
),
},
{
key: 'campaigns',
label: (
<span><MailOutlined style={{ marginRight: 4 }} />Campaigns</span>
),
children: (
<PublicLeaderboard
type="campaigns"
showOptIn={isLoggedIn}
optedIn={optedIn}
onOptInChange={handleOptInChange}
/>
),
},
];
return (
<div style={{ maxWidth: 1000, margin: '0 auto', padding: isMobile ? '16px' : '24px 16px' }}>
{/* Featured Volunteers Section */}
<div style={{ textAlign: 'center', marginBottom: 32 }}>
<StarFilled style={{ fontSize: 32, color: '#d4a017', marginBottom: 8 }} />
<Title level={2} style={{ margin: 0 }}>Volunteer Spotlight</Title>
<Text type="secondary">Recognizing the outstanding volunteers making a difference</Text>
</div>
{loadingFeatured ? (
<Spin style={{ display: 'block', margin: '24px auto' }} />
) : featured.length > 0 ? (
<div style={{ marginBottom: 40 }}>
<Title level={4} style={{ marginBottom: 16 }}>
<StarFilled style={{ color: '#d4a017', marginRight: 8 }} />
This Month's Featured Volunteers
</Title>
<Row gutter={[16, 16]}>
{featured.map((s) => (
<Col key={s.id} xs={24} md={12}>
<SpotlightCard
spotlight={{
id: s.id,
userId: s.userId,
userName: s.user?.name,
headline: s.headline,
story: s.story,
featuredMonth: s.featuredMonth,
}}
/>
</Col>
))}
</Row>
</div>
) : (
<Empty
description="No featured volunteers this month"
image={Empty.PRESENTED_IMAGE_SIMPLE}
style={{ marginBottom: 40 }}
/>
)}
{/* Leaderboard Section */}
<div style={{
background: themeToken.colorBgContainer,
borderRadius: 12,
padding: isMobile ? 16 : 24,
marginBottom: 40,
}}>
<Title level={4} style={{ marginBottom: 16 }}>
<TrophyOutlined style={{ color: '#d4a017', marginRight: 8 }} />
Leaderboard
</Title>
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
items={leaderboardTabs}
/>
</div>
{/* Wall of Fame Section */}
{wallTotal > 0 && (
<div>
<Title level={4} style={{ marginBottom: 16 }}>
<StarFilled style={{ color: '#d4a017', marginRight: 8 }} />
Wall of Fame
</Title>
{loadingWall ? (
<Spin style={{ display: 'block', margin: '24px auto' }} />
) : (
<>
<Row gutter={[16, 16]}>
{wallOfFame.map((s) => (
<Col key={s.id} xs={24} md={12}>
<SpotlightCard
spotlight={{
id: s.id,
userId: s.userId,
userName: s.user?.name,
headline: s.headline,
story: s.story,
featuredMonth: s.featuredMonth,
}}
/>
</Col>
))}
</Row>
{wallTotal > 12 && (
<div style={{ textAlign: 'center', marginTop: 24 }}>
<Pagination
current={wallPage}
total={wallTotal}
pageSize={12}
onChange={handleWallPageChange}
showSizeChanger={false}
/>
</div>
)}
</>
)}
</div>
)}
</div>
);
}

View File

@ -1,297 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import {
Table,
Button,
Tag,
Space,
Modal,
Form,
Input,
Select,
InputNumber,
DatePicker,
Popconfirm,
Tabs,
App,
} from 'antd';
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
PlayCircleOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
ReloadOutlined,
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
import { api } from '@/lib/api';
import { METRIC_MAP, STATUS_COLORS } from '@/components/social/ChallengeCard';
import type { PaginationMeta } from '@/types/api';
const { TextArea } = Input;
const { RangePicker } = DatePicker;
interface ChallengeRow {
id: string;
title: string;
description: string | null;
metric: string;
status: string;
startsAt: string;
endsAt: string;
minTeamSize: number;
maxTeamSize: number;
maxTeams: number | null;
_count?: { teams: number };
}
const METRIC_OPTIONS = Object.entries(METRIC_MAP).map(([value, info]) => ({
value,
label: info.label,
}));
const STATUS_TABS = ['ALL', 'DRAFT', 'UPCOMING', 'ACTIVE', 'COMPLETED', 'CANCELLED'];
export default function ChallengesAdminPage() {
const { message } = App.useApp();
const [challenges, setChallenges] = useState<ChallengeRow[]>([]);
const [pagination, setPagination] = useState<PaginationMeta | null>(null);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [statusFilter, setStatusFilter] = useState<string>('ALL');
const [modalOpen, setModalOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [form] = Form.useForm();
const load = useCallback(async () => {
setLoading(true);
try {
const params: Record<string, unknown> = { page, limit: 20 };
if (statusFilter !== 'ALL') params.status = statusFilter;
const res = await api.get('/social/challenges', { params });
setChallenges(res.data.challenges);
setPagination(res.data.pagination);
} catch {
message.error('Failed to load challenges');
} finally {
setLoading(false);
}
}, [page, statusFilter]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => { load(); }, [load]);
const openCreate = () => {
setEditingId(null);
form.resetFields();
setModalOpen(true);
};
const openEdit = (row: ChallengeRow) => {
setEditingId(row.id);
form.setFieldsValue({
title: row.title,
description: row.description,
metric: row.metric,
dateRange: [dayjs(row.startsAt), dayjs(row.endsAt)],
minTeamSize: row.minTeamSize,
maxTeamSize: row.maxTeamSize,
maxTeams: row.maxTeams,
});
setModalOpen(true);
};
const handleSave = async () => {
try {
const values = await form.validateFields();
setSaving(true);
const payload = {
title: values.title,
description: values.description || undefined,
metric: values.metric,
startsAt: values.dateRange[0].toISOString(),
endsAt: values.dateRange[1].toISOString(),
minTeamSize: values.minTeamSize,
maxTeamSize: values.maxTeamSize,
maxTeams: values.maxTeams || undefined,
};
if (editingId) {
await api.put(`/social/challenges/admin/${editingId}`, payload);
message.success('Challenge updated');
} else {
await api.post('/social/challenges/admin', payload);
message.success('Challenge created');
}
setModalOpen(false);
load();
} catch (err: any) {
if (err.response?.data?.error?.message) {
message.error(err.response.data.error.message);
}
} finally {
setSaving(false);
}
};
const handleAction = async (id: string, action: string) => {
try {
if (action === 'delete') {
await api.delete(`/social/challenges/admin/${id}`);
message.success('Deleted');
} else {
await api.post(`/social/challenges/admin/${id}/${action}`);
message.success(`Challenge ${action}d`);
}
load();
} catch (err: any) {
message.error(err.response?.data?.error?.message || `Failed to ${action}`);
}
};
const columns: ColumnsType<ChallengeRow> = [
{
title: 'Title',
dataIndex: 'title',
key: 'title',
ellipsis: true,
},
{
title: 'Metric',
dataIndex: 'metric',
key: 'metric',
width: 160,
render: (m: string) => {
const info = METRIC_MAP[m];
return info ? <Tag icon={info.icon} color={info.color}>{info.label}</Tag> : m;
},
},
{
title: 'Status',
dataIndex: 'status',
key: 'status',
width: 110,
render: (s: string) => <Tag color={STATUS_COLORS[s]}>{s}</Tag>,
},
{
title: 'Period',
key: 'period',
width: 180,
render: (_: unknown, r: ChallengeRow) =>
`${dayjs(r.startsAt).format('MMM D')} - ${dayjs(r.endsAt).format('MMM D')}`,
},
{
title: 'Teams',
key: 'teams',
width: 70,
render: (_: unknown, r: ChallengeRow) => r._count?.teams ?? 0,
},
{
title: 'Actions',
key: 'actions',
width: 200,
render: (_: unknown, r: ChallengeRow) => (
<Space size={4} wrap>
{r.status === 'DRAFT' && (
<Button size="small" icon={<PlayCircleOutlined />} onClick={() => handleAction(r.id, 'activate')}>
Activate
</Button>
)}
{r.status === 'ACTIVE' && (
<>
<Button size="small" icon={<CheckCircleOutlined />} onClick={() => handleAction(r.id, 'complete')}>
Complete
</Button>
<Button size="small" icon={<ReloadOutlined />} onClick={() => handleAction(r.id, 'rescore')}>
Rescore
</Button>
</>
)}
{(r.status === 'DRAFT' || r.status === 'UPCOMING') && (
<Button size="small" icon={<EditOutlined />} onClick={() => openEdit(r)}>Edit</Button>
)}
{r.status !== 'COMPLETED' && r.status !== 'CANCELLED' && (
<Popconfirm title="Cancel this challenge?" onConfirm={() => handleAction(r.id, 'cancel')}>
<Button size="small" danger icon={<CloseCircleOutlined />}>Cancel</Button>
</Popconfirm>
)}
{(r.status === 'DRAFT' || r.status === 'CANCELLED') && (
<Popconfirm title="Delete this challenge?" onConfirm={() => handleAction(r.id, 'delete')}>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
)}
</Space>
),
},
];
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<Tabs
activeKey={statusFilter}
onChange={(key) => { setStatusFilter(key); setPage(1); }}
items={STATUS_TABS.map((s) => ({ key: s, label: s === 'ALL' ? 'All' : s }))}
style={{ marginBottom: 0 }}
/>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
New Challenge
</Button>
</div>
<Table
dataSource={challenges}
columns={columns}
rowKey="id"
size="small"
loading={loading}
pagination={{
current: page,
total: pagination?.total ?? 0,
pageSize: 20,
showSizeChanger: false,
onChange: setPage,
}}
/>
<Modal
title={editingId ? 'Edit Challenge' : 'New Challenge'}
open={modalOpen}
onCancel={() => setModalOpen(false)}
onOk={handleSave}
confirmLoading={saving}
destroyOnHidden
width={600}
>
<Form form={form} layout="vertical">
<Form.Item name="title" label="Title" rules={[{ required: true, max: 200 }]}>
<Input maxLength={200} />
</Form.Item>
<Form.Item name="description" label="Description">
<TextArea rows={3} maxLength={2000} />
</Form.Item>
<Form.Item name="metric" label="Metric" rules={[{ required: true }]}>
<Select options={METRIC_OPTIONS} placeholder="Select metric" />
</Form.Item>
<Form.Item name="dateRange" label="Date Range" rules={[{ required: true }]}>
<RangePicker showTime style={{ width: '100%' }} />
</Form.Item>
<Space>
<Form.Item name="minTeamSize" label="Min Team Size" initialValue={2}>
<InputNumber min={1} max={50} />
</Form.Item>
<Form.Item name="maxTeamSize" label="Max Team Size" initialValue={10}>
<InputNumber min={2} max={100} />
</Form.Item>
<Form.Item name="maxTeams" label="Max Teams">
<InputNumber min={1} placeholder="Unlimited" />
</Form.Item>
</Space>
</Form>
</Modal>
</div>
);
}

View File

@ -1,148 +0,0 @@
import { useEffect, useState, useCallback } from 'react';
import { Card, Table, Typography, Space, Spin, Row, Col, Grid, App } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { TrophyOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import { api } from '@/lib/api';
interface AdminReferralRow {
id: number;
completedAt: string;
referralSource: string | null;
referrer: { id: string; name: string | null; email: string };
referredUser: { id: string; name: string | null; email: string };
}
interface LeaderboardEntry {
rank: number;
userId: string;
name: string | null;
email: string;
referralCount: number;
}
export default function ReferralAdminPage() {
const { message } = App.useApp();
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const [referrals, setReferrals] = useState<AdminReferralRow[]>([]);
const [leaderboard, setLeaderboard] = useState<LeaderboardEntry[]>([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const loadData = useCallback(async () => {
setLoading(true);
try {
const [refRes, lbRes] = await Promise.all([
api.get('/social/referrals/admin/all', { params: { page, limit: 20 } }),
api.get('/social/referrals/admin/leaderboard', { params: { limit: 10 } }),
]);
setReferrals(refRes.data.referrals);
setTotal(refRes.data.pagination.total);
setLeaderboard(lbRes.data.leaderboard);
} catch {
message.error('Failed to load referral data');
} finally {
setLoading(false);
}
}, [page]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => { loadData(); }, [loadData]);
const referralColumns: ColumnsType<AdminReferralRow> = [
{
title: 'Referrer',
key: 'referrer',
render: (_: unknown, r: AdminReferralRow) => r.referrer.name || r.referrer.email,
},
{
title: 'Referred User',
key: 'referred',
render: (_: unknown, r: AdminReferralRow) => r.referredUser.name || r.referredUser.email,
},
{
title: 'Source',
dataIndex: 'referralSource',
key: 'source',
render: (v: string | null) => v || '-',
},
{
title: 'Date',
dataIndex: 'completedAt',
key: 'date',
render: (v: string) => dayjs(v).format('MMM D, YYYY'),
responsive: ['md'] as any,
},
];
const leaderboardColumns: ColumnsType<LeaderboardEntry> = [
{
title: '#',
dataIndex: 'rank',
key: 'rank',
width: 50,
},
{
title: 'Name',
key: 'name',
render: (_: unknown, r: LeaderboardEntry) => r.name || r.email,
},
{
title: 'Referrals',
dataIndex: 'referralCount',
key: 'count',
width: 100,
},
];
if (loading && page === 1) {
return <div style={{ textAlign: 'center', padding: 48 }}><Spin size="large" /></div>;
}
return (
<div style={{ padding: isMobile ? 16 : 24 }}>
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<Typography.Title level={4} style={{ margin: 0 }}>Referral Management</Typography.Title>
<Row gutter={[24, 24]}>
<Col xs={24} lg={16}>
<Card title={`All Referrals (${total})`} size="small">
<Table
dataSource={referrals}
columns={referralColumns}
rowKey="id"
size="small"
loading={loading}
pagination={{
current: page,
total,
pageSize: 20,
onChange: setPage,
showSizeChanger: false,
}}
locale={{ emptyText: 'No referrals yet' }}
/>
</Card>
</Col>
<Col xs={24} lg={8}>
<Card
title={<span><TrophyOutlined style={{ marginRight: 8 }} />Top Referrers</span>}
size="small"
>
<Table
dataSource={leaderboard}
columns={leaderboardColumns}
rowKey="userId"
size="small"
pagination={false}
locale={{ emptyText: 'No referrals yet' }}
/>
</Card>
</Col>
</Row>
</Space>
</div>
);
}

View File

@ -1,372 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import {
Table, Tag, Button, Space, Modal, Form, Input, Select, DatePicker, Popconfirm,
message, Typography, Tabs,
} from 'antd';
import {
PlusOutlined, CheckOutlined, StarOutlined, InboxOutlined, EditOutlined,
DeleteOutlined, UserOutlined,
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
import { api } from '@/lib/api';
const { Text } = Typography;
const { TextArea } = Input;
interface Spotlight {
id: string;
userId: string;
status: 'NOMINATED' | 'APPROVED' | 'FEATURED' | 'ARCHIVED';
headline: string | null;
story: string | null;
featuredMonth: string | null;
createdAt: string;
user: { id: string; name: string | null; email: string };
nominatedBy?: { id: string; name: string | null } | null;
approvedBy?: { id: string; name: string | null } | null;
}
interface UserOption {
id: string;
name: string | null;
email: string;
}
const STATUS_COLORS: Record<string, string> = {
NOMINATED: 'blue',
APPROVED: 'green',
FEATURED: 'gold',
ARCHIVED: 'default',
};
export default function SpotlightAdminPage() {
const [spotlights, setSpotlights] = useState<Spotlight[]>([]);
const [loading, setLoading] = useState(false);
const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0 });
const [statusFilter, setStatusFilter] = useState<string>('all');
const [nominateOpen, setNominateOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [featureOpen, setFeatureOpen] = useState(false);
const [selectedSpotlight, setSelectedSpotlight] = useState<Spotlight | null>(null);
const [users, setUsers] = useState<UserOption[]>([]);
const [userSearch, setUserSearch] = useState('');
const [featureMonth, setFeatureMonth] = useState<dayjs.Dayjs | null>(null);
const [nominateForm] = Form.useForm();
const [editForm] = Form.useForm();
const fetchSpotlights = useCallback(async (page = 1) => {
try {
setLoading(true);
const params: Record<string, string | number> = { page, limit: pagination.limit };
if (statusFilter !== 'all') params.status = statusFilter;
const { data } = await api.get('/social/spotlight/admin', { params });
setSpotlights(data.spotlights || []);
setPagination((prev) => ({ ...prev, page, total: data.pagination?.total || 0 }));
} catch {
message.error('Failed to load spotlights');
} finally {
setLoading(false);
}
}, [statusFilter, pagination.limit]);
const searchUsers = useCallback(async (search: string) => {
if (search.length < 2) return;
try {
const { data } = await api.get('/users', { params: { search, limit: 20 } });
setUsers(data.users || []);
} catch {
// Ignore
}
}, []);
useEffect(() => {
fetchSpotlights(1);
}, [fetchSpotlights]);
useEffect(() => {
if (userSearch.length >= 2) {
const timer = setTimeout(() => searchUsers(userSearch), 300);
return () => clearTimeout(timer);
}
}, [userSearch, searchUsers]);
const handleNominate = async (values: { userId: string; headline?: string; story?: string }) => {
try {
await api.post('/social/spotlight/admin/nominate', values);
message.success('Volunteer nominated');
setNominateOpen(false);
nominateForm.resetFields();
fetchSpotlights(1);
} catch (err: any) {
message.error(err?.response?.data?.error?.message || 'Failed to nominate');
}
};
const handleApprove = async (id: string) => {
try {
await api.post(`/social/spotlight/admin/${id}/approve`);
message.success('Spotlight approved');
fetchSpotlights(pagination.page);
} catch (err: any) {
message.error(err?.response?.data?.error?.message || 'Failed to approve');
}
};
const handleFeature = async () => {
if (!selectedSpotlight || !featureMonth) return;
try {
const month = featureMonth.format('YYYY-MM');
await api.post(`/social/spotlight/admin/${selectedSpotlight.id}/feature`, { month });
message.success('Spotlight featured');
setFeatureOpen(false);
setFeatureMonth(null);
fetchSpotlights(pagination.page);
} catch (err: any) {
message.error(err?.response?.data?.error?.message || 'Failed to feature');
}
};
const handleArchive = async (id: string) => {
try {
await api.post(`/social/spotlight/admin/${id}/archive`);
message.success('Spotlight archived');
fetchSpotlights(pagination.page);
} catch (err: any) {
message.error(err?.response?.data?.error?.message || 'Failed to archive');
}
};
const handleEdit = async (values: { headline?: string; story?: string }) => {
if (!selectedSpotlight) return;
try {
await api.put(`/social/spotlight/admin/${selectedSpotlight.id}`, values);
message.success('Spotlight updated');
setEditOpen(false);
fetchSpotlights(pagination.page);
} catch (err: any) {
message.error(err?.response?.data?.error?.message || 'Failed to update');
}
};
const handleDelete = async (id: string) => {
try {
await api.delete(`/social/spotlight/admin/${id}`);
message.success('Spotlight deleted');
fetchSpotlights(pagination.page);
} catch (err: any) {
message.error(err?.response?.data?.error?.message || 'Failed to delete');
}
};
const openEdit = (record: Spotlight) => {
setSelectedSpotlight(record);
editForm.setFieldsValue({ headline: record.headline, story: record.story });
setEditOpen(true);
};
const openFeature = (record: Spotlight) => {
setSelectedSpotlight(record);
setFeatureMonth(null);
setFeatureOpen(true);
};
const columns: ColumnsType<Spotlight> = [
{
title: 'Volunteer',
key: 'user',
render: (_, r) => (
<div>
<Text strong>{r.user.name || r.user.email}</Text>
{r.user.name && <br />}
{r.user.name && <Text type="secondary" style={{ fontSize: 12 }}>{r.user.email}</Text>}
</div>
),
},
{
title: 'Status',
dataIndex: 'status',
key: 'status',
width: 120,
render: (status: string) => (
<Tag color={STATUS_COLORS[status]}>{status}</Tag>
),
},
{
title: 'Headline',
dataIndex: 'headline',
key: 'headline',
ellipsis: true,
render: (v: string | null) => v || <Text type="secondary">--</Text>,
},
{
title: 'Month',
dataIndex: 'featuredMonth',
key: 'featuredMonth',
width: 110,
render: (v: string | null) =>
v ? dayjs(v + '-01').format('MMM YYYY') : <Text type="secondary">--</Text>,
},
{
title: 'Created',
dataIndex: 'createdAt',
key: 'createdAt',
width: 110,
render: (v: string) => dayjs(v).format('MMM D, YYYY'),
},
{
title: 'Actions',
key: 'actions',
width: 200,
render: (_, record) => (
<Space size="small" wrap>
{record.status === 'NOMINATED' && (
<Button
size="small"
type="primary"
icon={<CheckOutlined />}
onClick={() => handleApprove(record.id)}
>
Approve
</Button>
)}
{record.status === 'APPROVED' && (
<Button
size="small"
icon={<StarOutlined />}
style={{ color: '#d4a017', borderColor: '#d4a017' }}
onClick={() => openFeature(record)}
>
Feature
</Button>
)}
{record.status !== 'ARCHIVED' && (
<Button
size="small"
icon={<InboxOutlined />}
onClick={() => handleArchive(record.id)}
>
Archive
</Button>
)}
<Button size="small" icon={<EditOutlined />} onClick={() => openEdit(record)} />
<Popconfirm
title="Delete this spotlight?"
onConfirm={() => handleDelete(record.id)}
>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
},
];
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<Typography.Title level={4} style={{ margin: 0 }}>Volunteer Spotlight</Typography.Title>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setNominateOpen(true)}>
Nominate
</Button>
</div>
<Tabs
activeKey={statusFilter}
onChange={(key) => setStatusFilter(key)}
items={[
{ key: 'all', label: 'All' },
{ key: 'NOMINATED', label: 'Nominated' },
{ key: 'APPROVED', label: 'Approved' },
{ key: 'FEATURED', label: 'Featured' },
{ key: 'ARCHIVED', label: 'Archived' },
]}
style={{ marginBottom: 16 }}
/>
<Table
dataSource={spotlights}
columns={columns}
rowKey="id"
loading={loading}
pagination={{
current: pagination.page,
total: pagination.total,
pageSize: pagination.limit,
onChange: (page) => fetchSpotlights(page),
showTotal: (total) => `${total} spotlights`,
}}
scroll={{ x: 800 }}
/>
{/* Nominate Modal */}
<Modal
title="Nominate Volunteer"
open={nominateOpen}
onCancel={() => { setNominateOpen(false); nominateForm.resetFields(); }}
onOk={() => nominateForm.submit()}
okText="Nominate"
>
<Form form={nominateForm} layout="vertical" onFinish={handleNominate}>
<Form.Item name="userId" label="Volunteer" rules={[{ required: true, message: 'Select a volunteer' }]}>
<Select
showSearch
placeholder="Search by name or email"
filterOption={false}
onSearch={setUserSearch}
suffixIcon={<UserOutlined />}
options={users.map((u) => ({
value: u.id,
label: `${u.name || ''} (${u.email})`.trim(),
}))}
/>
</Form.Item>
<Form.Item name="headline" label="Headline">
<Input maxLength={200} placeholder="A short title for their spotlight" />
</Form.Item>
<Form.Item name="story" label="Story">
<TextArea rows={4} maxLength={2000} placeholder="Tell us why this volunteer deserves recognition" showCount />
</Form.Item>
</Form>
</Modal>
{/* Edit Modal */}
<Modal
title="Edit Spotlight"
open={editOpen}
onCancel={() => setEditOpen(false)}
onOk={() => editForm.submit()}
okText="Save"
>
<Form form={editForm} layout="vertical" onFinish={handleEdit}>
<Form.Item name="headline" label="Headline">
<Input maxLength={200} />
</Form.Item>
<Form.Item name="story" label="Story">
<TextArea rows={4} maxLength={2000} showCount />
</Form.Item>
</Form>
</Modal>
{/* Feature Modal */}
<Modal
title="Feature Spotlight"
open={featureOpen}
onCancel={() => setFeatureOpen(false)}
onOk={handleFeature}
okText="Feature"
okButtonProps={{ disabled: !featureMonth }}
>
<Text>Select the month to feature this volunteer:</Text>
<div style={{ marginTop: 12 }}>
<DatePicker
picker="month"
value={featureMonth}
onChange={setFeatureMonth}
style={{ width: '100%' }}
/>
</div>
</Modal>
</div>
);
}

View File

@ -1,9 +1,9 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Card, Typography, Progress, Tag, Skeleton, Empty, Tabs, List, Statistic, Row, Col, Switch, message } from 'antd'; import { Card, Typography, Progress, Tag, Skeleton, Empty, Tabs, List, Statistic, Row, Col } from 'antd';
import { import {
TrophyOutlined, ScheduleOutlined, EnvironmentOutlined, MailOutlined, TrophyOutlined, ScheduleOutlined, EnvironmentOutlined, MailOutlined,
TeamOutlined, FireOutlined, StarOutlined, HomeOutlined, UserAddOutlined, TeamOutlined, FireOutlined, StarOutlined, HomeOutlined, UserAddOutlined,
CrownOutlined, EyeOutlined, CrownOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import UserAvatar from '@/components/social/UserAvatar'; import UserAvatar from '@/components/social/UserAvatar';
@ -37,12 +37,9 @@ export default function AchievementsPage() {
const [leaderboardType, setLeaderboardType] = useState<string>('canvass'); const [leaderboardType, setLeaderboardType] = useState<string>('canvass');
const [myRank, setMyRank] = useState<number | null>(null); const [myRank, setMyRank] = useState<number | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [leaderboardOptIn, setLeaderboardOptIn] = useState(true);
const [optInLoading, setOptInLoading] = useState(false);
useEffect(() => { useEffect(() => {
fetchData(); fetchData();
fetchOptInStatus();
}, []); }, []);
useEffect(() => { useEffect(() => {
@ -62,26 +59,6 @@ export default function AchievementsPage() {
setLoading(false); setLoading(false);
}; };
const fetchOptInStatus = async () => {
try {
const { data } = await api.get('/social/spotlight/opt-in-status');
setLeaderboardOptIn(data.showOnLeaderboard ?? true);
} catch {}
};
const handleOptInToggle = async (checked: boolean) => {
setOptInLoading(true);
try {
await api.post(checked ? '/social/spotlight/opt-in' : '/social/spotlight/opt-out');
setLeaderboardOptIn(checked);
message.success(checked ? 'You are now visible on public leaderboards' : 'You are now hidden from public leaderboards');
} catch {
message.error('Failed to update preference');
} finally {
setOptInLoading(false);
}
};
const fetchLeaderboard = async (type: string) => { const fetchLeaderboard = async (type: string) => {
try { try {
const { data } = await api.get('/social/achievements/leaderboard', { params: { type, limit: 10 } }); const { data } = await api.get('/social/achievements/leaderboard', { params: { type, limit: 10 } });
@ -171,24 +148,7 @@ export default function AchievementsPage() {
</Card> </Card>
{/* Leaderboard */} {/* Leaderboard */}
<Card <Card title="Leaderboard" size="small">
title="Leaderboard"
size="small"
extra={
<span style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12 }}>
<EyeOutlined />
<Switch
size="small"
checked={leaderboardOptIn}
loading={optInLoading}
onChange={handleOptInToggle}
/>
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
{leaderboardOptIn ? 'Visible' : 'Hidden'}
</Typography.Text>
</span>
}
>
<Tabs <Tabs
activeKey={leaderboardType} activeKey={leaderboardType}
onChange={setLeaderboardType} onChange={setLeaderboardType}

View File

@ -1,202 +0,0 @@
import { useEffect, useState, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Typography, Spin, Card, Tag, Space, Button, Divider, Descriptions, App } from 'antd';
import {
ArrowLeftOutlined,
ClockCircleOutlined,
LogoutOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth.store';
import { METRIC_MAP, STATUS_COLORS } from '@/components/social/ChallengeCard';
import ChallengeLeaderboard from '@/components/social/ChallengeLeaderboard';
import TeamJoinCard from '@/components/social/TeamJoinCard';
interface ChallengeDetail {
id: string;
title: string;
description: string | null;
metric: string;
status: string;
startsAt: string;
endsAt: string;
minTeamSize: number;
maxTeamSize: number;
maxTeams: number | null;
createdBy: { name: string | null; email: string };
teams: Array<{
id: string;
name: string;
score: number;
captainUserId: string;
captain: { id: string; name: string | null; email: string };
members: Array<{
id: number;
userId: string;
score: number;
user: { id: string; name: string | null; email: string };
}>;
}>;
}
export default function ChallengeDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { message } = App.useApp();
const { user } = useAuthStore();
const [challenge, setChallenge] = useState<ChallengeDetail | null>(null);
const [myTeam, setMyTeam] = useState<ChallengeDetail['teams'][0] | null>(null);
const [loading, setLoading] = useState(true);
const [leaving, setLeaving] = useState(false);
const load = useCallback(async () => {
if (!id) return;
setLoading(true);
try {
const [challengeRes, myTeamRes] = await Promise.all([
api.get(`/social/challenges/${id}`),
api.get(`/social/challenges/${id}/my-team`),
]);
setChallenge(challengeRes.data);
setMyTeam(myTeamRes.data.team);
} catch {
message.error('Failed to load challenge');
} finally {
setLoading(false);
}
}, [id]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => { load(); }, [load]);
const handleLeave = async () => {
if (!myTeam || !id) return;
setLeaving(true);
try {
await api.post(`/social/challenges/${id}/teams/${myTeam.id}/leave`);
message.success('Left team');
load();
} catch (err: any) {
message.error(err.response?.data?.error?.message || 'Failed to leave team');
} finally {
setLeaving(false);
}
};
if (loading || !challenge) {
return <div style={{ textAlign: 'center', padding: 48 }}><Spin size="large" /></div>;
}
const metric = (METRIC_MAP[challenge.metric] || METRIC_MAP.DOORS_KNOCKED)!;
const isJoinable = challenge.status === 'UPCOMING' || challenge.status === 'ACTIVE';
const isActive = challenge.status === 'ACTIVE';
const isUpcoming = challenge.status === 'UPCOMING';
const endsIn = dayjs(challenge.endsAt).diff(dayjs(), 'second');
const startsIn = dayjs(challenge.startsAt).diff(dayjs(), 'second');
const countdownLabel =
isActive && endsIn > 0
? `Ends in ${Math.floor(endsIn / 86400)}d ${Math.floor((endsIn % 86400) / 3600)}h`
: isUpcoming && startsIn > 0
? `Starts in ${Math.floor(startsIn / 86400)}d ${Math.floor((startsIn % 86400) / 3600)}h`
: null;
return (
<div>
<Button
type="text"
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/volunteer/challenges')}
style={{ marginBottom: 8, padding: 0 }}
>
Back
</Button>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 16 }}>
<div>
<Typography.Title level={3} style={{ margin: 0 }}>
{challenge.title}
</Typography.Title>
{challenge.description && (
<Typography.Paragraph type="secondary" style={{ marginTop: 4 }}>
{challenge.description}
</Typography.Paragraph>
)}
</div>
<Tag color={STATUS_COLORS[challenge.status]}>{challenge.status}</Tag>
</div>
<Descriptions size="small" column={{ xs: 1, sm: 2, md: 3 }} style={{ marginBottom: 16 }}>
<Descriptions.Item label="Metric">
<Tag icon={metric.icon} color={metric.color}>{metric.label}</Tag>
</Descriptions.Item>
<Descriptions.Item label="Period">
{dayjs(challenge.startsAt).format('MMM D')} - {dayjs(challenge.endsAt).format('MMM D, YYYY')}
</Descriptions.Item>
<Descriptions.Item label="Team Size">
{challenge.minTeamSize} - {challenge.maxTeamSize}
</Descriptions.Item>
</Descriptions>
{countdownLabel && (
<Tag icon={<ClockCircleOutlined />} color="processing" style={{ marginBottom: 16 }}>
{countdownLabel}
</Tag>
)}
<Divider orientation="left">Leaderboard</Divider>
<ChallengeLeaderboard
teams={challenge.teams}
myTeamId={myTeam?.id}
/>
<Divider orientation="left">My Team</Divider>
{myTeam ? (
<Card size="small">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<Typography.Title level={5} style={{ margin: 0 }}>{myTeam.name}</Typography.Title>
{isJoinable && (
<Button
size="small"
danger
icon={<LogoutOutlined />}
loading={leaving}
onClick={handleLeave}
>
Leave
</Button>
)}
</div>
<Space direction="vertical" size={2} style={{ width: '100%' }}>
{myTeam.members.map((m) => (
<div key={m.id} style={{ display: 'flex', justifyContent: 'space-between' }}>
<span>
{m.userId === myTeam.captainUserId ? '(C) ' : ''}
{m.user.name || m.user.email}
{m.userId === user?.id ? ' (you)' : ''}
</span>
<Typography.Text strong>{m.score}</Typography.Text>
</div>
))}
</Space>
</Card>
) : isJoinable ? (
<TeamJoinCard
challengeId={challenge.id}
teams={challenge.teams}
maxTeamSize={challenge.maxTeamSize}
onTeamCreated={load}
onTeamJoined={load}
/>
) : (
<Typography.Text type="secondary">
This challenge is not accepting new teams.
</Typography.Text>
)}
</div>
);
}

View File

@ -1,124 +0,0 @@
import { useEffect, useState } from 'react';
import { Typography, Spin, Row, Col, Collapse, Empty, App } from 'antd';
import { api } from '@/lib/api';
import ChallengeCard from '@/components/social/ChallengeCard';
interface ChallengeListItem {
id: string;
title: string;
metric: string;
status: string;
startsAt: string;
endsAt: string;
_count?: { teams: number };
}
interface MyTeamMap {
[challengeId: string]: { id: string; name: string } | null;
}
export default function ChallengesPage() {
const { message } = App.useApp();
const [challenges, setChallenges] = useState<ChallengeListItem[]>([]);
const [myTeams, setMyTeams] = useState<MyTeamMap>({});
const [loading, setLoading] = useState(true);
useEffect(() => {
loadChallenges();
}, []);
const loadChallenges = async () => {
setLoading(true);
try {
const res = await api.get('/social/challenges', { params: { limit: 100 } });
const all: ChallengeListItem[] = res.data.challenges;
setChallenges(all);
// Fetch my team for UPCOMING/ACTIVE challenges
const relevant = all.filter((c) => c.status === 'UPCOMING' || c.status === 'ACTIVE');
const teamMap: MyTeamMap = {};
await Promise.all(
relevant.map(async (c) => {
try {
const r = await api.get(`/social/challenges/${c.id}/my-team`);
teamMap[c.id] = r.data.team;
} catch {
teamMap[c.id] = null;
}
}),
);
setMyTeams(teamMap);
} catch {
message.error('Failed to load challenges');
} finally {
setLoading(false);
}
};
if (loading) {
return <div style={{ textAlign: 'center', padding: 48 }}><Spin size="large" /></div>;
}
const active = challenges.filter((c) => c.status === 'ACTIVE');
const upcoming = challenges.filter((c) => c.status === 'UPCOMING');
const past = challenges.filter((c) => c.status === 'COMPLETED');
return (
<div>
<Typography.Title level={3} style={{ marginBottom: 16 }}>
Team Challenges
</Typography.Title>
{active.length > 0 && (
<div style={{ marginBottom: 24 }}>
<Typography.Title level={5} style={{ marginBottom: 8 }}>Active Challenges</Typography.Title>
<Row gutter={[12, 12]}>
{active.map((c) => (
<Col xs={24} sm={12} lg={8} key={c.id}>
<ChallengeCard challenge={c} myTeam={myTeams[c.id]} />
</Col>
))}
</Row>
</div>
)}
{upcoming.length > 0 && (
<div style={{ marginBottom: 24 }}>
<Typography.Title level={5} style={{ marginBottom: 8 }}>Upcoming</Typography.Title>
<Row gutter={[12, 12]}>
{upcoming.map((c) => (
<Col xs={24} sm={12} lg={8} key={c.id}>
<ChallengeCard challenge={c} myTeam={myTeams[c.id]} />
</Col>
))}
</Row>
</div>
)}
{past.length > 0 && (
<Collapse
ghost
items={[
{
key: 'past',
label: <Typography.Text type="secondary">Past Challenges ({past.length})</Typography.Text>,
children: (
<Row gutter={[12, 12]}>
{past.map((c) => (
<Col xs={24} sm={12} lg={8} key={c.id}>
<ChallengeCard challenge={c} />
</Col>
))}
</Row>
),
},
]}
/>
)}
{challenges.length === 0 && (
<Empty description="No challenges yet" />
)}
</div>
);
}

View File

@ -1,196 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
Typography,
Button,
Grid,
Skeleton,
Empty,
List,
Tag,
Space,
message,
theme,
} from 'antd';
import {
ArrowLeftOutlined,
CalendarOutlined,
ClockCircleOutlined,
EnvironmentOutlined,
} from '@ant-design/icons';
import dayjs, { Dayjs } from 'dayjs';
import { api } from '@/lib/api';
import FeatureGate from '@/components/FeatureGate';
import PersonalCalendarView from '@/components/calendar/PersonalCalendarView';
import MobileDayView from '@/components/calendar/MobileDayView';
import type { PersonalCalendarItem } from '@/types/api';
const { Title, Text } = Typography;
export default function FriendCalendarPage() {
const { userId } = useParams<{ userId: string }>();
const navigate = useNavigate();
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const { token } = theme.useToken();
const [friendName, setFriendName] = useState<string>('');
const [items, setItems] = useState<PersonalCalendarItem[]>([]);
const [loading, setLoading] = useState(true);
const [currentMonth, setCurrentMonth] = useState<Dayjs>(dayjs());
const [selectedDate, setSelectedDate] = useState<string | null>(null);
const fetchItems = useCallback(async () => {
if (!userId) return;
const startDate = currentMonth.startOf('month').subtract(7, 'day').format('YYYY-MM-DD');
const endDate = currentMonth.endOf('month').add(7, 'day').format('YYYY-MM-DD');
try {
const { data } = await api.get<{ items: PersonalCalendarItem[] }>(
`/calendar/shared/user/${userId}`,
{ params: { startDate, endDate } },
);
setItems(data.items);
} catch (err: any) {
if (err.response?.status === 403) {
message.error('This user has not shared their calendar');
}
} finally {
setLoading(false);
}
}, [userId, currentMonth]);
const fetchFriendName = useCallback(async () => {
if (!userId) return;
try {
const { data } = await api.get(`/social/profile/${userId}`);
setFriendName(data.user?.name || data.user?.email || 'Friend');
} catch {
setFriendName('Friend');
}
}, [userId]);
useEffect(() => {
setLoading(true);
Promise.all([fetchItems(), fetchFriendName()]).then(() => setLoading(false));
}, [fetchItems, fetchFriendName]);
const selectedDateItems = selectedDate
? items.filter((item) => item.date === selectedDate)
: [];
const handleItemClick = () => {};
if (loading) {
return (
<div style={{ padding: '12px 0' }}>
<Skeleton active paragraph={{ rows: 10 }} />
</div>
);
}
const dateDetailPanel = selectedDate && (
<div
style={{
width: isMobile ? '100%' : 280,
flexShrink: 0,
padding: isMobile ? '12px 0' : '0 0 0 16px',
borderLeft: isMobile ? undefined : `1px solid ${token.colorBorderSecondary}`,
}}
>
<Text strong style={{ fontSize: 15, marginBottom: 12, display: 'block' }}>
{dayjs(selectedDate).format('ddd, MMM D')}
</Text>
{selectedDateItems.length === 0 ? (
<Empty description="No events" image={Empty.PRESENTED_IMAGE_SIMPLE} />
) : (
<List
size="small"
dataSource={selectedDateItems}
renderItem={(item) => (
<List.Item style={{ padding: '8px 0' }}>
<List.Item.Meta
avatar={
<div
style={{
width: 4,
height: 32,
borderRadius: 2,
background: item.color,
flexShrink: 0,
}}
/>
}
title={
<Text style={{ fontSize: 13 }} ellipsis>{item.title}</Text>
}
description={
<Space size={4} wrap style={{ fontSize: 11 }}>
{!item.isAllDay && (
<Tag icon={<ClockCircleOutlined />} style={{ fontSize: 11, margin: 0 }}>
{item.startTime?.slice(0, 5)} - {item.endTime?.slice(0, 5)}
</Tag>
)}
{item.isAllDay && <Tag style={{ fontSize: 11, margin: 0 }}>All day</Tag>}
{item.location && (
<Tag icon={<EnvironmentOutlined />} style={{ fontSize: 11, margin: 0 }}>
{item.location}
</Tag>
)}
</Space>
}
/>
</List.Item>
)}
/>
)}
</div>
);
return (
<FeatureGate feature="enableSocialCalendar">
<div style={{ padding: '12px 0' }}>
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 16 }}>
<Button
type="text"
icon={<ArrowLeftOutlined />}
onClick={() => navigate(-1)}
/>
<CalendarOutlined style={{ fontSize: 18, marginRight: 8 }} />
<Title level={4} style={{ margin: 0 }}>
{friendName}&apos;s Calendar
</Title>
</div>
{isMobile ? (
<div>
<MobileDayView
items={items}
currentMonth={currentMonth}
selectedDate={selectedDate}
onDateSelect={setSelectedDate}
onMonthChange={setCurrentMonth}
onAddItem={() => {}}
onItemClick={handleItemClick}
/>
{dateDetailPanel}
</div>
) : (
<div style={{ display: 'flex', gap: 0 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<PersonalCalendarView
items={items}
currentMonth={currentMonth}
selectedDate={selectedDate}
onDateSelect={setSelectedDate}
onItemClick={handleItemClick}
onMonthChange={setCurrentMonth}
/>
</div>
{dateDetailPanel}
</div>
)}
</div>
</FeatureGate>
);
}

View File

@ -1,477 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import {
Typography,
Button,
Grid,
Skeleton,
Empty,
List,
Tag,
Space,
message,
theme,
Modal,
Segmented,
Drawer,
Divider,
} from 'antd';
import {
CalendarOutlined,
PlusOutlined,
ClockCircleOutlined,
EnvironmentOutlined,
DeleteOutlined,
EditOutlined,
SettingOutlined,
} from '@ant-design/icons';
import dayjs, { Dayjs } from 'dayjs';
import { useNavigate } from 'react-router-dom';
import { api } from '@/lib/api';
import FeatureGate from '@/components/FeatureGate';
import CalendarLayerPanel from '@/components/calendar/CalendarLayerPanel';
import PersonalCalendarView from '@/components/calendar/PersonalCalendarView';
import MobileDayView from '@/components/calendar/MobileDayView';
import CalendarItemModal, { type CalendarItemFormData } from '@/components/calendar/CalendarItemModal';
import CalendarFeedsPanel from '@/components/calendar/CalendarFeedsPanel';
import CalendarExportPanel from '@/components/calendar/CalendarExportPanel';
import type {
CalendarLayer,
PersonalCalendarItem,
PersonalCalendarResponse,
SeriesEditScope,
} from '@/types/api';
const { Text } = Typography;
export default function MyCalendarPage() {
const navigate = useNavigate();
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const { token } = theme.useToken();
// Data state
const [layers, setLayers] = useState<CalendarLayer[]>([]);
const [items, setItems] = useState<PersonalCalendarItem[]>([]);
const [loading, setLoading] = useState(true);
// View state
const [currentMonth, setCurrentMonth] = useState<Dayjs>(dayjs());
const [selectedDate, setSelectedDate] = useState<string | null>(null);
// Modal state
const [itemModalOpen, setItemModalOpen] = useState(false);
const [editingItem, setEditingItem] = useState<PersonalCalendarItem | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false);
// Derived: enabled layer IDs for filtering
const enabledLayerIds = new Set(layers.filter((l) => l.isEnabled).map((l) => l.id));
// Filtered items based on enabled layers
const filteredItems = items.filter((item) => enabledLayerIds.has(item.layerId));
// Items for the selected date
const selectedDateItems = selectedDate
? filteredItems.filter((item) => item.date === selectedDate)
: [];
// Fetch layers
const fetchLayers = useCallback(async () => {
try {
const { data } = await api.get<{ layers: CalendarLayer[] }>('/calendar/layers');
setLayers(data.layers);
} catch {
// Layers may not exist yet
}
}, []);
// Fetch items for the current visible range
const fetchItems = useCallback(async () => {
const startDate = currentMonth.startOf('month').subtract(7, 'day').format('YYYY-MM-DD');
const endDate = currentMonth.endOf('month').add(7, 'day').format('YYYY-MM-DD');
try {
const { data } = await api.get<PersonalCalendarResponse>('/calendar/my', {
params: { startDate, endDate },
});
// Flatten the dates map into a flat item array
const allItems: PersonalCalendarItem[] = [];
for (const dateGroup of Object.values(data.dates)) {
allItems.push(...dateGroup.items);
}
setItems(allItems);
} catch {
// Empty calendar
}
}, [currentMonth]);
// Initial load
useEffect(() => {
const load = async () => {
setLoading(true);
await Promise.all([fetchLayers(), fetchItems()]);
setLoading(false);
};
load();
}, [fetchLayers, fetchItems]);
// Layer CRUD
const handleCreateLayer = async (name: string, color: string) => {
try {
await api.post('/calendar/layers', { name, color });
await fetchLayers();
message.success('Layer created');
} catch {
message.error('Failed to create layer');
}
};
const handleUpdateLayer = async (id: string, updates: Partial<CalendarLayer>) => {
try {
await api.patch(`/calendar/layers/${id}`, updates);
// Optimistic update for toggle
setLayers((prev) =>
prev.map((l) => (l.id === id ? { ...l, ...updates } : l)),
);
} catch {
message.error('Failed to update layer');
await fetchLayers();
}
};
const handleDeleteLayer = async (id: string) => {
try {
await api.delete(`/calendar/layers/${id}`);
setLayers((prev) => prev.filter((l) => l.id !== id));
message.success('Layer deleted');
} catch {
message.error('Failed to delete layer');
}
};
// Item CRUD
const handleSaveItem = async (values: CalendarItemFormData, scope?: SeriesEditScope) => {
try {
if (editingItem?.calendarItemId) {
await api.patch(
`/calendar/items/${editingItem.calendarItemId}`,
values,
{ params: scope ? { scope } : undefined },
);
message.success('Event updated');
} else {
await api.post('/calendar/items', values);
message.success('Event created');
}
setItemModalOpen(false);
setEditingItem(null);
await fetchItems();
} catch {
message.error('Failed to save event');
}
};
const handleDeleteItem = async (item: PersonalCalendarItem, scope?: SeriesEditScope) => {
if (!item.calendarItemId) return;
try {
await api.delete(`/calendar/items/${item.calendarItemId}`, {
params: scope ? { scope } : undefined,
});
message.success('Event deleted');
await fetchItems();
} catch {
message.error('Failed to delete event');
}
};
const confirmDeleteItem = (item: PersonalCalendarItem) => {
if (item.isRecurring && item.seriesId) {
Modal.confirm({
title: 'Delete recurring event',
content: 'How would you like to delete this recurring event?',
okText: 'This event only',
cancelText: 'Cancel',
onOk: () => handleDeleteItem(item, 'THIS_ONLY'),
footer: (_, { OkBtn, CancelBtn }) => (
<Space>
<CancelBtn />
<OkBtn />
<Button danger onClick={() => { handleDeleteItem(item, 'ALL'); Modal.destroyAll(); }}>
All in series
</Button>
</Space>
),
});
} else {
Modal.confirm({
title: 'Delete event?',
content: `Are you sure you want to delete "${item.title}"?`,
okText: 'Delete',
okType: 'danger',
onOk: () => handleDeleteItem(item),
});
}
};
// Open modal for new item on a date
const handleAddItem = (date?: string) => {
setEditingItem(null);
if (date) {
setSelectedDate(date);
}
setItemModalOpen(true);
};
// Open modal for editing an existing item
const handleEditItem = (item: PersonalCalendarItem) => {
if (item.type !== 'personal') return; // Only personal items are editable
setEditingItem(item);
setItemModalOpen(true);
};
// Date click handler
const handleDateSelect = (date: string) => {
setSelectedDate(date);
};
// Month change handler
const handleMonthChange = (month: Dayjs) => {
setCurrentMonth(month);
};
if (loading) {
return (
<div style={{ padding: '12px 0' }}>
<Skeleton active paragraph={{ rows: 10 }} />
</div>
);
}
// Date detail panel (right side on desktop, or inline on mobile)
const dateDetailPanel = selectedDate && (
<div
style={{
width: isMobile ? '100%' : 280,
flexShrink: 0,
padding: isMobile ? '12px 0' : '0 0 0 16px',
borderLeft: isMobile ? undefined : `1px solid ${token.colorBorderSecondary}`,
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<Text strong style={{ fontSize: 15 }}>
{dayjs(selectedDate).format('ddd, MMM D')}
</Text>
<Button
type="primary"
size="small"
icon={<PlusOutlined />}
onClick={() => handleAddItem(selectedDate)}
>
Add
</Button>
</div>
{selectedDateItems.length === 0 ? (
<Empty description="No events" image={Empty.PRESENTED_IMAGE_SIMPLE} />
) : (
<List
size="small"
dataSource={selectedDateItems}
renderItem={(item) => (
<List.Item
style={{ padding: '8px 0', cursor: item.type === 'personal' ? 'pointer' : 'default' }}
onClick={() => handleEditItem(item)}
actions={
item.type === 'personal'
? [
<Button
key="edit"
type="text"
size="small"
icon={<EditOutlined />}
onClick={(e) => {
e.stopPropagation();
handleEditItem(item);
}}
/>,
<Button
key="delete"
type="text"
size="small"
danger
icon={<DeleteOutlined />}
onClick={(e) => {
e.stopPropagation();
confirmDeleteItem(item);
}}
/>,
]
: undefined
}
>
<List.Item.Meta
avatar={
<div
style={{
width: 4,
height: 32,
borderRadius: 2,
background: item.color,
flexShrink: 0,
}}
/>
}
title={
<Text style={{ fontSize: 13 }} ellipsis>
{item.title}
</Text>
}
description={
<Space size={4} wrap style={{ fontSize: 11 }}>
{!item.isAllDay && (
<Tag
icon={<ClockCircleOutlined />}
style={{ fontSize: 11, margin: 0 }}
>
{item.startTime?.slice(0, 5)} - {item.endTime?.slice(0, 5)}
</Tag>
)}
{item.isAllDay && <Tag style={{ fontSize: 11, margin: 0 }}>All day</Tag>}
{item.location && (
<Tag
icon={<EnvironmentOutlined />}
style={{ fontSize: 11, margin: 0 }}
>
{item.location}
</Tag>
)}
{item.type !== 'personal' && (
<Tag color="blue" style={{ fontSize: 10, margin: 0 }}>
{item.type}
</Tag>
)}
</Space>
}
/>
</List.Item>
)}
/>
)}
</div>
);
return (
<FeatureGate feature="enableSocialCalendar">
<div style={{ padding: '12px 0' }}>
{/* Header */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 16,
}}
>
<Space>
<CalendarOutlined style={{ fontSize: 20 }} />
<Segmented
options={['My Calendar', 'Shared']}
value="My Calendar"
onChange={(val) => {
if (val === 'Shared') navigate('/volunteer/calendar/shared');
}}
/>
</Space>
<Space>
<Button
icon={<SettingOutlined />}
onClick={() => setSettingsOpen(true)}
/>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => handleAddItem(selectedDate ?? dayjs().format('YYYY-MM-DD'))}
>
{!isMobile && 'Add Event'}
</Button>
</Space>
</div>
{isMobile ? (
/* Mobile layout: MobileDayView with layer toggles at top */
<div>
<CalendarLayerPanel
layers={layers}
compact
onToggle={(id, enabled) => handleUpdateLayer(id, { isEnabled: enabled })}
onCreate={handleCreateLayer}
onUpdate={handleUpdateLayer}
onDelete={handleDeleteLayer}
/>
<MobileDayView
items={filteredItems}
currentMonth={currentMonth}
selectedDate={selectedDate}
onDateSelect={handleDateSelect}
onMonthChange={handleMonthChange}
onItemClick={handleEditItem}
onAddItem={handleAddItem}
/>
{dateDetailPanel}
</div>
) : (
/* Desktop layout: layer panel | calendar | date detail */
<div style={{ display: 'flex', gap: 0 }}>
<div style={{ width: 240, flexShrink: 0, paddingRight: 16 }}>
<CalendarLayerPanel
layers={layers}
onToggle={(id, enabled) => handleUpdateLayer(id, { isEnabled: enabled })}
onCreate={handleCreateLayer}
onUpdate={handleUpdateLayer}
onDelete={handleDeleteLayer}
/>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<PersonalCalendarView
items={filteredItems}
currentMonth={currentMonth}
selectedDate={selectedDate}
onDateSelect={handleDateSelect}
onMonthChange={handleMonthChange}
onItemClick={handleEditItem}
/>
</div>
{dateDetailPanel}
</div>
)}
{/* Settings drawer */}
<Drawer
title="Calendar Settings"
open={settingsOpen}
onClose={() => {
setSettingsOpen(false);
fetchItems();
}}
width={400}
>
<CalendarFeedsPanel />
<Divider />
<CalendarExportPanel layers={layers} />
</Drawer>
{/* Item create/edit modal */}
<CalendarItemModal
open={itemModalOpen}
item={editingItem}
layers={layers.filter((l) => l.layerType === 'USER')}
defaultDate={selectedDate ?? dayjs().format('YYYY-MM-DD')}
onSave={handleSaveItem}
onDelete={editingItem ? () => confirmDeleteItem(editingItem) : undefined}
onCancel={() => {
setItemModalOpen(false);
setEditingItem(null);
}}
/>
</div>
</FeatureGate>
);
}

View File

@ -1,159 +0,0 @@
import { useState, useEffect } from 'react';
import {
Typography, Card, Row, Col, Tag, Button, Spin, Empty, Space, Grid,
} from 'antd';
import {
CalendarOutlined, ClockCircleOutlined, EnvironmentOutlined,
TagOutlined,
} from '@ant-design/icons';
import { api } from '@/lib/api';
import { useNavigate } from 'react-router-dom';
import dayjs from 'dayjs';
const { Title, Text } = Typography;
interface MyTicket {
id: string;
ticketCode: string;
status: string;
holderName: string | null;
holderEmail: string;
issuedAt: string;
checkedInAt: string | null;
tier: { name: string; tierType: string; priceCAD: number };
event: {
id: string;
slug: string;
title: string;
date: string;
startTime: string;
endTime: string;
venueName: string | null;
coverImageUrl: string | null;
};
qrUrl: string;
}
export default function MyTicketsPage() {
const navigate = useNavigate();
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const [tickets, setTickets] = useState<MyTicket[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchTickets();
}, []);
const fetchTickets = async () => {
setLoading(true);
try {
const { data } = await api.get('/ticketed-events/my-tickets');
setTickets(data.tickets);
} catch {
// silent
} finally {
setLoading(false);
}
};
if (loading) {
return (
<div style={{ textAlign: 'center', padding: 80 }}>
<Spin size="large" />
</div>
);
}
if (tickets.length === 0) {
return (
<div style={{ padding: isMobile ? 16 : 24 }}>
<Title level={3}><TagOutlined /> My Tickets</Title>
<Empty
description="You don't have any tickets yet"
image={Empty.PRESENTED_IMAGE_SIMPLE}
>
<Button type="primary" onClick={() => navigate('/events')}>Browse Events</Button>
</Empty>
</div>
);
}
// Group tickets by event
const grouped = tickets.reduce<Record<string, MyTicket[]>>((acc, t) => {
const key = t.event.id;
if (!acc[key]) acc[key] = [];
acc[key].push(t);
return acc;
}, {});
const statusColors: Record<string, string> = {
VALID: 'green',
CHECKED_IN: 'blue',
CANCELLED: 'red',
REFUNDED: 'orange',
};
return (
<div style={{ padding: isMobile ? 16 : 24 }}>
<Title level={3}><TagOutlined /> My Tickets</Title>
{Object.entries(grouped).map(([eventId, eventTickets]) => {
const ev = eventTickets[0]!.event;
const isPast = dayjs(ev.date).isBefore(dayjs(), 'day');
return (
<Card
key={eventId}
style={{ marginBottom: 16, opacity: isPast ? 0.7 : 1 }}
title={
<Space>
<Text strong>{ev.title}</Text>
{isPast && <Tag>Past</Tag>}
</Space>
}
extra={
<Button size="small" onClick={() => navigate(`/event/${ev.slug}`)}>
View Event
</Button>
}
>
<Space wrap style={{ marginBottom: 12 }}>
<Tag icon={<CalendarOutlined />}>{dayjs(ev.date).format('MMM D, YYYY')}</Tag>
<Tag icon={<ClockCircleOutlined />}>{ev.startTime} {ev.endTime}</Tag>
{ev.venueName && <Tag icon={<EnvironmentOutlined />}>{ev.venueName}</Tag>}
</Space>
<Row gutter={[12, 12]}>
{eventTickets.map(ticket => (
<Col xs={24} sm={12} md={8} key={ticket.id}>
<Card
size="small"
hoverable
onClick={() => navigate(`/event/${ev.slug}/ticket/${ticket.ticketCode}`)}
style={{ textAlign: 'center' }}
>
<img
src={ticket.qrUrl}
alt={`QR ${ticket.ticketCode}`}
style={{ width: 120, height: 120, marginBottom: 8 }}
/>
<div>
<Text code style={{ fontSize: 14 }}>{ticket.ticketCode}</Text>
</div>
<div style={{ marginTop: 4 }}>
<Tag color={statusColors[ticket.status]} style={{ fontSize: 11 }}>
{ticket.status}
</Tag>
</div>
<Text type="secondary" style={{ fontSize: 12 }}>{ticket.tier.name}</Text>
</Card>
</Col>
))}
</Row>
</Card>
);
})}
</div>
);
}

View File

@ -1,217 +0,0 @@
import { useEffect, useState, useCallback } from 'react';
import {
Card, Button, Row, Col, Statistic, Table, Typography, Space,
Input, InputNumber, Form, Modal, Spin, Grid, App, Empty,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { PlusOutlined, GiftOutlined, TeamOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import { api } from '@/lib/api';
import InviteCodeCard from '@/components/social/InviteCodeCard';
interface InviteCode {
id: string;
code: string;
maxUses: number;
usedCount: number;
expiresAt: string | null;
isActive: boolean;
note: string | null;
createdAt: string;
_count?: { referrals: number };
}
interface ReferralRow {
id: number;
completedAt: string;
referralSource: string | null;
referredUser: { id: string; name: string | null; email: string };
}
interface ReferralStats {
totalReferrals: number;
thisMonth: number;
}
export default function ReferralsPage() {
const { message } = App.useApp();
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const [codes, setCodes] = useState<InviteCode[]>([]);
const [referrals, setReferrals] = useState<ReferralRow[]>([]);
const [stats, setStats] = useState<ReferralStats | null>(null);
const [loading, setLoading] = useState(true);
const [createOpen, setCreateOpen] = useState(false);
const [creating, setCreating] = useState(false);
const [deactivatingId, setDeactivatingId] = useState<string | null>(null);
const [refPage, setRefPage] = useState(1);
const [refTotal, setRefTotal] = useState(0);
const [form] = Form.useForm();
const loadData = useCallback(async () => {
setLoading(true);
try {
const [codesRes, referralsRes, statsRes] = await Promise.all([
api.get('/social/referrals/codes', { params: { limit: 50 } }),
api.get('/social/referrals/my-referrals', { params: { page: refPage, limit: 10 } }),
api.get('/social/referrals/stats'),
]);
setCodes(codesRes.data.codes);
setReferrals(referralsRes.data.referrals);
setRefTotal(referralsRes.data.pagination.total);
setStats(statsRes.data);
} catch {
message.error('Failed to load referral data');
} finally {
setLoading(false);
}
}, [refPage]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => { loadData(); }, [loadData]);
const handleCreate = async (values: { maxUses?: number; expiresInDays?: number; note?: string }) => {
setCreating(true);
try {
await api.post('/social/referrals/codes', values);
message.success('Invite code created');
setCreateOpen(false);
form.resetFields();
loadData();
} catch {
message.error('Failed to create invite code');
} finally {
setCreating(false);
}
};
const handleDeactivate = async (id: string) => {
setDeactivatingId(id);
try {
await api.delete(`/social/referrals/codes/${id}`);
message.success('Code deactivated');
loadData();
} catch {
message.error('Failed to deactivate code');
} finally {
setDeactivatingId(null);
}
};
const referralColumns: ColumnsType<ReferralRow> = [
{
title: 'Person',
key: 'person',
render: (_: unknown, r: ReferralRow) => r.referredUser.name || r.referredUser.email,
},
{
title: 'Date',
dataIndex: 'completedAt',
key: 'date',
render: (v: string) => dayjs(v).format('MMM D, YYYY'),
},
];
if (loading) {
return <div style={{ textAlign: 'center', padding: 48 }}><Spin size="large" /></div>;
}
return (
<div style={{ padding: isMobile ? 16 : 24 }}>
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
<Typography.Title level={4} style={{ margin: 0 }}>My Referrals</Typography.Title>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
Create Invite Code
</Button>
</div>
{/* Stats */}
<Row gutter={[16, 16]}>
<Col xs={12} sm={8}>
<Card size="small">
<Statistic
title="Total Referrals"
value={stats?.totalReferrals ?? 0}
prefix={<TeamOutlined />}
/>
</Card>
</Col>
<Col xs={12} sm={8}>
<Card size="small">
<Statistic
title="This Month"
value={stats?.thisMonth ?? 0}
prefix={<GiftOutlined />}
/>
</Card>
</Col>
<Col xs={12} sm={8}>
<Card size="small">
<Statistic title="Active Codes" value={codes.filter((c) => c.isActive).length} />
</Card>
</Col>
</Row>
{/* Invite Codes */}
<Card title="My Invite Codes" size="small">
{codes.length === 0 ? (
<Empty description="No invite codes yet. Create one to start referring people!" />
) : (
<Space direction="vertical" style={{ width: '100%' }} size="small">
{codes.map((code) => (
<InviteCodeCard
key={code.id}
code={code}
onDeactivate={handleDeactivate}
deactivating={deactivatingId === code.id}
/>
))}
</Space>
)}
</Card>
{/* My Referrals */}
<Card title="People I Referred" size="small">
<Table
dataSource={referrals}
columns={referralColumns}
rowKey="id"
size="small"
pagination={{
current: refPage,
total: refTotal,
pageSize: 10,
onChange: setRefPage,
showSizeChanger: false,
}}
locale={{ emptyText: 'No referrals yet' }}
/>
</Card>
</Space>
{/* Create Code Modal */}
<Modal
title="Create Invite Code"
open={createOpen}
onCancel={() => { setCreateOpen(false); form.resetFields(); }}
onOk={() => form.submit()}
confirmLoading={creating}
destroyOnHidden
>
<Form form={form} layout="vertical" onFinish={handleCreate}>
<Form.Item name="maxUses" label="Max Uses (0 = unlimited)">
<InputNumber min={0} style={{ width: '100%' }} placeholder="0" />
</Form.Item>
<Form.Item name="expiresInDays" label="Expires In (days)">
<InputNumber min={1} max={365} style={{ width: '100%' }} placeholder="No expiry" />
</Form.Item>
<Form.Item name="note" label="Note">
<Input.TextArea maxLength={200} rows={2} placeholder="Optional note for your reference" />
</Form.Item>
</Form>
</Modal>
</div>
);
}

View File

@ -1,347 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
Typography,
Button,
Grid,
Skeleton,
Empty,
List,
Tag,
Space,
Switch,
Tabs,
message,
theme,
} from 'antd';
import {
ArrowLeftOutlined,
CalendarOutlined,
ClockCircleOutlined,
EnvironmentOutlined,
} from '@ant-design/icons';
import dayjs, { Dayjs } from 'dayjs';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth.store';
import FeatureGate from '@/components/FeatureGate';
import PersonalCalendarView from '@/components/calendar/PersonalCalendarView';
import MobileDayView from '@/components/calendar/MobileDayView';
import SharedViewMembersPanel from '@/components/calendar/SharedViewMembersPanel';
import AvailabilityFinder from '@/components/calendar/AvailabilityFinder';
import CalendarComments from '@/components/calendar/CalendarComments';
import CalendarReactions from '@/components/calendar/CalendarReactions';
import type {
SharedCalendarView,
SharedCalendarMember,
SharedCalendarItem,
SharedViewReactionGroup,
} from '@/types/api';
const { Title, Text } = Typography;
export default function SharedCalendarViewPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const { token } = theme.useToken();
const { user } = useAuthStore();
const currentUserId = user?.id || '';
const [view, setView] = useState<SharedCalendarView | null>(null);
const [members, setMembers] = useState<SharedCalendarMember[]>([]);
const [items, setItems] = useState<SharedCalendarItem[]>([]);
const [reactions, setReactions] = useState<Record<string, SharedViewReactionGroup[]>>({});
const [loading, setLoading] = useState(true);
const [currentMonth, setCurrentMonth] = useState<Dayjs>(dayjs());
const [selectedDate, setSelectedDate] = useState<string | null>(null);
const [showAvailability, setShowAvailability] = useState(false);
const fetchView = useCallback(async () => {
if (!id) return;
try {
const { data } = await api.get<SharedCalendarView>(`/calendar/shared/${id}`);
setView(data);
} catch {
message.error('Failed to load shared calendar');
navigate('/volunteer/calendar/shared');
}
}, [id, navigate]);
const fetchMembers = useCallback(async () => {
if (!id) return;
try {
const { data } = await api.get<{ members: SharedCalendarMember[] }>(`/calendar/shared/${id}/members`);
setMembers(data.members);
} catch {
// ignore
}
}, [id]);
const fetchItems = useCallback(async () => {
if (!id) return;
const startDate = currentMonth.startOf('month').subtract(7, 'day').format('YYYY-MM-DD');
const endDate = currentMonth.endOf('month').add(7, 'day').format('YYYY-MM-DD');
try {
const { data } = await api.get<{ items: SharedCalendarItem[] }>(`/calendar/shared/${id}/items`, {
params: { startDate, endDate },
});
setItems(data.items);
} catch {
// ignore
}
}, [id, currentMonth]);
const fetchReactions = useCallback(async () => {
if (!id || !selectedDate) return;
try {
const { data } = await api.get<Record<string, SharedViewReactionGroup[]>>(
`/calendar/shared/${id}/reactions`,
{ params: { date: selectedDate } },
);
setReactions(data);
} catch {
// ignore
}
}, [id, selectedDate]);
useEffect(() => {
const load = async () => {
setLoading(true);
await Promise.all([fetchView(), fetchMembers(), fetchItems()]);
setLoading(false);
};
load();
}, [fetchView, fetchMembers, fetchItems]);
useEffect(() => {
if (selectedDate) fetchReactions();
}, [fetchReactions, selectedDate]);
const isOwner = view?.ownerId === currentUserId;
const selectedDateItems = selectedDate
? items.filter((item) => item.date === selectedDate)
: [];
const handleLeave = async () => {
if (!id) return;
try {
await api.delete(`/calendar/shared/${id}/leave`);
message.success('Left shared calendar');
navigate('/volunteer/calendar/shared');
} catch {
message.error('Failed to leave');
}
};
// Noop for read-only calendar item click
const handleItemClick = () => {};
if (loading) {
return (
<div style={{ padding: '12px 0' }}>
<Skeleton active paragraph={{ rows: 10 }} />
</div>
);
}
if (!view) return null;
const dateDetailPanel = selectedDate && (
<div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<Text strong style={{ fontSize: 15 }}>
{dayjs(selectedDate).format('ddd, MMM D')}
</Text>
</div>
{selectedDateItems.length === 0 ? (
<Empty description="No events" image={Empty.PRESENTED_IMAGE_SIMPLE} />
) : (
<List
size="small"
dataSource={selectedDateItems}
renderItem={(item) => (
<List.Item style={{ padding: '8px 0', flexDirection: 'column', alignItems: 'stretch' }}>
<List.Item.Meta
avatar={
<div
style={{
width: 4,
height: 32,
borderRadius: 2,
background: item.memberColor || item.color,
flexShrink: 0,
}}
/>
}
title={
<Space size={4}>
<Text style={{ fontSize: 13 }} ellipsis>{item.title}</Text>
<Text type="secondary" style={{ fontSize: 11 }}>
({item.memberName})
</Text>
</Space>
}
description={
<Space size={4} wrap style={{ fontSize: 11 }}>
{!item.isAllDay && (
<Tag icon={<ClockCircleOutlined />} style={{ fontSize: 11, margin: 0 }}>
{item.startTime?.slice(0, 5)} - {item.endTime?.slice(0, 5)}
</Tag>
)}
{item.isAllDay && <Tag style={{ fontSize: 11, margin: 0 }}>All day</Tag>}
{item.location && (
<Tag icon={<EnvironmentOutlined />} style={{ fontSize: 11, margin: 0 }}>
{item.location}
</Tag>
)}
</Space>
}
/>
<CalendarReactions
viewId={id!}
itemId={item.id}
reactions={reactions[item.id] || []}
currentUserId={currentUserId}
onUpdate={fetchReactions}
/>
</List.Item>
)}
/>
)}
<div style={{ marginTop: 16 }}>
<CalendarComments
viewId={id!}
date={selectedDate}
currentUserId={currentUserId}
/>
</div>
</div>
);
const membersPanel = (
<SharedViewMembersPanel
viewId={id!}
members={members}
isOwner={isOwner}
onInvite={fetchMembers}
onLeave={handleLeave}
onRefresh={fetchMembers}
/>
);
const availabilityPanel = (
<AvailabilityFinder viewId={id!} />
);
if (isMobile) {
return (
<FeatureGate feature="enableSocialCalendar">
<div style={{ padding: '12px 0' }}>
<Space style={{ marginBottom: 12 }}>
<Button
type="text"
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/volunteer/calendar/shared')}
/>
<Title level={5} style={{ margin: 0 }}>{view.name}</Title>
</Space>
<Tabs
defaultActiveKey="calendar"
items={[
{
key: 'calendar',
label: 'Calendar',
children: (
<>
<MobileDayView
items={items}
currentMonth={currentMonth}
selectedDate={selectedDate}
onDateSelect={setSelectedDate}
onMonthChange={setCurrentMonth}
onAddItem={() => {}}
onItemClick={handleItemClick}
/>
{dateDetailPanel}
</>
),
},
{ key: 'members', label: 'Members', children: membersPanel },
{ key: 'availability', label: 'Availability', children: availabilityPanel },
]}
/>
</div>
</FeatureGate>
);
}
return (
<FeatureGate feature="enableSocialCalendar">
<div style={{ padding: '12px 0' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
<Space>
<Button
type="text"
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/volunteer/calendar/shared')}
/>
<CalendarOutlined style={{ fontSize: 18 }} />
<Title level={4} style={{ margin: 0 }}>{view.name}</Title>
</Space>
<Space>
<Text style={{ fontSize: 12 }}>Availability</Text>
<Switch
size="small"
checked={showAvailability}
onChange={setShowAvailability}
/>
</Space>
</div>
{showAvailability ? (
<div style={{ display: 'flex', gap: 16 }}>
<div style={{ width: 260, flexShrink: 0 }}>
{membersPanel}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
{availabilityPanel}
</div>
</div>
) : (
<div style={{ display: 'flex', gap: 0 }}>
<div style={{ width: 260, flexShrink: 0, paddingRight: 16 }}>
{membersPanel}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<PersonalCalendarView
items={items}
currentMonth={currentMonth}
selectedDate={selectedDate}
onDateSelect={setSelectedDate}
onItemClick={handleItemClick}
onMonthChange={setCurrentMonth}
/>
</div>
{selectedDate && (
<div
style={{
width: 280,
flexShrink: 0,
padding: '0 0 0 16px',
borderLeft: `1px solid ${token.colorBorderSecondary}`,
}}
>
{dateDetailPanel}
</div>
)}
</div>
)}
</div>
</FeatureGate>
);
}

View File

@ -1,255 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import {
Typography,
Button,
Card,
Row,
Col,
Modal,
Form,
Input,
Select,
Checkbox,
Space,
Tag,
Empty,
Skeleton,
Grid,
message,
Segmented,
} from 'antd';
import {
PlusOutlined,
CalendarOutlined,
TeamOutlined,
GlobalOutlined,
CheckOutlined,
CloseOutlined,
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { api } from '@/lib/api';
import FeatureGate from '@/components/FeatureGate';
import type { SharedCalendarView, SharedViewScope } from '@/types/api';
const { Text, Paragraph } = Typography;
const LAYER_TYPE_OPTIONS = [
{ label: 'Personal Events', value: 'USER' },
{ label: 'Shifts', value: 'SYSTEM_SHIFTS' },
{ label: 'Ticketed Events', value: 'SYSTEM_EVENTS' },
{ label: 'Scheduling Polls', value: 'SYSTEM_POLLS' },
];
export default function SharedCalendarsPage() {
const navigate = useNavigate();
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const [views, setViews] = useState<SharedCalendarView[]>([]);
const [loading, setLoading] = useState(true);
const [createModalOpen, setCreateModalOpen] = useState(false);
const [creating, setCreating] = useState(false);
const [form] = Form.useForm();
const fetchViews = useCallback(async () => {
try {
const { data } = await api.get<{ views: SharedCalendarView[] }>('/calendar/shared');
setViews(data.views);
} catch {
// ignore
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchViews();
}, [fetchViews]);
const handleCreate = async (values: any) => {
setCreating(true);
try {
await api.post('/calendar/shared', {
name: values.name,
description: values.description || null,
includedLayerTypes: values.includedLayerTypes || ['USER'],
shareScope: values.shareScope || 'MEMBERS',
});
message.success('Shared calendar created');
setCreateModalOpen(false);
form.resetFields();
await fetchViews();
} catch {
message.error('Failed to create shared calendar');
} finally {
setCreating(false);
}
};
const handleRespond = async (viewId: string, status: 'ACCEPTED' | 'DECLINED') => {
try {
await api.patch(`/calendar/shared/${viewId}/respond`, { status });
message.success(status === 'ACCEPTED' ? 'Invitation accepted' : 'Invitation declined');
await fetchViews();
} catch {
message.error('Failed to respond to invitation');
}
};
const scopeIcon = (scope: SharedViewScope) =>
scope === 'PUBLIC' ? <GlobalOutlined /> : <TeamOutlined />;
return (
<FeatureGate feature="enableSocialCalendar">
<div style={{ padding: '12px 0' }}>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 16,
}}
>
<Space>
<CalendarOutlined style={{ fontSize: 20 }} />
<Segmented
options={['My Calendar', 'Shared']}
value="Shared"
onChange={(val) => {
if (val === 'My Calendar') navigate('/volunteer/calendar');
}}
/>
</Space>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setCreateModalOpen(true)}
>
{!isMobile && 'Create'}
</Button>
</div>
{loading ? (
<Skeleton active paragraph={{ rows: 6 }} />
) : views.length === 0 ? (
<Empty description="No shared calendars yet. Create one or wait for an invite." />
) : (
<Row gutter={[12, 12]}>
{views.map((view) => (
<Col key={view.id} xs={24} sm={12} lg={8}>
<Card
hoverable
onClick={() => {
if (view.myStatus === 'ACCEPTED' || view.ownerId === view.owner?.id) {
navigate(`/volunteer/calendar/shared/${view.id}`);
}
}}
style={{ height: '100%' }}
>
<Space direction="vertical" style={{ width: '100%' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<Text strong style={{ fontSize: 15 }}>{view.name}</Text>
<Tag icon={scopeIcon(view.shareScope)} style={{ margin: 0 }}>
{view.shareScope}
</Tag>
</div>
{view.description && (
<Paragraph type="secondary" ellipsis={{ rows: 2 }} style={{ margin: 0, fontSize: 13 }}>
{view.description}
</Paragraph>
)}
<Space size={4}>
<TeamOutlined />
<Text type="secondary" style={{ fontSize: 12 }}>
{view._count?.members || 0} member{(view._count?.members || 0) !== 1 ? 's' : ''}
</Text>
{view.owner && (
<Text type="secondary" style={{ fontSize: 12 }}>
by {view.owner.name || view.owner.email}
</Text>
)}
</Space>
{view.myStatus === 'INVITED' && (
<Space style={{ marginTop: 4 }}>
<Button
size="small"
type="primary"
icon={<CheckOutlined />}
onClick={(e) => {
e.stopPropagation();
handleRespond(view.id, 'ACCEPTED');
}}
>
Accept
</Button>
<Button
size="small"
danger
icon={<CloseOutlined />}
onClick={(e) => {
e.stopPropagation();
handleRespond(view.id, 'DECLINED');
}}
>
Decline
</Button>
</Space>
)}
{view.myStatus === 'DECLINED' && (
<Tag color="red" style={{ marginTop: 4 }}>Declined</Tag>
)}
</Space>
</Card>
</Col>
))}
</Row>
)}
<Modal
title="Create Shared Calendar"
open={createModalOpen}
onCancel={() => setCreateModalOpen(false)}
onOk={() => form.submit()}
confirmLoading={creating}
okText="Create"
>
<Form form={form} layout="vertical" onFinish={handleCreate}>
<Form.Item
name="name"
label="Name"
rules={[{ required: true, message: 'Please enter a name' }]}
>
<Input placeholder="e.g. Team Calendar" />
</Form.Item>
<Form.Item name="description" label="Description">
<Input.TextArea rows={2} placeholder="Optional description" />
</Form.Item>
<Form.Item
name="includedLayerTypes"
label="Include Layer Types"
initialValue={['USER']}
>
<Checkbox.Group options={LAYER_TYPE_OPTIONS} />
</Form.Item>
<Form.Item
name="shareScope"
label="Visibility"
initialValue="MEMBERS"
>
<Select
options={[
{ value: 'MEMBERS', label: 'Members Only' },
{ value: 'PUBLIC', label: 'Public (anyone with link)' },
]}
/>
</Form.Item>
</Form>
</Modal>
</div>
</FeatureGate>
);
}

View File

@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Typography, Skeleton, Empty, Pagination, Button, Space, Card, List, Tag } from 'antd'; import { Typography, Skeleton, Empty, Pagination, Button, Space, Card, List } from 'antd';
import { TeamOutlined, CompassOutlined, TrophyOutlined, FlagOutlined, ThunderboltOutlined, GiftOutlined } from '@ant-design/icons'; import { TeamOutlined, CompassOutlined, TrophyOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import FeedCard from '@/components/social/FeedCard'; import FeedCard from '@/components/social/FeedCard';
@ -26,14 +26,12 @@ export default function SocialFeedPage() {
const [items, setItems] = useState<FeedItem[]>([]); const [items, setItems] = useState<FeedItem[]>([]);
const [pagination, setPagination] = useState<PaginationMeta | null>(null); const [pagination, setPagination] = useState<PaginationMeta | null>(null);
const [topVolunteers, setTopVolunteers] = useState<LeaderboardEntry[]>([]); const [topVolunteers, setTopVolunteers] = useState<LeaderboardEntry[]>([]);
const [activeChallenge, setActiveChallenge] = useState<{ id: string; title: string; teamName: string; score: number } | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
useEffect(() => { useEffect(() => {
loadFeed(page); loadFeed(page);
loadTopVolunteers(); loadTopVolunteers();
loadActiveChallenge();
}, [page]); }, [page]);
const loadFeed = async (p: number) => { const loadFeed = async (p: number) => {
@ -54,22 +52,6 @@ export default function SocialFeedPage() {
} catch {} } catch {}
}; };
const loadActiveChallenge = async () => {
try {
const { data } = await api.get('/social/challenges', { params: { status: 'ACTIVE' } });
const challenges = data.challenges || [];
for (const c of challenges) {
try {
const { data: teamData } = await api.get(`/social/challenges/${c.id}/my-team`);
if (teamData.team) {
setActiveChallenge({ id: c.id, title: c.title, teamName: teamData.team.name, score: teamData.team.score });
break;
}
} catch {}
}
} catch {}
};
return ( return (
<div style={{ padding: '12px 0' }}> <div style={{ padding: '12px 0' }}>
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 12 }}> <Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 12 }}>
@ -81,38 +63,9 @@ export default function SocialFeedPage() {
<Button size="small" icon={<TeamOutlined />} onClick={() => navigate('/volunteer/friends')}> <Button size="small" icon={<TeamOutlined />} onClick={() => navigate('/volunteer/friends')}>
Friends Friends
</Button> </Button>
<Button size="small" icon={<GiftOutlined />} onClick={() => navigate('/volunteer/referrals')}>
Referrals
</Button>
</Space> </Space>
</Space> </Space>
{activeChallenge && (
<Card
size="small"
style={{
marginBottom: 12,
borderLeft: '3px solid #52c41a',
cursor: 'pointer',
}}
onClick={() => navigate(`/volunteer/challenges/${activeChallenge.id}`)}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<ThunderboltOutlined style={{ color: '#52c41a', fontSize: 18 }} />
<div style={{ flex: 1 }}>
<Typography.Text strong style={{ fontSize: 13 }}>
<FlagOutlined style={{ marginRight: 4 }} />
Active Challenge: {activeChallenge.title}
</Typography.Text>
<Typography.Text type="secondary" style={{ display: 'block', fontSize: 12 }}>
Team "{activeChallenge.teamName}" {activeChallenge.score} pts
</Typography.Text>
</div>
<Tag color="green">LIVE</Tag>
</div>
</Card>
)}
<FriendSuggestions limit={5} /> <FriendSuggestions limit={5} />
{topVolunteers.length > 0 && ( {topVolunteers.length > 0 && (

View File

@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useParams, Link } from 'react-router-dom'; import { useParams, Link } from 'react-router-dom';
import { Card, Typography, Statistic, Row, Col, Skeleton, Space, Tag, Alert, Button } from 'antd'; import { Card, Typography, Statistic, Row, Col, Skeleton, Space, Tag, Alert } from 'antd';
import { TeamOutlined, ScheduleOutlined, EnvironmentOutlined, TrophyOutlined, CalendarOutlined } from '@ant-design/icons'; import { TeamOutlined, ScheduleOutlined, EnvironmentOutlined, TrophyOutlined } from '@ant-design/icons';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth.store'; import { useAuthStore } from '@/stores/auth.store';
import UserAvatar from '@/components/social/UserAvatar'; import UserAvatar from '@/components/social/UserAvatar';
@ -145,11 +145,6 @@ export default function SocialProfilePage() {
friendId={user.id} friendId={user.id}
isFriend={friendshipStatus.status === 'accepted'} isFriend={friendshipStatus.status === 'accepted'}
/> />
{friendshipStatus.status === 'accepted' && (
<Link to={`/volunteer/calendar/friend/${user.id}`}>
<Button icon={<CalendarOutlined />}>View Calendar</Button>
</Link>
)}
</Space> </Space>
</Space> </Space>
</Card> </Card>

View File

@ -1,12 +1,8 @@
import { useState, useEffect, useCallback, useRef } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import { ConfigProvider, App, Button, Spin, Result, theme } from 'antd'; import { Button, Spin, Result, Grid } from 'antd';
import { MessageOutlined } from '@ant-design/icons'; import { MessageOutlined } from '@ant-design/icons';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { buildServiceUrl } from '@/lib/service-url'; import { buildServiceUrl } from '@/lib/service-url';
import { useSettingsStore } from '@/stores/settings.store';
import VolunteerFooterNav from '@/components/VolunteerFooterNav';
const FOOTER_HEIGHT = 44;
interface RCConfig { interface RCConfig {
enabled: boolean; enabled: boolean;
@ -21,7 +17,8 @@ interface RCAuthResponse {
} }
export default function VolunteerChatPage() { export default function VolunteerChatPage() {
const { settings } = useSettingsStore(); const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const [online, setOnline] = useState<boolean | null>(null); const [online, setOnline] = useState<boolean | null>(null);
const [rcConfig, setRcConfig] = useState<RCConfig | null>(null); const [rcConfig, setRcConfig] = useState<RCConfig | null>(null);
@ -57,38 +54,30 @@ export default function VolunteerChatPage() {
fetchAndAuth(); fetchAndAuth();
}, [fetchAndAuth]); }, [fetchAndAuth]);
// Retry postMessage pattern — RC's iframe listener may not be ready on first load
const retryTimers = useRef<ReturnType<typeof setTimeout>[]>([]);
const handleIframeLoad = useCallback(() => { const handleIframeLoad = useCallback(() => {
retryTimers.current.forEach(clearTimeout); if (authToken && iframeRef.current?.contentWindow) {
retryTimers.current = [];
if (!authToken || !iframeRef.current?.contentWindow) return;
const sendToken = () => {
if (!iframeRef.current?.contentWindow) return;
iframeRef.current.contentWindow.postMessage( iframeRef.current.contentWindow.postMessage(
{ event: 'login-with-token', loginToken: authToken }, { externalCommand: 'login-with-token', token: authToken },
'*', '*',
); );
}; }
sendToken();
retryTimers.current.push(setTimeout(sendToken, 1000));
retryTimers.current.push(setTimeout(sendToken, 3000));
}, [authToken]); }, [authToken]);
useEffect(() => { if (isMobile) {
return () => retryTimers.current.forEach(clearTimeout); return (
}, []); <div style={{ padding: 24, textAlign: 'center' }}>
<Result
icon={<MessageOutlined style={{ fontSize: 48 }} />}
title="Desktop Recommended"
subTitle="Chat works best on a larger screen."
/>
</div>
);
}
const contentHeight = `calc(100dvh - ${FOOTER_HEIGHT}px)`;
const renderContent = () => {
if (loading) { if (loading) {
return ( return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: contentHeight, background: '#0d1b2a' }}> <div style={{ textAlign: 'center', padding: 80 }}>
<Spin size="large" /> <Spin size="large" />
</div> </div>
); );
@ -96,20 +85,15 @@ export default function VolunteerChatPage() {
if (!rcConfig?.enabled) { if (!rcConfig?.enabled) {
return ( return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: contentHeight, background: '#0d1b2a' }}> <div style={{ padding: 24 }}>
<Result <Result status="info" title="Chat Not Available" subTitle="Team chat has not been enabled yet." />
icon={<MessageOutlined style={{ fontSize: 48 }} />}
status="info"
title="Chat Not Available"
subTitle="Team chat has not been enabled yet."
/>
</div> </div>
); );
} }
if (!online || error) { if (!online || error) {
return ( return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: contentHeight, background: '#0d1b2a' }}> <div style={{ padding: 24 }}>
<Result <Result
status="error" status="error"
title="Chat Unavailable" title="Chat Unavailable"
@ -121,15 +105,16 @@ export default function VolunteerChatPage() {
} }
const serviceUrl = buildServiceUrl(rcConfig.subdomain, rcConfig.domain, rcConfig.embedPort); const serviceUrl = buildServiceUrl(rcConfig.subdomain, rcConfig.domain, rcConfig.embedPort);
const iframeSrc = `${serviceUrl}/channel/general?layout=embedded`;
return ( return (
<iframe <iframe
ref={iframeRef} ref={iframeRef}
src={`${serviceUrl}/channel/general`} src={iframeSrc}
onLoad={handleIframeLoad} onLoad={handleIframeLoad}
style={{ style={{
width: '100%', width: '100%',
height: contentHeight, height: 'calc(100vh - 64px)',
border: 'none', border: 'none',
display: 'block', display: 'block',
}} }}
@ -137,38 +122,4 @@ export default function VolunteerChatPage() {
allow="microphone; camera" allow="microphone; camera"
/> />
); );
};
return (
<ConfigProvider
theme={{
algorithm: theme.darkAlgorithm,
token: {
colorPrimary: settings?.publicColorPrimary ?? '#3498db',
colorBgBase: settings?.publicColorBgBase ?? '#0d1b2a',
colorBgContainer: settings?.publicColorBgContainer ?? '#1b2838',
colorBgElevated: settings?.publicColorBgContainer ?? '#1b2838',
borderRadius: 8,
},
}}
>
<App>
<div style={{ height: '100dvh', overflow: 'hidden', background: '#0d1b2a' }}>
{/* Chat content — RC's full UI provides its own header/nav */}
{renderContent()}
{/* Footer nav */}
<VolunteerFooterNav
style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
zIndex: 1200,
}}
/>
</div>
</App>
</ConfigProvider>
);
} }

View File

@ -103,7 +103,7 @@ export default function VolunteerMapPage() {
const sessionActive = mode === 'session' && !!session; const sessionActive = mode === 'session' && !!session;
// Footer nav height for positioning (no session bar, controls integrated into bottom panel) // Footer nav height for positioning (no session bar, controls integrated into bottom panel)
const FOOTER_HEIGHT = 44; const FOOTER_HEIGHT = 56;
// ─── Initialize ────────────────────────────────────────────────── // ─── Initialize ──────────────────────────────────────────────────
useEffect(() => { useEffect(() => {
@ -418,7 +418,7 @@ export default function VolunteerMapPage() {
if (loading) { if (loading) {
return ( return (
<div style={{ height: '100dvh', display: 'flex', justifyContent: 'center', alignItems: 'center', background: '#0d1b2a' }}> <div style={{ height: '100vh', display: 'flex', justifyContent: 'center', alignItems: 'center', background: '#0d1b2a' }}>
<Spin size="large" /> <Spin size="large" />
</div> </div>
); );
@ -444,8 +444,8 @@ export default function VolunteerMapPage() {
position: 'relative', position: 'relative',
width: '100vw', width: '100vw',
height: drawerOpen height: drawerOpen
? `calc(100dvh - ${FOOTER_HEIGHT}px - ${menuDrawerHeight}px)` ? `calc(100vh - ${FOOTER_HEIGHT}px - ${menuDrawerHeight}px)`
: '100dvh', : '100vh',
transition: 'height 0.2s cubic-bezier(0.4, 0, 0.2, 1)', transition: 'height 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
overflow: 'hidden', overflow: 'hidden',
}} }}
@ -587,7 +587,7 @@ export default function VolunteerMapPage() {
header: { padding: '12px 16px 0' }, header: { padding: '12px 16px 0' },
body: { body: {
padding: '12px 16px', padding: '12px 16px',
maxHeight: `calc(100dvh - ${FOOTER_HEIGHT + 120}px - env(safe-area-inset-bottom))`, maxHeight: `calc(100vh - ${FOOTER_HEIGHT + 120}px - env(safe-area-inset-bottom))`,
overflow: 'auto', overflow: 'auto',
}, },
}} }}

View File

@ -16,7 +16,7 @@ interface AuthState {
interface AuthActions { interface AuthActions {
login: (email: string, password: string) => Promise<void>; login: (email: string, password: string) => Promise<void>;
register: (name: string, email: string, password: string, inviteCode?: string) => Promise<{ requiresVerification?: boolean }>; register: (name: string, email: string, password: string) => Promise<{ requiresVerification?: boolean }>;
logout: () => Promise<void>; logout: () => Promise<void>;
refresh: () => Promise<void>; refresh: () => Promise<void>;
fetchMe: () => Promise<void>; fetchMe: () => Promise<void>;
@ -61,14 +61,13 @@ export const useAuthStore = create<AuthState & AuthActions>()(
} }
}, },
register: async (name: string, email: string, password: string, inviteCode?: string) => { register: async (name: string, email: string, password: string) => {
set({ error: null, errorCode: null, isLoading: true, registrationMessage: null }); set({ error: null, errorCode: null, isLoading: true, registrationMessage: null });
try { try {
const { data } = await api.post<AuthResponse>('/auth/register', { const { data } = await api.post<AuthResponse>('/auth/register', {
name, name,
email, email,
password, password,
...(inviteCode ? { inviteCode } : {}),
}); });
// If verification is required, don't set tokens — user needs to verify email first // If verification is required, don't set tokens — user needs to verify email first

View File

@ -979,7 +979,6 @@ export interface LandingPage {
mkdocsHideToc: boolean; mkdocsHideToc: boolean;
mkdocsSkipExport: boolean; mkdocsSkipExport: boolean;
published: boolean; published: boolean;
listed: boolean;
seoTitle: string | null; seoTitle: string | null;
seoDescription: string | null; seoDescription: string | null;
seoImage: string | null; seoImage: string | null;
@ -1151,9 +1150,6 @@ export interface SiteSettings {
enableSocial: boolean; enableSocial: boolean;
enableMeet: boolean; enableMeet: boolean;
enableMeetingPlanner: boolean; enableMeetingPlanner: boolean;
enableTicketedEvents: boolean;
enableSocialCalendar: boolean;
requireEventApproval: boolean;
autoSyncPeopleToMap: boolean; autoSyncPeopleToMap: boolean;
// SMS connection config (only present from admin endpoint) // SMS connection config (only present from admin endpoint)
smsTermuxApiUrl?: string; smsTermuxApiUrl?: string;
@ -1210,14 +1206,13 @@ export interface Meeting {
export interface NavItem { export interface NavItem {
id: string; id: string;
label: string; label: string;
path: string; // Empty string '' for group items path: string;
icon: string; icon: string;
enabled: boolean; enabled: boolean;
order: number; order: number;
type: 'builtin' | 'custom' | 'group'; type: 'builtin' | 'custom';
featureFlag?: string; featureFlag?: string;
external?: boolean; external?: boolean;
children?: NavItem[]; // One level deep only (for groups)
} }
export interface NavConfig { export interface NavConfig {
@ -1359,61 +1354,6 @@ export interface PangolinConnectedClient {
online: boolean; online: boolean;
} }
// --- Pangolin Resource Status ---
export interface ResourceStatusItem {
name: string;
subdomain: string | null;
fullDomain: string;
required: boolean;
container: string | null;
profile: string | null;
expectedTargetIp: string | null;
expectedTargetPort: number | null;
exists: boolean;
resourceId: string | number | null;
hasTarget: boolean;
targetCorrect: boolean;
actualTargetIp: string | null;
actualTargetPort: number | null;
targetId: string | null;
targetEnabled: boolean | null;
ssl: boolean | null;
sso: boolean | null;
blockAccess: boolean | null;
enabled: boolean | null;
}
export interface ResourceStatusResponse {
resources: ResourceStatusItem[];
extras: ResourceStatusItem[];
summary: {
total: number;
healthy: number;
misconfigured: number;
missing: number;
extras: number;
};
siteId: string;
domain: string;
}
export interface SyncResult {
success: boolean;
created: number;
targetFixed: number;
skipped: number;
warnings: number;
errors: number;
details: {
created: string[];
targetFixed: string[];
skipped: string[];
warnings: string[];
errors: string[];
};
}
// --- Listmonk --- // --- Listmonk ---
export interface ListmonkStatus { export interface ListmonkStatus {
@ -2313,7 +2253,7 @@ export interface DashboardRecentSignupsResult {
export interface UnifiedCalendarItem { export interface UnifiedCalendarItem {
id: string; id: string;
type: 'shift' | 'event' | 'poll' | 'ticketed_event'; type: 'shift' | 'event' | 'poll';
title: string; title: string;
date: string; date: string;
startTime: string; startTime: string;
@ -2329,13 +2269,6 @@ export interface UnifiedCalendarItem {
pollSlug?: string; pollSlug?: string;
pollStatus?: SchedulingPollStatus; pollStatus?: SchedulingPollStatus;
pollVoteCount?: number; pollVoteCount?: number;
ticketedEventId?: string;
eventSlug?: string;
eventFormat?: string;
hasPaidTiers?: boolean;
isSoldOut?: boolean;
maxAttendees?: number | null;
currentAttendees?: number;
} }
export interface UnifiedCalendarResponse { export interface UnifiedCalendarResponse {
@ -2970,226 +2903,3 @@ export interface UpgradeStatusResponse {
running: boolean; running: boolean;
} }
// --- Social Calendar Types ---
export type CalendarLayerType = 'SYSTEM' | 'USER' | 'EXTERNAL';
export type CalendarSystemType = 'SHIFTS' | 'TICKETS' | 'POLLS' | 'PUBLIC_EVENTS';
export type CalendarVisibility = 'PRIVATE' | 'FRIENDS' | 'PUBLIC';
export type CalendarItemType = 'EVENT' | 'TIME_BLOCK' | 'REMINDER';
export type CalendarBusyStatus = 'BUSY' | 'TENTATIVE' | 'FREE';
export type CalendarShowDetailsTo = 'NOBODY' | 'FRIENDS' | 'EVERYONE';
export type CalendarRecurrenceFrequency = 'DAILY' | 'WEEKLY' | 'BIWEEKLY' | 'MONTHLY';
export type SeriesEditScope = 'THIS_ONLY' | 'THIS_AND_FUTURE' | 'ALL';
export interface CalendarRecurrenceRule {
frequency: CalendarRecurrenceFrequency;
daysOfWeek?: number[]; // 1=Mon...7=Sun
dayOfMonth?: number;
interval?: number;
}
export interface CalendarLayer {
id: string;
userId: string;
name: string;
layerType: CalendarLayerType;
systemType: CalendarSystemType | null;
color: string;
visibility: CalendarVisibility;
isEnabled: boolean;
sortOrder: number;
}
export interface CalendarItemData {
id: string;
userId: string;
layerId: string;
title: string;
description: string | null;
date: string;
startTime: string;
endTime: string;
isAllDay: boolean;
itemType: CalendarItemType;
location: string | null;
color: string | null;
visibility: CalendarVisibility | null;
busyStatus: CalendarBusyStatus;
showDetailsTo: CalendarShowDetailsTo;
recurrenceRule: CalendarRecurrenceRule | null;
recurrenceEnd: string | null;
seriesId: string | null;
isException: boolean;
sourceType: 'MANUAL' | 'ICS_FEED';
sourceId: string | null;
}
export interface PersonalCalendarResponse {
dates: Record<string, { count: number; items: PersonalCalendarItem[] }>;
}
export interface PersonalCalendarItem {
id: string;
type: 'shift' | 'event' | 'ticket' | 'poll' | 'personal';
layerId: string;
title: string;
date: string;
startTime: string;
endTime: string;
isAllDay: boolean;
location: string | null;
color: string;
itemType: CalendarItemType;
busyStatus: CalendarBusyStatus;
showDetailsTo: CalendarShowDetailsTo;
// For personal items
calendarItemId?: string;
seriesId?: string | null;
isRecurring?: boolean;
// For system items
shiftId?: string;
ticketId?: string;
pollId?: string;
}
// --- Shared Calendar Types ---
export type SharedViewType = 'MANUAL' | 'ROLE_BASED';
export type SharedViewScope = 'MEMBERS' | 'PUBLIC';
export type SharedViewMemberStatus = 'INVITED' | 'ACCEPTED' | 'DECLINED';
export interface SharedCalendarView {
id: string;
name: string;
description: string | null;
ownerId: string;
viewType: SharedViewType;
includedLayerTypes: string[];
shareScope: SharedViewScope;
shareToken: string | null;
createdAt: string;
updatedAt: string;
owner?: { id: string; name: string | null; email: string };
_count?: { members: number };
myStatus?: SharedViewMemberStatus;
}
export interface SharedCalendarMember {
id: string;
viewId: string;
userId: string;
status: SharedViewMemberStatus;
color: string;
joinedAt: string | null;
user: { id: string; name: string | null; email: string };
}
export interface SharedViewComment {
id: string;
viewId: string;
userId: string;
itemDate: string;
itemId: string | null;
content: string;
createdAt: string;
user: { id: string; name: string | null; email: string };
}
export interface SharedViewReactionGroup {
itemId: string;
emoji: string;
count: number;
users: { id: string; name: string | null }[];
hasReacted: boolean;
}
export interface SharedCalendarItem extends PersonalCalendarItem {
memberColor: string;
memberName: string;
memberId: string;
}
export interface AvailabilitySlot {
time: string;
members: { userId: string; userName: string; status: 'free' | 'busy' | 'tentative' }[];
allFree: boolean;
}
export interface AvailabilityDay {
slots: AvailabilitySlot[];
}
export interface AvailabilityResponse {
dates: Record<string, AvailabilityDay>;
}
// --- Calendar Feed Types ---
export type CalendarFeedStatus = 'OK' | 'ERROR' | 'PENDING';
export type CalendarFeedInterval = 'FIFTEEN_MIN' | 'HOURLY' | 'SIX_HOUR' | 'DAILY';
export interface CalendarFeed {
id: string;
userId: string;
name: string;
url: string;
layerId: string;
refreshInterval: CalendarFeedInterval;
lastFetchedAt: string | null;
lastStatus: CalendarFeedStatus;
lastError: string | null;
itemCount: number;
createdAt: string;
updatedAt: string;
}
// --- Admin Calendar View Types ---
export interface AdminCalendarView {
id: string;
name: string;
description: string | null;
autoIncludeRoles: string[];
includedLayerTypes: string[];
ownerId: string;
owner: { id: string; name: string | null; email: string };
userCount: number;
createdAt: string;
updatedAt: string;
}
export interface AdminCalendarUser {
id: string;
name: string | null;
email: string;
role: string;
color: string;
}
export interface AdminCalendarItem {
id: string;
type: string;
title: string;
date: string;
startTime: string;
endTime: string;
location: string | null;
color: string;
itemType: string;
layerId: string;
layerName: string;
layerColor: string;
userId: string;
userName: string;
userColor: string;
}
export interface CalendarExportToken {
id: string;
userId: string;
token: string;
includePersonal: boolean;
includeLayers: string[] | null;
createdAt: string;
}

View File

@ -1,99 +0,0 @@
/**
* Insert markdown snippets into a textarea at the current cursor position.
* Handles selection-aware insertion (wrapping selected text).
*/
export interface TextareaInsertResult {
newValue: string;
cursorPos: number;
}
export function insertAtCursor(
textarea: HTMLTextAreaElement,
before: string,
after: string,
): TextareaInsertResult {
const { selectionStart, selectionEnd, value } = textarea;
const selected = value.substring(selectionStart, selectionEnd);
if (selected) {
// Wrap selection
const inserted = before + selected + after;
const newValue = value.substring(0, selectionStart) + inserted + value.substring(selectionEnd);
return { newValue, cursorPos: selectionStart + inserted.length };
}
// No selection — insert markers and position cursor between them
const inserted = before + after;
const newValue = value.substring(0, selectionStart) + inserted + value.substring(selectionEnd);
return { newValue, cursorPos: selectionStart + before.length };
}
export function insertBlock(
textarea: HTMLTextAreaElement,
template: string,
): TextareaInsertResult {
const { selectionStart, selectionEnd, value } = textarea;
const selected = value.substring(selectionStart, selectionEnd);
let text = template.replace('$CURSOR', selected || '');
// If cursor is in the middle of a line, prepend newline
const lineStart = value.lastIndexOf('\n', selectionStart - 1) + 1;
const beforeCursor = value.substring(lineStart, selectionStart);
if (beforeCursor.trim().length > 0) {
text = '\n' + text;
}
const newValue = value.substring(0, selectionStart) + text + value.substring(selectionEnd);
const cursorMarker = text.indexOf(selected || '');
const cursorPos = selectionStart + (cursorMarker >= 0 ? cursorMarker + (selected || '').length : text.length);
return { newValue, cursorPos };
}
/**
* Cycle heading level at the current line.
* No heading -> # -> ## -> ### -> remove heading
*/
export function cycleHeading(textarea: HTMLTextAreaElement): TextareaInsertResult {
const { selectionStart, value } = textarea;
// Find the current line
const lineStart = value.lastIndexOf('\n', selectionStart - 1) + 1;
const lineEnd = value.indexOf('\n', selectionStart);
const line = value.substring(lineStart, lineEnd === -1 ? value.length : lineEnd);
const match = line.match(/^(#{1,3})\s/);
let newLine: string;
if (!match) {
newLine = '# ' + line;
} else if (match[1] === '#') {
newLine = '## ' + line.substring(2);
} else if (match[1] === '##') {
newLine = '### ' + line.substring(3);
} else {
// ### -> remove
newLine = line.substring(4);
}
const end = lineEnd === -1 ? value.length : lineEnd;
const newValue = value.substring(0, lineStart) + newLine + value.substring(end);
const cursorPos = lineStart + newLine.length;
return { newValue, cursorPos };
}
/**
* Apply a textarea snippet result: update value and set cursor position.
*/
export function applyResult(
textarea: HTMLTextAreaElement,
result: TextareaInsertResult,
onChange: (value: string) => void,
): void {
onChange(result.newValue);
// Use requestAnimationFrame so React has time to update the textarea value
requestAnimationFrame(() => {
textarea.focus();
textarea.setSelectionRange(result.cursorPos, result.cursorPos);
});
}

View File

@ -23,18 +23,3 @@ declare module 'grapesjs-touch' {
const plugin: Plugin; const plugin: Plugin;
export default plugin; export default plugin;
} }
declare module 'html5-qrcode' {
export class Html5QrcodeScanner {
constructor(
elementId: string,
config: { fps: number; qrbox: { width: number; height: number } },
verbose: boolean,
);
render(
onScanSuccess: (decodedText: string) => void,
onScanFailure: (error: string) => void,
): void;
clear(): Promise<void>;
}
}

98
api/package-lock.json generated
View File

@ -26,13 +26,11 @@
"express-rate-limit": "^7.5.0", "express-rate-limit": "^7.5.0",
"fastify": "^5.7.4", "fastify": "^5.7.4",
"helmet": "^8.0.0", "helmet": "^8.0.0",
"ical-generator": "^10.0.0",
"ioredis": "^5.4.2", "ioredis": "^5.4.2",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"mime-types": "^3.0.2", "mime-types": "^3.0.2",
"multer": "^2.0.2", "multer": "^2.0.2",
"node-addon-api": "^8.5.0", "node-addon-api": "^8.5.0",
"node-ical": "^0.25.5",
"nodemailer": "^6.9.16", "nodemailer": "^6.9.16",
"pg": "^8.18.0", "pg": "^8.18.0",
"proj4": "^2.20.2", "proj4": "^2.20.2",
@ -1632,17 +1630,6 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@js-temporal/polyfill": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/@js-temporal/polyfill/-/polyfill-0.5.1.tgz",
"integrity": "sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ==",
"dependencies": {
"jsbi": "^4.3.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@lukeed/ms": { "node_modules/@lukeed/ms": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz",
@ -3835,53 +3822,6 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/ical-generator": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/ical-generator/-/ical-generator-10.0.0.tgz",
"integrity": "sha512-YUQ7H4eZdLfYvx3zE/qN4AoG0qqwMZG37vLdWzysXFDn/YQEfctZ9tQuPSBncARKgv79d2smWf5Sh67k6xiZfg==",
"engines": {
"node": "20 || 22 || >=24"
},
"peerDependencies": {
"@touch4it/ical-timezones": ">=1.6.0",
"@types/luxon": ">= 1.26.0",
"@types/mocha": ">= 8.2.1",
"dayjs": ">= 1.10.0",
"luxon": ">= 1.26.0",
"moment": ">= 2.29.0",
"moment-timezone": ">= 0.5.33",
"rrule": ">= 2.6.8"
},
"peerDependenciesMeta": {
"@touch4it/ical-timezones": {
"optional": true
},
"@types/luxon": {
"optional": true
},
"@types/mocha": {
"optional": true
},
"@types/node": {
"optional": true
},
"dayjs": {
"optional": true
},
"luxon": {
"optional": true
},
"moment": {
"optional": true
},
"moment-timezone": {
"optional": true
},
"rrule": {
"optional": true
}
}
},
"node_modules/iconv-lite": { "node_modules/iconv-lite": {
"version": "0.4.24", "version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
@ -3992,11 +3932,6 @@
"jiti": "lib/jiti-cli.mjs" "jiti": "lib/jiti-cli.mjs"
} }
}, },
"node_modules/jsbi": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/jsbi/-/jsbi-4.3.2.tgz",
"integrity": "sha512-9fqMSQbhJykSeii05nxKl4m6Eqn2P6rOlYiS+C5Dr/HPIU/7yZxu5qzbs40tgaFORiw2Amd0mirjxatXYMkIew=="
},
"node_modules/json-schema-ref-resolver": { "node_modules/json-schema-ref-resolver": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz",
@ -4415,18 +4350,6 @@
"node-gyp-build-optional-packages-test": "build-test.js" "node-gyp-build-optional-packages-test": "build-test.js"
} }
}, },
"node_modules/node-ical": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/node-ical/-/node-ical-0.25.5.tgz",
"integrity": "sha512-hj1I+kV38EXdhMB9Sh9phtvdzeJML/HvYbiKqBqcET1O2JiFmJnvpEWISNLA5nUeCWQAaTqiDhZH4uwUTG2Vdg==",
"dependencies": {
"rrule-temporal": "^1.4.7",
"temporal-polyfill": "^0.3.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/nodemailer": { "node_modules/nodemailer": {
"version": "6.10.1", "version": "6.10.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz", "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
@ -5044,14 +4967,6 @@
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==" "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="
}, },
"node_modules/rrule-temporal": {
"version": "1.4.7",
"resolved": "https://registry.npmjs.org/rrule-temporal/-/rrule-temporal-1.4.7.tgz",
"integrity": "sha512-5qiq4dnzIiRsvLnHObNMaPQiHnYLXBkXGQORJkbtl8UO8d/Y5h5Pq5xniW8c5U2BMdPH6XBvBxufjxvDcCLKUA==",
"dependencies": {
"@js-temporal/polyfill": "^0.5.1"
}
},
"node_modules/safe-buffer": { "node_modules/safe-buffer": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@ -5416,19 +5331,6 @@
"bintrees": "1.0.2" "bintrees": "1.0.2"
} }
}, },
"node_modules/temporal-polyfill": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/temporal-polyfill/-/temporal-polyfill-0.3.0.tgz",
"integrity": "sha512-qNsTkX9K8hi+FHDfHmf22e/OGuXmfBm9RqNismxBrnSmZVJKegQ+HYYXT+R7Ha8F/YSm2Y34vmzD4cxMu2u95g==",
"dependencies": {
"temporal-spec": "0.3.0"
}
},
"node_modules/temporal-spec": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/temporal-spec/-/temporal-spec-0.3.0.tgz",
"integrity": "sha512-n+noVpIqz4hYgFSMOSiINNOUOMFtV5cZQNCmmszA6GiVFVRt3G7AqVyhXjhCSmowvQn+NsGn+jMDMKJYHd3bSQ=="
},
"node_modules/text-hex": { "node_modules/text-hex": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz",

View File

@ -34,13 +34,11 @@
"express-rate-limit": "^7.5.0", "express-rate-limit": "^7.5.0",
"fastify": "^5.7.4", "fastify": "^5.7.4",
"helmet": "^8.0.0", "helmet": "^8.0.0",
"ical-generator": "^10.0.0",
"ioredis": "^5.4.2", "ioredis": "^5.4.2",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"mime-types": "^3.0.2", "mime-types": "^3.0.2",
"multer": "^2.0.2", "multer": "^2.0.2",
"node-addon-api": "^8.5.0", "node-addon-api": "^8.5.0",
"node-ical": "^0.25.5",
"nodemailer": "^6.9.16", "nodemailer": "^6.9.16",
"pg": "^8.18.0", "pg": "^8.18.0",
"proj4": "^2.20.2", "proj4": "^2.20.2",

View File

@ -1,249 +0,0 @@
-- CreateEnum
CREATE TYPE "ImpactStoryType" AS ENUM ('MILESTONE', 'VICTORY', 'RESPONSE', 'CUSTOM');
-- CreateEnum
CREATE TYPE "ImpactStoryStatus" AS ENUM ('DRAFT', 'PUBLISHED', 'ARCHIVED');
-- CreateEnum
CREATE TYPE "SpotlightStatus" AS ENUM ('NOMINATED', 'APPROVED', 'FEATURED', 'ARCHIVED');
-- CreateEnum
CREATE TYPE "ChallengeStatus" AS ENUM ('DRAFT', 'UPCOMING', 'ACTIVE', 'COMPLETED', 'CANCELLED');
-- CreateEnum
CREATE TYPE "ChallengeMetric" AS ENUM ('DOORS_KNOCKED', 'EMAILS_SENT', 'SHIFTS_ATTENDED', 'RESPONSES_SUBMITTED', 'REFERRALS_MADE');
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "NotificationType" ADD VALUE 'impact_story';
ALTER TYPE "NotificationType" ADD VALUE 'referral_completed';
ALTER TYPE "NotificationType" ADD VALUE 'challenge_update';
-- AlterTable
ALTER TABLE "privacy_settings" ADD COLUMN "show_on_leaderboard" BOOLEAN DEFAULT true;
-- CreateTable
CREATE TABLE "invite_codes" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"created_by_user_id" TEXT NOT NULL,
"max_uses" INTEGER NOT NULL DEFAULT 0,
"used_count" INTEGER NOT NULL DEFAULT 0,
"expires_at" TIMESTAMP(3),
"is_active" BOOLEAN NOT NULL DEFAULT true,
"note" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "invite_codes_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "referrals" (
"id" SERIAL NOT NULL,
"referrer_id" TEXT NOT NULL,
"referred_user_id" TEXT NOT NULL,
"invite_code_id" TEXT,
"referral_source" TEXT,
"completed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "referrals_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "impact_stories" (
"id" TEXT NOT NULL,
"campaign_id" TEXT NOT NULL,
"type" "ImpactStoryType" NOT NULL,
"status" "ImpactStoryStatus" NOT NULL DEFAULT 'DRAFT',
"title" TEXT NOT NULL,
"body" TEXT NOT NULL,
"cover_image_url" TEXT,
"milestone_value" INTEGER,
"milestone_metric" TEXT,
"created_by_user_id" TEXT,
"published_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "impact_stories_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "campaign_milestones" (
"id" SERIAL NOT NULL,
"campaign_id" TEXT NOT NULL,
"metric" TEXT NOT NULL,
"threshold" INTEGER NOT NULL,
"reached_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"story_generated" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "campaign_milestones_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "volunteer_spotlights" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"status" "SpotlightStatus" NOT NULL DEFAULT 'NOMINATED',
"headline" TEXT,
"story" TEXT,
"featured_month" TEXT,
"nominated_by_user_id" TEXT,
"approved_by_user_id" TEXT,
"approved_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "volunteer_spotlights_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "challenges" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"metric" "ChallengeMetric" NOT NULL,
"status" "ChallengeStatus" NOT NULL DEFAULT 'DRAFT',
"starts_at" TIMESTAMP(3) NOT NULL,
"ends_at" TIMESTAMP(3) NOT NULL,
"min_team_size" INTEGER NOT NULL DEFAULT 2,
"max_team_size" INTEGER NOT NULL DEFAULT 10,
"max_teams" INTEGER,
"created_by_user_id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "challenges_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "challenge_teams" (
"id" TEXT NOT NULL,
"challenge_id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"captain_user_id" TEXT NOT NULL,
"score" INTEGER NOT NULL DEFAULT 0,
"last_scored_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "challenge_teams_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "challenge_team_members" (
"id" SERIAL NOT NULL,
"team_id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"score" INTEGER NOT NULL DEFAULT 0,
"joined_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "challenge_team_members_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "invite_codes_code_key" ON "invite_codes"("code");
-- CreateIndex
CREATE INDEX "idx_invite_codes_code" ON "invite_codes"("code");
-- CreateIndex
CREATE INDEX "idx_invite_codes_created_by" ON "invite_codes"("created_by_user_id");
-- CreateIndex
CREATE UNIQUE INDEX "referrals_referred_user_id_key" ON "referrals"("referred_user_id");
-- CreateIndex
CREATE INDEX "idx_referrals_referrer" ON "referrals"("referrer_id");
-- CreateIndex
CREATE INDEX "idx_impact_stories_campaign" ON "impact_stories"("campaign_id");
-- CreateIndex
CREATE INDEX "idx_impact_stories_status" ON "impact_stories"("status");
-- CreateIndex
CREATE INDEX "idx_impact_stories_type" ON "impact_stories"("type");
-- CreateIndex
CREATE UNIQUE INDEX "campaign_milestones_campaign_id_metric_threshold_key" ON "campaign_milestones"("campaign_id", "metric", "threshold");
-- CreateIndex
CREATE INDEX "idx_volunteer_spotlights_user" ON "volunteer_spotlights"("user_id");
-- CreateIndex
CREATE INDEX "idx_volunteer_spotlights_status" ON "volunteer_spotlights"("status");
-- CreateIndex
CREATE INDEX "idx_volunteer_spotlights_month" ON "volunteer_spotlights"("featured_month");
-- CreateIndex
CREATE INDEX "idx_challenges_status" ON "challenges"("status");
-- CreateIndex
CREATE INDEX "idx_challenges_starts_at" ON "challenges"("starts_at");
-- CreateIndex
CREATE INDEX "idx_challenge_teams_challenge" ON "challenge_teams"("challenge_id");
-- CreateIndex
CREATE INDEX "idx_challenge_teams_score" ON "challenge_teams"("score");
-- CreateIndex
CREATE UNIQUE INDEX "challenge_teams_challenge_id_name_key" ON "challenge_teams"("challenge_id", "name");
-- CreateIndex
CREATE INDEX "idx_challenge_team_members_user" ON "challenge_team_members"("user_id");
-- CreateIndex
CREATE UNIQUE INDEX "challenge_team_members_team_id_user_id_key" ON "challenge_team_members"("team_id", "user_id");
-- AddForeignKey
ALTER TABLE "invite_codes" ADD CONSTRAINT "invite_codes_created_by_user_id_fkey" FOREIGN KEY ("created_by_user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "referrals" ADD CONSTRAINT "referrals_referrer_id_fkey" FOREIGN KEY ("referrer_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "referrals" ADD CONSTRAINT "referrals_referred_user_id_fkey" FOREIGN KEY ("referred_user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "referrals" ADD CONSTRAINT "referrals_invite_code_id_fkey" FOREIGN KEY ("invite_code_id") REFERENCES "invite_codes"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "impact_stories" ADD CONSTRAINT "impact_stories_campaign_id_fkey" FOREIGN KEY ("campaign_id") REFERENCES "campaigns"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "impact_stories" ADD CONSTRAINT "impact_stories_created_by_user_id_fkey" FOREIGN KEY ("created_by_user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "campaign_milestones" ADD CONSTRAINT "campaign_milestones_campaign_id_fkey" FOREIGN KEY ("campaign_id") REFERENCES "campaigns"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "volunteer_spotlights" ADD CONSTRAINT "volunteer_spotlights_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "volunteer_spotlights" ADD CONSTRAINT "volunteer_spotlights_nominated_by_user_id_fkey" FOREIGN KEY ("nominated_by_user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "volunteer_spotlights" ADD CONSTRAINT "volunteer_spotlights_approved_by_user_id_fkey" FOREIGN KEY ("approved_by_user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "challenges" ADD CONSTRAINT "challenges_created_by_user_id_fkey" FOREIGN KEY ("created_by_user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "challenge_teams" ADD CONSTRAINT "challenge_teams_challenge_id_fkey" FOREIGN KEY ("challenge_id") REFERENCES "challenges"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "challenge_teams" ADD CONSTRAINT "challenge_teams_captain_user_id_fkey" FOREIGN KEY ("captain_user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "challenge_team_members" ADD CONSTRAINT "challenge_team_members_team_id_fkey" FOREIGN KEY ("team_id") REFERENCES "challenge_teams"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "challenge_team_members" ADD CONSTRAINT "challenge_team_members_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -1,176 +0,0 @@
-- CreateEnum
CREATE TYPE "TicketedEventStatus" AS ENUM ('DRAFT', 'PENDING_APPROVAL', 'PUBLISHED', 'CANCELLED', 'COMPLETED');
-- CreateEnum
CREATE TYPE "TicketedEventVisibility" AS ENUM ('PUBLIC', 'UNLISTED', 'PRIVATE');
-- CreateEnum
CREATE TYPE "TicketTierType" AS ENUM ('PAID', 'FREE', 'DONATION');
-- CreateEnum
CREATE TYPE "TicketStatus" AS ENUM ('VALID', 'CHECKED_IN', 'CANCELLED', 'REFUNDED');
-- AlterTable
ALTER TABLE "site_settings" ADD COLUMN "enable_ticketed_events" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "require_event_approval" BOOLEAN NOT NULL DEFAULT true;
-- CreateTable
CREATE TABLE "ticketed_events" (
"id" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"rich_description" TEXT,
"date" DATE NOT NULL,
"start_time" TEXT NOT NULL,
"end_time" TEXT NOT NULL,
"doors_open_time" TEXT,
"venue_name" TEXT,
"venue_address" TEXT,
"latitude" DECIMAL(10,7),
"longitude" DECIMAL(10,7),
"status" "TicketedEventStatus" NOT NULL DEFAULT 'DRAFT',
"visibility" "TicketedEventVisibility" NOT NULL DEFAULT 'PUBLIC',
"invite_code" TEXT,
"cover_image_url" TEXT,
"max_attendees" INTEGER,
"current_attendees" INTEGER NOT NULL DEFAULT 0,
"gancio_event_id" INTEGER,
"created_by_user_id" TEXT NOT NULL,
"organizer_name" TEXT,
"organizer_email" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ticketed_events_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ticket_tiers" (
"id" TEXT NOT NULL,
"event_id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"tier_type" "TicketTierType" NOT NULL,
"price_cad" INTEGER NOT NULL DEFAULT 0,
"min_donation_cad" INTEGER,
"max_quantity" INTEGER,
"sold_count" INTEGER NOT NULL DEFAULT 0,
"max_per_order" INTEGER NOT NULL DEFAULT 10,
"sales_start_at" TIMESTAMP(3),
"sales_end_at" TIMESTAMP(3),
"sort_order" INTEGER NOT NULL DEFAULT 0,
"is_active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ticket_tiers_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "tickets" (
"id" TEXT NOT NULL,
"ticket_code" TEXT NOT NULL,
"token_hash" TEXT NOT NULL,
"event_id" TEXT NOT NULL,
"tier_id" TEXT NOT NULL,
"order_id" TEXT,
"holder_email" TEXT NOT NULL,
"holder_name" TEXT,
"user_id" TEXT,
"status" "TicketStatus" NOT NULL DEFAULT 'VALID',
"checked_in_at" TIMESTAMP(3),
"checked_in_by_user_id" TEXT,
"issued_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "tickets_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "check_ins" (
"id" TEXT NOT NULL,
"ticket_id" TEXT NOT NULL,
"event_id" TEXT NOT NULL,
"checked_in_by_user_id" TEXT,
"method" TEXT NOT NULL,
"checked_in_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"notes" TEXT,
CONSTRAINT "check_ins_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "ticketed_events_slug_key" ON "ticketed_events"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "ticketed_events_invite_code_key" ON "ticketed_events"("invite_code");
-- CreateIndex
CREATE INDEX "idx_ticketed_events_status" ON "ticketed_events"("status");
-- CreateIndex
CREATE INDEX "idx_ticketed_events_date" ON "ticketed_events"("date");
-- CreateIndex
CREATE INDEX "idx_ticketed_events_visibility" ON "ticketed_events"("visibility");
-- CreateIndex
CREATE INDEX "idx_ticketed_events_creator" ON "ticketed_events"("created_by_user_id");
-- CreateIndex
CREATE INDEX "idx_ticket_tiers_event" ON "ticket_tiers"("event_id");
-- CreateIndex
CREATE UNIQUE INDEX "tickets_ticket_code_key" ON "tickets"("ticket_code");
-- CreateIndex
CREATE UNIQUE INDEX "tickets_token_hash_key" ON "tickets"("token_hash");
-- CreateIndex
CREATE INDEX "idx_tickets_event" ON "tickets"("event_id");
-- CreateIndex
CREATE INDEX "idx_tickets_tier" ON "tickets"("tier_id");
-- CreateIndex
CREATE INDEX "idx_tickets_order" ON "tickets"("order_id");
-- CreateIndex
CREATE INDEX "idx_tickets_holder_email" ON "tickets"("holder_email");
-- CreateIndex
CREATE INDEX "idx_tickets_status" ON "tickets"("status");
-- CreateIndex
CREATE INDEX "idx_checkins_event" ON "check_ins"("event_id");
-- CreateIndex
CREATE INDEX "idx_checkins_ticket" ON "check_ins"("ticket_id");
-- AddForeignKey
ALTER TABLE "ticketed_events" ADD CONSTRAINT "ticketed_events_created_by_user_id_fkey" FOREIGN KEY ("created_by_user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ticket_tiers" ADD CONSTRAINT "ticket_tiers_event_id_fkey" FOREIGN KEY ("event_id") REFERENCES "ticketed_events"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tickets" ADD CONSTRAINT "tickets_event_id_fkey" FOREIGN KEY ("event_id") REFERENCES "ticketed_events"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tickets" ADD CONSTRAINT "tickets_tier_id_fkey" FOREIGN KEY ("tier_id") REFERENCES "ticket_tiers"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tickets" ADD CONSTRAINT "tickets_order_id_fkey" FOREIGN KEY ("order_id") REFERENCES "orders"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tickets" ADD CONSTRAINT "tickets_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "check_ins" ADD CONSTRAINT "check_ins_ticket_id_fkey" FOREIGN KEY ("ticket_id") REFERENCES "tickets"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "check_ins" ADD CONSTRAINT "check_ins_event_id_fkey" FOREIGN KEY ("event_id") REFERENCES "ticketed_events"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "check_ins" ADD CONSTRAINT "check_ins_checked_in_by_user_id_fkey" FOREIGN KEY ("checked_in_by_user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -1,12 +0,0 @@
-- CreateEnum
CREATE TYPE "EventFormat" AS ENUM ('IN_PERSON', 'ONLINE', 'HYBRID');
-- AlterTable
ALTER TABLE "ticketed_events" ADD COLUMN "event_format" "EventFormat" NOT NULL DEFAULT 'IN_PERSON',
ADD COLUMN "meeting_id" TEXT;
-- CreateIndex
CREATE UNIQUE INDEX "ticketed_events_meeting_id_key" ON "ticketed_events"("meeting_id");
-- AddForeignKey
ALTER TABLE "ticketed_events" ADD CONSTRAINT "ticketed_events_meeting_id_fkey" FOREIGN KEY ("meeting_id") REFERENCES "meetings"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -1,258 +0,0 @@
-- CreateEnum
CREATE TYPE "CalendarLayerType" AS ENUM ('SYSTEM', 'USER', 'EXTERNAL');
-- CreateEnum
CREATE TYPE "CalendarSystemType" AS ENUM ('SHIFTS', 'TICKETS', 'POLLS', 'PUBLIC_EVENTS');
-- CreateEnum
CREATE TYPE "CalendarVisibility" AS ENUM ('PRIVATE', 'FRIENDS', 'PUBLIC');
-- CreateEnum
CREATE TYPE "CalendarItemType" AS ENUM ('EVENT', 'TIME_BLOCK', 'REMINDER');
-- CreateEnum
CREATE TYPE "CalendarBusyStatus" AS ENUM ('BUSY', 'TENTATIVE', 'FREE');
-- CreateEnum
CREATE TYPE "CalendarShowDetailsTo" AS ENUM ('NOBODY', 'FRIENDS', 'EVERYONE');
-- CreateEnum
CREATE TYPE "CalendarItemSource" AS ENUM ('MANUAL', 'ICS_FEED');
-- CreateEnum
CREATE TYPE "CalendarRecurrenceFrequency" AS ENUM ('DAILY', 'WEEKLY', 'BIWEEKLY', 'MONTHLY');
-- CreateEnum
CREATE TYPE "CalendarFeedStatus" AS ENUM ('OK', 'ERROR', 'PENDING');
-- CreateEnum
CREATE TYPE "CalendarFeedInterval" AS ENUM ('FIFTEEN_MIN', 'HOURLY', 'SIX_HOUR', 'DAILY');
-- CreateEnum
CREATE TYPE "SharedViewType" AS ENUM ('MANUAL', 'ROLE_BASED');
-- CreateEnum
CREATE TYPE "SharedViewScope" AS ENUM ('MEMBERS', 'PUBLIC');
-- CreateEnum
CREATE TYPE "SharedViewMemberStatus" AS ENUM ('INVITED', 'ACCEPTED', 'DECLINED');
-- AlterTable
ALTER TABLE "site_settings" ADD COLUMN "enable_social_calendar" BOOLEAN NOT NULL DEFAULT false;
-- CreateTable
CREATE TABLE "calendar_layers" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"layer_type" "CalendarLayerType" NOT NULL,
"system_type" "CalendarSystemType",
"color" TEXT NOT NULL DEFAULT '#1890ff',
"visibility" "CalendarVisibility" NOT NULL DEFAULT 'PRIVATE',
"is_enabled" BOOLEAN NOT NULL DEFAULT true,
"sort_order" INTEGER NOT NULL DEFAULT 0,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "calendar_layers_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "calendar_items" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"layer_id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"date" DATE NOT NULL,
"start_time" TEXT NOT NULL,
"end_time" TEXT NOT NULL,
"is_all_day" BOOLEAN NOT NULL DEFAULT false,
"item_type" "CalendarItemType" NOT NULL DEFAULT 'EVENT',
"location" TEXT,
"color" TEXT,
"visibility" "CalendarVisibility",
"busy_status" "CalendarBusyStatus" NOT NULL DEFAULT 'BUSY',
"show_details_to" "CalendarShowDetailsTo" NOT NULL DEFAULT 'FRIENDS',
"recurrence_rule" JSONB,
"recurrence_end" TIMESTAMP(3),
"series_id" TEXT,
"is_exception" BOOLEAN NOT NULL DEFAULT false,
"source_type" "CalendarItemSource" NOT NULL DEFAULT 'MANUAL',
"source_id" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "calendar_items_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "calendar_feeds" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"url" TEXT NOT NULL,
"layer_id" TEXT NOT NULL,
"refresh_interval" "CalendarFeedInterval" NOT NULL DEFAULT 'HOURLY',
"last_fetched_at" TIMESTAMP(3),
"last_status" "CalendarFeedStatus" NOT NULL DEFAULT 'PENDING',
"last_error" TEXT,
"item_count" INTEGER NOT NULL DEFAULT 0,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "calendar_feeds_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "shared_calendar_views" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"owner_id" TEXT NOT NULL,
"view_type" "SharedViewType" NOT NULL DEFAULT 'MANUAL',
"auto_include_roles" JSONB,
"included_layer_types" JSONB NOT NULL DEFAULT '[]',
"share_scope" "SharedViewScope" NOT NULL DEFAULT 'MEMBERS',
"share_token" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "shared_calendar_views_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "shared_calendar_members" (
"id" TEXT NOT NULL,
"view_id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"status" "SharedViewMemberStatus" NOT NULL DEFAULT 'INVITED',
"color" TEXT NOT NULL DEFAULT '#1890ff',
"joined_at" TIMESTAMP(3),
CONSTRAINT "shared_calendar_members_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "shared_view_comments" (
"id" TEXT NOT NULL,
"view_id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"item_date" TEXT NOT NULL,
"item_id" TEXT,
"content" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "shared_view_comments_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "shared_view_reactions" (
"id" TEXT NOT NULL,
"view_id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"item_id" TEXT NOT NULL,
"emoji" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "shared_view_reactions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "calendar_export_tokens" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"token" TEXT NOT NULL,
"include_personal" BOOLEAN NOT NULL DEFAULT false,
"include_layers" JSONB,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "calendar_export_tokens_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "idx_calendar_layers_user" ON "calendar_layers"("user_id");
-- CreateIndex
CREATE UNIQUE INDEX "idx_calendar_layers_user_system" ON "calendar_layers"("user_id", "system_type");
-- CreateIndex
CREATE INDEX "idx_calendar_items_user_date" ON "calendar_items"("user_id", "date");
-- CreateIndex
CREATE INDEX "idx_calendar_items_layer_date" ON "calendar_items"("layer_id", "date");
-- CreateIndex
CREATE INDEX "idx_calendar_items_series" ON "calendar_items"("series_id");
-- CreateIndex
CREATE INDEX "idx_calendar_items_source" ON "calendar_items"("source_type", "source_id");
-- CreateIndex
CREATE UNIQUE INDEX "calendar_feeds_layer_id_key" ON "calendar_feeds"("layer_id");
-- CreateIndex
CREATE INDEX "idx_calendar_feeds_user" ON "calendar_feeds"("user_id");
-- CreateIndex
CREATE UNIQUE INDEX "shared_calendar_views_share_token_key" ON "shared_calendar_views"("share_token");
-- CreateIndex
CREATE INDEX "idx_shared_views_owner" ON "shared_calendar_views"("owner_id");
-- CreateIndex
CREATE INDEX "idx_shared_members_user" ON "shared_calendar_members"("user_id");
-- CreateIndex
CREATE UNIQUE INDEX "idx_shared_members_view_user" ON "shared_calendar_members"("view_id", "user_id");
-- CreateIndex
CREATE INDEX "idx_shared_comments_view_date" ON "shared_view_comments"("view_id", "item_date");
-- CreateIndex
CREATE UNIQUE INDEX "idx_shared_reactions_unique" ON "shared_view_reactions"("view_id", "user_id", "item_id", "emoji");
-- CreateIndex
CREATE UNIQUE INDEX "calendar_export_tokens_token_key" ON "calendar_export_tokens"("token");
-- CreateIndex
CREATE INDEX "idx_calendar_export_tokens_user" ON "calendar_export_tokens"("user_id");
-- AddForeignKey
ALTER TABLE "calendar_layers" ADD CONSTRAINT "calendar_layers_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "calendar_items" ADD CONSTRAINT "calendar_items_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "calendar_items" ADD CONSTRAINT "calendar_items_layer_id_fkey" FOREIGN KEY ("layer_id") REFERENCES "calendar_layers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "calendar_feeds" ADD CONSTRAINT "calendar_feeds_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "calendar_feeds" ADD CONSTRAINT "calendar_feeds_layer_id_fkey" FOREIGN KEY ("layer_id") REFERENCES "calendar_layers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "shared_calendar_views" ADD CONSTRAINT "shared_calendar_views_owner_id_fkey" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "shared_calendar_members" ADD CONSTRAINT "shared_calendar_members_view_id_fkey" FOREIGN KEY ("view_id") REFERENCES "shared_calendar_views"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "shared_calendar_members" ADD CONSTRAINT "shared_calendar_members_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "shared_view_comments" ADD CONSTRAINT "shared_view_comments_view_id_fkey" FOREIGN KEY ("view_id") REFERENCES "shared_calendar_views"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "shared_view_comments" ADD CONSTRAINT "shared_view_comments_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "shared_view_reactions" ADD CONSTRAINT "shared_view_reactions_view_id_fkey" FOREIGN KEY ("view_id") REFERENCES "shared_calendar_views"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "shared_view_reactions" ADD CONSTRAINT "shared_view_reactions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "calendar_export_tokens" ADD CONSTRAINT "calendar_export_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -1,11 +0,0 @@
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "NotificationType" ADD VALUE 'shared_view_invite';
ALTER TYPE "NotificationType" ADD VALUE 'shared_view_accepted';
ALTER TYPE "NotificationType" ADD VALUE 'calendar_event_invite';

View File

@ -160,39 +160,6 @@ model User {
schedulingPollVotes SchedulingPollVote[] @relation("PollVoter") schedulingPollVotes SchedulingPollVote[] @relation("PollVoter")
schedulingPollComments SchedulingPollComment[] @relation("PollCommenter") schedulingPollComments SchedulingPollComment[] @relation("PollCommenter")
// Referral system
inviteCodesCreated InviteCode[] @relation("InviteCodesCreated")
referralsMade Referral[] @relation("ReferralsMade")
referredBy Referral? @relation("ReferredBy")
// Impact Stories
impactStoriesCreated ImpactStory[] @relation("ImpactStoryCreator")
// Volunteer Spotlight
spotlights VolunteerSpotlight[] @relation("SpotlightUser")
spotlightNominations VolunteerSpotlight[] @relation("SpotlightNominator")
spotlightApprovals VolunteerSpotlight[] @relation("SpotlightApprover")
// Team Challenges
challengesCreated Challenge[] @relation("ChallengesCreated")
challengeTeamsCaptained ChallengeTeam[] @relation("ChallengeTeamsCaptained")
challengeParticipations ChallengeTeamMember[] @relation("ChallengeParticipations")
// Ticketed Events
ticketedEventsCreated TicketedEvent[] @relation("EventCreator")
ticketsHeld Ticket[] @relation("TicketHolder")
checkInsMade CheckIn[] @relation("CheckInUser")
// Social Calendar
calendarLayers CalendarLayer[] @relation("CalendarLayerOwner")
calendarItems CalendarItem[] @relation("CalendarItemOwner")
calendarFeeds CalendarFeed[] @relation("CalendarFeedOwner")
sharedCalendarViewsOwned SharedCalendarView[] @relation("SharedViewOwner")
sharedCalendarMemberships SharedCalendarMember[] @relation("SharedViewMember")
sharedViewComments SharedViewComment[] @relation("SharedViewCommentUser")
sharedViewReactions SharedViewReaction[] @relation("SharedViewReactionUser")
calendarExportTokens CalendarExportToken[] @relation("CalendarExportTokenOwner")
@@map("users") @@map("users")
} }
@ -282,8 +249,6 @@ model Campaign {
customRecipients CustomRecipient[] customRecipients CustomRecipient[]
calls Call[] calls Call[]
smsCampaigns SmsCampaign[] @relation("SmsCampaigns") smsCampaigns SmsCampaign[] @relation("SmsCampaigns")
stories ImpactStory[] @relation("CampaignStories")
milestones CampaignMilestone[] @relation("CampaignMilestones")
@@index([moderationStatus]) @@index([moderationStatus])
@@index([isUserGenerated]) @@index([isUserGenerated])
@ -933,9 +898,6 @@ model SiteSettings {
enableSocial Boolean @default(false) @map("enable_social") enableSocial Boolean @default(false) @map("enable_social")
enableMeet Boolean @default(false) @map("enable_meet") enableMeet Boolean @default(false) @map("enable_meet")
enableMeetingPlanner Boolean @default(false) @map("enable_meeting_planner") enableMeetingPlanner Boolean @default(false) @map("enable_meeting_planner")
enableTicketedEvents Boolean @default(false) @map("enable_ticketed_events")
enableSocialCalendar Boolean @default(false) @map("enable_social_calendar")
requireEventApproval Boolean @default(true) @map("require_event_approval")
autoSyncPeopleToMap Boolean @default(false) @map("auto_sync_people_to_map") autoSyncPeopleToMap Boolean @default(false) @map("auto_sync_people_to_map")
// SMS connection config (overrides env vars when non-empty) // SMS connection config (overrides env vars when non-empty)
@ -1503,12 +1465,6 @@ enum NotificationType {
achievement achievement
system system
group_call group_call
impact_story
referral_completed
challenge_update
shared_view_invite
shared_view_accepted
calendar_event_invite
} }
// ============================================================================ // ============================================================================
@ -2400,7 +2356,6 @@ model PrivacySettings {
hidePublicFinishes Boolean? @default(false) @map("hide_public_finishes") hidePublicFinishes Boolean? @default(false) @map("hide_public_finishes")
allowFriendRequests Boolean? @default(true) @map("allow_friend_requests") allowFriendRequests Boolean? @default(true) @map("allow_friend_requests")
closeFriendsOnlyWatching Boolean? @default(false) @map("close_friends_only_watching") closeFriendsOnlyWatching Boolean? @default(false) @map("close_friends_only_watching")
showOnLeaderboard Boolean? @default(true) @map("show_on_leaderboard")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime? @map("updated_at") updatedAt DateTime? @map("updated_at")
@ -3459,7 +3414,6 @@ model Order {
product Product? @relation(fields: [productId], references: [id]) product Product? @relation(fields: [productId], references: [id])
donationPageId String? @map("donation_page_id") donationPageId String? @map("donation_page_id")
donationPage DonationPage? @relation("DonationPageOrders", fields: [donationPageId], references: [id], onDelete: SetNull) donationPage DonationPage? @relation("DonationPageOrders", fields: [donationPageId], references: [id], onDelete: SetNull)
tickets Ticket[] @relation("TicketOrder")
@@index([userId], map: "idx_orders_user") @@index([userId], map: "idx_orders_user")
@@index([productId], map: "idx_orders_product") @@index([productId], map: "idx_orders_product")
@ -4346,7 +4300,6 @@ model Meeting {
// Reverse relations (one-to-one) // Reverse relations (one-to-one)
shift Shift? @relation("ShiftMeeting") shift Shift? @relation("ShiftMeeting")
group SocialGroup? @relation("GroupMeeting") group SocialGroup? @relation("GroupMeeting")
ticketedEvent TicketedEvent? @relation("EventMeeting")
@@map("meetings") @@map("meetings")
} }
@ -4450,646 +4403,3 @@ model SchedulingPollComment {
@@index([pollId]) @@index([pollId])
@@map("scheduling_poll_comments") @@map("scheduling_poll_comments")
} }
// ============================================================================
// SOCIAL: INVITE / REFERRAL SYSTEM
// ============================================================================
model InviteCode {
id String @id @default(cuid())
code String @unique
createdByUserId String @map("created_by_user_id")
maxUses Int @default(0) @map("max_uses") // 0 = unlimited
usedCount Int @default(0) @map("used_count")
expiresAt DateTime? @map("expires_at")
isActive Boolean @default(true) @map("is_active")
note String?
createdAt DateTime @default(now()) @map("created_at")
// Relations
createdBy User @relation("InviteCodesCreated", fields: [createdByUserId], references: [id])
referrals Referral[] @relation("InviteCodeReferrals")
@@index([code], map: "idx_invite_codes_code")
@@index([createdByUserId], map: "idx_invite_codes_created_by")
@@map("invite_codes")
}
model Referral {
id Int @id @default(autoincrement())
referrerId String @map("referrer_id")
referredUserId String @unique @map("referred_user_id")
inviteCodeId String? @map("invite_code_id")
referralSource String? @map("referral_source")
completedAt DateTime @default(now()) @map("completed_at")
// Relations
referrer User @relation("ReferralsMade", fields: [referrerId], references: [id])
referredUser User @relation("ReferredBy", fields: [referredUserId], references: [id])
inviteCode InviteCode? @relation("InviteCodeReferrals", fields: [inviteCodeId], references: [id])
@@index([referrerId], map: "idx_referrals_referrer")
@@map("referrals")
}
// ============================================================================
// SOCIAL: IMPACT STORIES / CAMPAIGN VICTORIES
// ============================================================================
enum ImpactStoryType {
MILESTONE
VICTORY
RESPONSE
CUSTOM
}
enum ImpactStoryStatus {
DRAFT
PUBLISHED
ARCHIVED
}
model ImpactStory {
id String @id @default(cuid())
campaignId String @map("campaign_id")
type ImpactStoryType
status ImpactStoryStatus @default(DRAFT)
title String
body String @db.Text
coverImageUrl String? @map("cover_image_url")
milestoneValue Int? @map("milestone_value")
milestoneMetric String? @map("milestone_metric")
createdByUserId String? @map("created_by_user_id")
publishedAt DateTime? @map("published_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
campaign Campaign @relation("CampaignStories", fields: [campaignId], references: [id])
createdBy User? @relation("ImpactStoryCreator", fields: [createdByUserId], references: [id])
@@index([campaignId], map: "idx_impact_stories_campaign")
@@index([status], map: "idx_impact_stories_status")
@@index([type], map: "idx_impact_stories_type")
@@map("impact_stories")
}
model CampaignMilestone {
id Int @id @default(autoincrement())
campaignId String @map("campaign_id")
metric String // "emails_sent", "verified_responses"
threshold Int
reachedAt DateTime @default(now()) @map("reached_at")
storyGenerated Boolean @default(false) @map("story_generated")
// Relations
campaign Campaign @relation("CampaignMilestones", fields: [campaignId], references: [id])
@@unique([campaignId, metric, threshold])
@@map("campaign_milestones")
}
// ============================================================================
// SOCIAL: VOLUNTEER SPOTLIGHT / WALL OF FAME
// ============================================================================
enum SpotlightStatus {
NOMINATED
APPROVED
FEATURED
ARCHIVED
}
model VolunteerSpotlight {
id String @id @default(cuid())
userId String @map("user_id")
status SpotlightStatus @default(NOMINATED)
headline String?
story String? @db.Text
featuredMonth String? @map("featured_month") // "2026-03"
nominatedByUserId String? @map("nominated_by_user_id")
approvedByUserId String? @map("approved_by_user_id")
approvedAt DateTime? @map("approved_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
user User @relation("SpotlightUser", fields: [userId], references: [id])
nominatedBy User? @relation("SpotlightNominator", fields: [nominatedByUserId], references: [id])
approvedBy User? @relation("SpotlightApprover", fields: [approvedByUserId], references: [id])
@@index([userId], map: "idx_volunteer_spotlights_user")
@@index([status], map: "idx_volunteer_spotlights_status")
@@index([featuredMonth], map: "idx_volunteer_spotlights_month")
@@map("volunteer_spotlights")
}
// ============================================================================
// SOCIAL: TEAM CHALLENGES
// ============================================================================
enum ChallengeStatus {
DRAFT
UPCOMING
ACTIVE
COMPLETED
CANCELLED
}
enum ChallengeMetric {
DOORS_KNOCKED
EMAILS_SENT
SHIFTS_ATTENDED
RESPONSES_SUBMITTED
REFERRALS_MADE
}
model Challenge {
id String @id @default(cuid())
title String
description String? @db.Text
metric ChallengeMetric
status ChallengeStatus @default(DRAFT)
startsAt DateTime @map("starts_at")
endsAt DateTime @map("ends_at")
minTeamSize Int @default(2) @map("min_team_size")
maxTeamSize Int @default(10) @map("max_team_size")
maxTeams Int? @map("max_teams") // null = unlimited
createdByUserId String @map("created_by_user_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
createdBy User @relation("ChallengesCreated", fields: [createdByUserId], references: [id])
teams ChallengeTeam[] @relation("ChallengeTeams")
@@index([status], map: "idx_challenges_status")
@@index([startsAt], map: "idx_challenges_starts_at")
@@map("challenges")
}
model ChallengeTeam {
id String @id @default(cuid())
challengeId String @map("challenge_id")
name String
captainUserId String @map("captain_user_id")
score Int @default(0)
lastScoredAt DateTime? @map("last_scored_at")
createdAt DateTime @default(now()) @map("created_at")
// Relations
challenge Challenge @relation("ChallengeTeams", fields: [challengeId], references: [id])
captain User @relation("ChallengeTeamsCaptained", fields: [captainUserId], references: [id])
members ChallengeTeamMember[] @relation("ChallengeTeamMembers")
@@unique([challengeId, name])
@@index([challengeId], map: "idx_challenge_teams_challenge")
@@index([score], map: "idx_challenge_teams_score")
@@map("challenge_teams")
}
model ChallengeTeamMember {
id Int @id @default(autoincrement())
teamId String @map("team_id")
userId String @map("user_id")
score Int @default(0)
joinedAt DateTime @default(now()) @map("joined_at")
// Relations
team ChallengeTeam @relation("ChallengeTeamMembers", fields: [teamId], references: [id], onDelete: Cascade)
user User @relation("ChallengeParticipations", fields: [userId], references: [id])
@@unique([teamId, userId])
@@index([userId], map: "idx_challenge_team_members_user")
@@map("challenge_team_members")
}
// ============================================================================
// TICKETED EVENTS
// ============================================================================
enum TicketedEventStatus {
DRAFT
PENDING_APPROVAL
PUBLISHED
CANCELLED
COMPLETED
}
enum TicketedEventVisibility {
PUBLIC
UNLISTED
PRIVATE
}
enum TicketTierType {
PAID
FREE
DONATION
}
enum TicketStatus {
VALID
CHECKED_IN
CANCELLED
REFUNDED
}
enum EventFormat {
IN_PERSON
ONLINE
HYBRID
}
model TicketedEvent {
id String @id @default(cuid())
slug String @unique
title String
description String? @db.Text
richDescription String? @db.Text @map("rich_description")
// Schedule
date DateTime @db.Date
startTime String @map("start_time")
endTime String @map("end_time")
doorsOpenTime String? @map("doors_open_time")
// Venue
venueName String? @map("venue_name")
venueAddress String? @map("venue_address")
latitude Decimal? @db.Decimal(10, 7)
longitude Decimal? @db.Decimal(10, 7)
// Status
status TicketedEventStatus @default(DRAFT)
visibility TicketedEventVisibility @default(PUBLIC)
inviteCode String? @unique @map("invite_code")
// Media
coverImageUrl String? @map("cover_image_url")
// Capacity
maxAttendees Int? @map("max_attendees")
currentAttendees Int @default(0) @map("current_attendees")
// Gancio sync
gancioEventId Int? @map("gancio_event_id")
// Format & Meeting
eventFormat EventFormat @default(IN_PERSON) @map("event_format")
meetingId String? @unique @map("meeting_id")
// Creator
createdByUserId String @map("created_by_user_id")
organizerName String? @map("organizer_name")
organizerEmail String? @map("organizer_email")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
createdBy User @relation("EventCreator", fields: [createdByUserId], references: [id])
meeting Meeting? @relation("EventMeeting", fields: [meetingId], references: [id], onDelete: SetNull)
ticketTiers TicketTier[] @relation("EventTiers")
tickets Ticket[] @relation("EventTickets")
checkIns CheckIn[] @relation("EventCheckIns")
@@index([status], map: "idx_ticketed_events_status")
@@index([date], map: "idx_ticketed_events_date")
@@index([visibility], map: "idx_ticketed_events_visibility")
@@index([createdByUserId], map: "idx_ticketed_events_creator")
@@map("ticketed_events")
}
model TicketTier {
id String @id @default(cuid())
eventId String @map("event_id")
name String
description String?
tierType TicketTierType @map("tier_type")
priceCAD Int @default(0) @map("price_cad") // In cents
minDonationCAD Int? @map("min_donation_cad") // In cents
maxQuantity Int? @map("max_quantity")
soldCount Int @default(0) @map("sold_count")
maxPerOrder Int @default(10) @map("max_per_order")
salesStartAt DateTime? @map("sales_start_at")
salesEndAt DateTime? @map("sales_end_at")
sortOrder Int @default(0) @map("sort_order")
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
event TicketedEvent @relation("EventTiers", fields: [eventId], references: [id], onDelete: Cascade)
tickets Ticket[] @relation("TierTickets")
@@index([eventId], map: "idx_ticket_tiers_event")
@@map("ticket_tiers")
}
model Ticket {
id String @id @default(cuid())
ticketCode String @unique @map("ticket_code")
tokenHash String @unique @map("token_hash")
eventId String @map("event_id")
tierId String @map("tier_id")
orderId String? @map("order_id")
holderEmail String @map("holder_email")
holderName String? @map("holder_name")
userId String? @map("user_id")
status TicketStatus @default(VALID)
checkedInAt DateTime? @map("checked_in_at")
checkedInByUserId String? @map("checked_in_by_user_id")
issuedAt DateTime @default(now()) @map("issued_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
event TicketedEvent @relation("EventTickets", fields: [eventId], references: [id])
tier TicketTier @relation("TierTickets", fields: [tierId], references: [id])
order Order? @relation("TicketOrder", fields: [orderId], references: [id])
holder User? @relation("TicketHolder", fields: [userId], references: [id])
checkIns CheckIn[] @relation("TicketCheckIns")
@@index([eventId], map: "idx_tickets_event")
@@index([tierId], map: "idx_tickets_tier")
@@index([orderId], map: "idx_tickets_order")
@@index([holderEmail], map: "idx_tickets_holder_email")
@@index([status], map: "idx_tickets_status")
@@map("tickets")
}
model CheckIn {
id String @id @default(cuid())
ticketId String @map("ticket_id")
eventId String @map("event_id")
checkedInByUserId String? @map("checked_in_by_user_id")
method String // "QR" | "MANUAL" | "CODE"
checkedInAt DateTime @default(now()) @map("checked_in_at")
notes String?
// Relations
ticket Ticket @relation("TicketCheckIns", fields: [ticketId], references: [id])
event TicketedEvent @relation("EventCheckIns", fields: [eventId], references: [id])
checkedInBy User? @relation("CheckInUser", fields: [checkedInByUserId], references: [id])
@@index([eventId], map: "idx_checkins_event")
@@index([ticketId], map: "idx_checkins_ticket")
@@map("check_ins")
}
// ============================================================================
// SOCIAL CALENDAR
// ============================================================================
enum CalendarLayerType {
SYSTEM
USER
EXTERNAL
}
enum CalendarSystemType {
SHIFTS
TICKETS
POLLS
PUBLIC_EVENTS
}
enum CalendarVisibility {
PRIVATE
FRIENDS
PUBLIC
}
enum CalendarItemType {
EVENT
TIME_BLOCK
REMINDER
}
enum CalendarBusyStatus {
BUSY
TENTATIVE
FREE
}
enum CalendarShowDetailsTo {
NOBODY
FRIENDS
EVERYONE
}
enum CalendarItemSource {
MANUAL
ICS_FEED
}
enum CalendarRecurrenceFrequency {
DAILY
WEEKLY
BIWEEKLY
MONTHLY
}
enum CalendarFeedStatus {
OK
ERROR
PENDING
}
enum CalendarFeedInterval {
FIFTEEN_MIN
HOURLY
SIX_HOUR
DAILY
}
enum SharedViewType {
MANUAL
ROLE_BASED
}
enum SharedViewScope {
MEMBERS
PUBLIC
}
enum SharedViewMemberStatus {
INVITED
ACCEPTED
DECLINED
}
model CalendarLayer {
id String @id @default(cuid())
userId String @map("user_id")
name String
layerType CalendarLayerType @map("layer_type")
systemType CalendarSystemType? @map("system_type")
color String @default("#1890ff")
visibility CalendarVisibility @default(PRIVATE)
isEnabled Boolean @default(true) @map("is_enabled")
sortOrder Int @default(0) @map("sort_order")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
user User @relation("CalendarLayerOwner", fields: [userId], references: [id], onDelete: Cascade)
items CalendarItem[] @relation("CalendarLayerItems")
feed CalendarFeed? @relation("CalendarFeedLayer")
@@unique([userId, systemType], map: "idx_calendar_layers_user_system")
@@index([userId], map: "idx_calendar_layers_user")
@@map("calendar_layers")
}
model CalendarItem {
id String @id @default(cuid())
userId String @map("user_id")
layerId String @map("layer_id")
title String
description String? @db.Text
date DateTime @db.Date
startTime String @map("start_time") // HH:MM
endTime String @map("end_time") // HH:MM
isAllDay Boolean @default(false) @map("is_all_day")
itemType CalendarItemType @default(EVENT) @map("item_type")
location String?
color String?
visibility CalendarVisibility? // null = inherit from layer
busyStatus CalendarBusyStatus @default(BUSY) @map("busy_status")
showDetailsTo CalendarShowDetailsTo @default(FRIENDS) @map("show_details_to")
// Recurrence
recurrenceRule Json? @map("recurrence_rule")
recurrenceEnd DateTime? @map("recurrence_end")
seriesId String? @map("series_id")
isException Boolean @default(false) @map("is_exception")
// Source tracking
sourceType CalendarItemSource @default(MANUAL) @map("source_type")
sourceId String? @map("source_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
user User @relation("CalendarItemOwner", fields: [userId], references: [id], onDelete: Cascade)
layer CalendarLayer @relation("CalendarLayerItems", fields: [layerId], references: [id], onDelete: Cascade)
@@index([userId, date], map: "idx_calendar_items_user_date")
@@index([layerId, date], map: "idx_calendar_items_layer_date")
@@index([seriesId], map: "idx_calendar_items_series")
@@index([sourceType, sourceId], map: "idx_calendar_items_source")
@@map("calendar_items")
}
model CalendarFeed {
id String @id @default(cuid())
userId String @map("user_id")
name String
url String
layerId String @unique @map("layer_id")
refreshInterval CalendarFeedInterval @default(HOURLY) @map("refresh_interval")
lastFetchedAt DateTime? @map("last_fetched_at")
lastStatus CalendarFeedStatus @default(PENDING) @map("last_status")
lastError String? @map("last_error")
itemCount Int @default(0) @map("item_count")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
user User @relation("CalendarFeedOwner", fields: [userId], references: [id], onDelete: Cascade)
layer CalendarLayer @relation("CalendarFeedLayer", fields: [layerId], references: [id], onDelete: Cascade)
@@index([userId], map: "idx_calendar_feeds_user")
@@map("calendar_feeds")
}
model SharedCalendarView {
id String @id @default(cuid())
name String
description String? @db.Text
ownerId String @map("owner_id")
viewType SharedViewType @default(MANUAL) @map("view_type")
autoIncludeRoles Json? @map("auto_include_roles")
includedLayerTypes Json @default("[]") @map("included_layer_types")
shareScope SharedViewScope @default(MEMBERS) @map("share_scope")
shareToken String? @unique @map("share_token")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
owner User @relation("SharedViewOwner", fields: [ownerId], references: [id], onDelete: Cascade)
members SharedCalendarMember[] @relation("SharedViewMembers")
comments SharedViewComment[] @relation("SharedViewComments")
reactions SharedViewReaction[] @relation("SharedViewReactions")
@@index([ownerId], map: "idx_shared_views_owner")
@@map("shared_calendar_views")
}
model SharedCalendarMember {
id String @id @default(cuid())
viewId String @map("view_id")
userId String @map("user_id")
status SharedViewMemberStatus @default(INVITED)
color String @default("#1890ff")
joinedAt DateTime? @map("joined_at")
// Relations
view SharedCalendarView @relation("SharedViewMembers", fields: [viewId], references: [id], onDelete: Cascade)
user User @relation("SharedViewMember", fields: [userId], references: [id], onDelete: Cascade)
@@unique([viewId, userId], map: "idx_shared_members_view_user")
@@index([userId], map: "idx_shared_members_user")
@@map("shared_calendar_members")
}
model SharedViewComment {
id String @id @default(cuid())
viewId String @map("view_id")
userId String @map("user_id")
itemDate String @map("item_date") // YYYY-MM-DD
itemId String? @map("item_id") // specific item reference
content String @db.Text
createdAt DateTime @default(now()) @map("created_at")
// Relations
view SharedCalendarView @relation("SharedViewComments", fields: [viewId], references: [id], onDelete: Cascade)
user User @relation("SharedViewCommentUser", fields: [userId], references: [id], onDelete: Cascade)
@@index([viewId, itemDate], map: "idx_shared_comments_view_date")
@@map("shared_view_comments")
}
model SharedViewReaction {
id String @id @default(cuid())
viewId String @map("view_id")
userId String @map("user_id")
itemId String @map("item_id")
emoji String
createdAt DateTime @default(now()) @map("created_at")
// Relations
view SharedCalendarView @relation("SharedViewReactions", fields: [viewId], references: [id], onDelete: Cascade)
user User @relation("SharedViewReactionUser", fields: [userId], references: [id], onDelete: Cascade)
@@unique([viewId, userId, itemId, emoji], map: "idx_shared_reactions_unique")
@@map("shared_view_reactions")
}
model CalendarExportToken {
id String @id @default(cuid())
userId String @map("user_id")
token String @unique
includePersonal Boolean @default(false) @map("include_personal")
includeLayers Json? @map("include_layers")
createdAt DateTime @default(now()) @map("created_at")
// Relations
user User @relation("CalendarExportTokenOwner", fields: [userId], references: [id], onDelete: Cascade)
@@index([userId], map: "idx_calendar_export_tokens_user")
@@map("calendar_export_tokens")
}

View File

@ -15,7 +15,6 @@ export const registerSchema = z.object({
.regex(/[0-9]/, 'Password must contain at least one digit'), .regex(/[0-9]/, 'Password must contain at least one digit'),
name: z.string().optional(), name: z.string().optional(),
phone: z.string().optional(), phone: z.string().optional(),
inviteCode: z.string().max(20).optional(),
// Role removed from public registration - must be set server-side only // Role removed from public registration - must be set server-side only
}); });

View File

@ -132,15 +132,6 @@ export const authService = {
}, },
}); });
// Fire-and-forget: process referral if invite code provided
if (data.inviteCode) {
import('../social/referral.service').then(({ referralService }) => {
referralService.processRegistrationReferral(user.id, data.inviteCode).catch(err => {
logger.warn('Referral processing failed:', err);
});
}).catch(() => {});
}
// Fire-and-forget: auto-link or create Contact if People feature is enabled // Fire-and-forget: auto-link or create Contact if People feature is enabled
siteSettingsService.get().then(async (s) => { siteSettingsService.get().then(async (s) => {
if (!s.enablePeople) return; if (!s.enablePeople) return;

View File

@ -1,74 +0,0 @@
import { Router } from 'express';
import { authenticate } from '../../middleware/auth.middleware';
import { requireRole } from '../../middleware/rbac.middleware';
import { validate } from '../../middleware/validate';
import { adminCalendarService } from './admin-calendar.service';
import { createAdminViewSchema, updateAdminViewSchema } from './admin-calendar.schemas';
import { dateRangeQuerySchema } from './shared-calendar.schemas';
const router = Router();
router.use(authenticate);
router.use(requireRole('SUPER_ADMIN', 'MAP_ADMIN'));
// List admin calendar views
router.get('/', async (req, res, next) => {
try {
const views = await adminCalendarService.listAdminViews(req.user!.id);
res.json({ views });
} catch (error) {
next(error);
}
});
// Create admin calendar view
router.post('/', validate(createAdminViewSchema), async (req, res, next) => {
try {
const view = await adminCalendarService.createAdminView(req.user!.id, req.body);
res.status(201).json({ view });
} catch (error) {
next(error);
}
});
// Update admin calendar view
router.patch('/:id', validate(updateAdminViewSchema), async (req, res, next) => {
try {
const view = await adminCalendarService.updateAdminView(
req.user!.id,
req.params.id as string,
req.body,
);
res.json({ view });
} catch (error) {
next(error);
}
});
// Delete admin calendar view
router.delete('/:id', async (req, res, next) => {
try {
await adminCalendarService.deleteAdminView(req.user!.id, req.params.id as string);
res.json({ success: true });
} catch (error) {
next(error);
}
});
// Get merged items for admin calendar view
router.get('/:id/items', validate(dateRangeQuerySchema, 'query'), async (req, res, next) => {
try {
const { startDate, endDate } = req.query as { startDate: string; endDate: string };
const result = await adminCalendarService.getAdminViewItems(
req.params.id as string,
req.user!.id,
startDate,
endDate,
);
res.json(result);
} catch (error) {
next(error);
}
});
export const adminCalendarRouter = router;

View File

@ -1,21 +0,0 @@
import { z } from 'zod';
const VALID_ROLES = ['SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN', 'USER', 'TEMP'] as const;
const VALID_LAYER_TYPES = ['SHIFTS', 'TICKETS', 'POLLS', 'PUBLIC_EVENTS'] as const;
export const createAdminViewSchema = z.object({
name: z.string().min(1).max(100),
description: z.string().optional(),
autoIncludeRoles: z.array(z.enum(VALID_ROLES)).min(1),
includedLayerTypes: z.array(z.enum(VALID_LAYER_TYPES)).min(1),
});
export const updateAdminViewSchema = z.object({
name: z.string().min(1).max(100).optional(),
description: z.string().nullable().optional(),
autoIncludeRoles: z.array(z.enum(VALID_ROLES)).min(1).optional(),
includedLayerTypes: z.array(z.enum(VALID_LAYER_TYPES)).min(1).optional(),
});
export type CreateAdminViewInput = z.infer<typeof createAdminViewSchema>;
export type UpdateAdminViewInput = z.infer<typeof updateAdminViewSchema>;

View File

@ -1,173 +0,0 @@
import { CalendarLayerType, CalendarSystemType, Prisma } from '@prisma/client';
import { prisma } from '../../config/database';
import { sharedCalendarService } from './shared-calendar.service';
import { AppError } from '../../middleware/error-handler';
import { logger } from '../../utils/logger';
import type { CreateAdminViewInput, UpdateAdminViewInput } from './admin-calendar.schemas';
const MEMBER_COLORS = [
'#1890ff', '#52c41a', '#fa8c16', '#eb2f96', '#722ed1',
'#13c2c2', '#faad14', '#f5222d', '#2f54eb', '#a0d911',
];
export const adminCalendarService = {
async listAdminViews(userId: string) {
const views = await prisma.sharedCalendarView.findMany({
where: { viewType: 'ROLE_BASED', ownerId: userId },
include: {
owner: { select: { id: true, name: true, email: true } },
},
orderBy: { createdAt: 'desc' },
});
// Count matching users for each view
const viewsWithCounts = await Promise.all(
views.map(async (view) => {
const roles = (view.autoIncludeRoles as string[]) || [];
const count = await prisma.user.count({
where: { role: { in: roles as any[] }, status: 'ACTIVE' },
});
return { ...view, userCount: count };
}),
);
return viewsWithCounts;
},
async createAdminView(userId: string, data: CreateAdminViewInput) {
const view = await prisma.sharedCalendarView.create({
data: {
name: data.name,
description: data.description,
ownerId: userId,
viewType: 'ROLE_BASED',
autoIncludeRoles: data.autoIncludeRoles as unknown as Prisma.InputJsonValue,
includedLayerTypes: data.includedLayerTypes as unknown as Prisma.InputJsonValue,
shareScope: 'MEMBERS',
},
include: {
owner: { select: { id: true, name: true, email: true } },
},
});
return view;
},
async updateAdminView(userId: string, viewId: string, data: UpdateAdminViewInput) {
const view = await prisma.sharedCalendarView.findFirst({
where: { id: viewId, ownerId: userId, viewType: 'ROLE_BASED' },
});
if (!view) throw new AppError(404, 'Admin view not found or not owner', 'NOT_FOUND');
return prisma.sharedCalendarView.update({
where: { id: viewId },
data: {
...(data.name !== undefined && { name: data.name }),
...(data.description !== undefined && { description: data.description }),
...(data.autoIncludeRoles !== undefined && {
autoIncludeRoles: data.autoIncludeRoles as unknown as Prisma.InputJsonValue,
}),
...(data.includedLayerTypes !== undefined && {
includedLayerTypes: data.includedLayerTypes as unknown as Prisma.InputJsonValue,
}),
},
include: {
owner: { select: { id: true, name: true, email: true } },
},
});
},
async deleteAdminView(userId: string, viewId: string) {
const view = await prisma.sharedCalendarView.findFirst({
where: { id: viewId, ownerId: userId, viewType: 'ROLE_BASED' },
});
if (!view) throw new AppError(404, 'Admin view not found or not owner', 'NOT_FOUND');
await prisma.sharedCalendarView.delete({ where: { id: viewId } });
},
async getAdminViewItems(
viewId: string,
userId: string,
startDate: string,
endDate: string,
) {
const view = await prisma.sharedCalendarView.findFirst({
where: { id: viewId, ownerId: userId, viewType: 'ROLE_BASED' },
});
if (!view) throw new AppError(404, 'Admin view not found or not owner', 'NOT_FOUND');
const roles = (view.autoIncludeRoles as string[]) || [];
const includedLayerTypes = (view.includedLayerTypes as string[]) || [];
// Query users by role (live, no member rows)
const users = await prisma.user.findMany({
where: { role: { in: roles as any[] }, status: 'ACTIVE' },
orderBy: { name: 'asc' },
take: 50,
select: { id: true, name: true, email: true, role: true },
});
const totalUsers = await prisma.user.count({
where: { role: { in: roles as any[] }, status: 'ACTIVE' },
});
const start = new Date(startDate);
const end = new Date(endDate);
const allItems: any[] = [];
for (let i = 0; i < users.length; i++) {
const user = users[i];
const color = MEMBER_COLORS[i % MEMBER_COLORS.length];
// Get this user's system layers matching includedLayerTypes
const layers = await prisma.calendarLayer.findMany({
where: {
userId: user.id,
isEnabled: true,
layerType: CalendarLayerType.SYSTEM,
systemType: { in: includedLayerTypes as CalendarSystemType[] },
},
});
for (const layer of layers) {
try {
const items = await sharedCalendarService.getSystemLayerItems(
user.id,
layer,
start,
end,
);
for (const item of items) {
allItems.push({
...item,
userId: user.id,
userName: user.name || user.email,
userColor: color,
});
}
} catch (err) {
logger.debug(`Failed to fetch system layer items for user ${user.id}:`, err);
}
}
}
// Sort by date then time
allItems.sort((a, b) => {
const dc = a.date.localeCompare(b.date);
return dc !== 0 ? dc : a.startTime.localeCompare(b.startTime);
});
return {
items: allItems,
users: users.map((u, i) => ({
...u,
color: MEMBER_COLORS[i % MEMBER_COLORS.length],
})),
totalUsers,
truncated: totalUsers > 50,
};
},
};

View File

@ -1,152 +0,0 @@
import { Router } from 'express';
import { authenticate } from '../../middleware/auth.middleware';
import { validate } from '../../middleware/validate';
import { calendarService } from './calendar.service';
import {
createLayerSchema,
updateLayerSchema,
createItemSchema,
updateItemSchema,
dateRangeSchema,
seriesEditScopeSchema,
} from './calendar.schemas';
import type { SeriesEditScope } from './calendar.schemas';
const router = Router();
// All routes require authentication
router.use(authenticate);
// =========================================================================
// Layers
// =========================================================================
// GET /api/calendar/layers — get all user layers (ensures system layers exist)
router.get('/layers', async (req, res, next) => {
try {
const layers = await calendarService.ensureSystemLayers(req.user!.id);
res.json({ layers });
} catch (error) {
next(error);
}
});
// POST /api/calendar/layers — create a user layer
router.post('/layers', validate(createLayerSchema), async (req, res, next) => {
try {
const layer = await calendarService.createLayer(req.user!.id, req.body);
res.status(201).json({ layer });
} catch (error) {
next(error);
}
});
// PATCH /api/calendar/layers/:id — update a layer
router.patch('/layers/:id', validate(updateLayerSchema), async (req, res, next) => {
try {
const layer = await calendarService.updateLayer(
req.user!.id,
req.params.id as string,
req.body
);
res.json({ layer });
} catch (error) {
next(error);
}
});
// DELETE /api/calendar/layers/:id — delete a layer (USER/EXTERNAL only)
router.delete('/layers/:id', async (req, res, next) => {
try {
await calendarService.deleteLayer(req.user!.id, req.params.id as string);
res.json({ success: true });
} catch (error) {
next(error);
}
});
// =========================================================================
// Items
// =========================================================================
// GET /api/calendar/items?startDate=&endDate=&layerIds=
router.get('/items', validate(dateRangeSchema, 'query'), async (req, res, next) => {
try {
const { startDate, endDate } = req.query as { startDate: string; endDate: string };
const layerIdsRaw = req.query.layerIds as string | undefined;
const layerIds = layerIdsRaw ? layerIdsRaw.split(',').filter(Boolean) : undefined;
const items = await calendarService.getItems(req.user!.id, startDate, endDate, layerIds);
res.json({ items });
} catch (error) {
next(error);
}
});
// POST /api/calendar/items — create item
router.post('/items', validate(createItemSchema), async (req, res, next) => {
try {
const item = await calendarService.createItem(req.user!.id, req.body);
res.status(201).json({ item });
} catch (error) {
next(error);
}
});
// PATCH /api/calendar/items/:id?scope=THIS_ONLY|THIS_AND_FUTURE|ALL
router.patch('/items/:id', validate(updateItemSchema), async (req, res, next) => {
try {
const scopeRaw = req.query.scope as string | undefined;
let scope: SeriesEditScope | undefined;
if (scopeRaw) {
scope = seriesEditScopeSchema.parse(scopeRaw);
}
const item = await calendarService.updateItem(
req.user!.id,
req.params.id as string,
req.body,
scope
);
res.json({ item });
} catch (error) {
next(error);
}
});
// DELETE /api/calendar/items/:id?scope=THIS_ONLY|THIS_AND_FUTURE|ALL
router.delete('/items/:id', async (req, res, next) => {
try {
const scopeRaw = req.query.scope as string | undefined;
let scope: SeriesEditScope | undefined;
if (scopeRaw) {
scope = seriesEditScopeSchema.parse(scopeRaw);
}
await calendarService.deleteItem(
req.user!.id,
req.params.id as string,
scope
);
res.json({ success: true });
} catch (error) {
next(error);
}
});
// =========================================================================
// Personal calendar (merged view)
// =========================================================================
// GET /api/calendar/my?startDate=&endDate=
router.get('/my', validate(dateRangeSchema, 'query'), async (req, res, next) => {
try {
const { startDate, endDate } = req.query as { startDate: string; endDate: string };
const result = await calendarService.getPersonalCalendar(req.user!.id, startDate, endDate);
res.json(result);
} catch (error) {
next(error);
}
});
export default router;

View File

@ -1,80 +0,0 @@
import { z } from 'zod';
// --- Layer schemas ---
export const createLayerSchema = z.object({
name: z.string().min(1).max(100),
color: z.string().regex(/^#[0-9a-fA-F]{6}$/, 'Color must be a hex color (#RRGGBB)'),
visibility: z.enum(['PRIVATE', 'FRIENDS', 'PUBLIC']),
});
export const updateLayerSchema = z.object({
name: z.string().min(1).max(100).optional(),
color: z.string().regex(/^#[0-9a-fA-F]{6}$/, 'Color must be a hex color (#RRGGBB)').optional(),
visibility: z.enum(['PRIVATE', 'FRIENDS', 'PUBLIC']).optional(),
isEnabled: z.boolean().optional(),
sortOrder: z.number().int().min(0).optional(),
});
// --- Item schemas ---
const recurrenceRuleSchema = z.object({
frequency: z.enum(['DAILY', 'WEEKLY', 'BIWEEKLY', 'MONTHLY']),
daysOfWeek: z.array(z.number().int().min(1).max(7)).optional(), // 1=Mon...7=Sun
dayOfMonth: z.number().int().min(1).max(31).optional(),
interval: z.number().int().min(1).max(12).optional(),
});
export const createItemSchema = z.object({
layerId: z.string().min(1),
title: z.string().min(1).max(200),
description: z.string().optional(),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be YYYY-MM-DD'),
startTime: z.string().regex(/^\d{2}:\d{2}$/, 'Start time must be HH:MM'),
endTime: z.string().regex(/^\d{2}:\d{2}$/, 'End time must be HH:MM'),
isAllDay: z.boolean().optional(),
itemType: z.enum(['EVENT', 'TIME_BLOCK', 'REMINDER']),
location: z.string().optional(),
color: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional(),
visibility: z.enum(['PRIVATE', 'FRIENDS', 'PUBLIC']).optional(),
busyStatus: z.enum(['BUSY', 'TENTATIVE', 'FREE']).optional(),
showDetailsTo: z.enum(['NOBODY', 'FRIENDS', 'EVERYONE']).optional(),
recurrenceRule: recurrenceRuleSchema.optional(),
recurrenceEnd: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Recurrence end must be YYYY-MM-DD').optional(),
});
export const updateItemSchema = z.object({
title: z.string().min(1).max(200).optional(),
description: z.string().nullable().optional(),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be YYYY-MM-DD').optional(),
startTime: z.string().regex(/^\d{2}:\d{2}$/, 'Start time must be HH:MM').optional(),
endTime: z.string().regex(/^\d{2}:\d{2}$/, 'End time must be HH:MM').optional(),
isAllDay: z.boolean().optional(),
itemType: z.enum(['EVENT', 'TIME_BLOCK', 'REMINDER']).optional(),
location: z.string().nullable().optional(),
color: z.string().regex(/^#[0-9a-fA-F]{6}$/).nullable().optional(),
visibility: z.enum(['PRIVATE', 'FRIENDS', 'PUBLIC']).nullable().optional(),
busyStatus: z.enum(['BUSY', 'TENTATIVE', 'FREE']).optional(),
showDetailsTo: z.enum(['NOBODY', 'FRIENDS', 'EVERYONE']).optional(),
recurrenceRule: recurrenceRuleSchema.nullable().optional(),
recurrenceEnd: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional(),
});
// --- Scope for recurring item edits ---
export const seriesEditScopeSchema = z.enum(['THIS_ONLY', 'THIS_AND_FUTURE', 'ALL']);
// --- Date range query ---
export const dateRangeSchema = z.object({
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'startDate must be YYYY-MM-DD'),
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'endDate must be YYYY-MM-DD'),
});
// --- Exported types ---
export type CreateLayerInput = z.infer<typeof createLayerSchema>;
export type UpdateLayerInput = z.infer<typeof updateLayerSchema>;
export type CreateItemInput = z.infer<typeof createItemSchema>;
export type UpdateItemInput = z.infer<typeof updateItemSchema>;
export type SeriesEditScope = z.infer<typeof seriesEditScopeSchema>;

View File

@ -1,781 +0,0 @@
import {
CalendarLayerType,
CalendarSystemType,
CalendarItemType,
CalendarBusyStatus,
CalendarShowDetailsTo,
CalendarItemSource,
CalendarVisibility,
SignupStatus,
ShiftStatus,
Prisma,
} from '@prisma/client';
import { prisma } from '../../config/database';
import { logger } from '../../utils/logger';
import { AppError } from '../../middleware/error-handler';
import type {
CreateLayerInput,
UpdateLayerInput,
CreateItemInput,
UpdateItemInput,
SeriesEditScope,
} from './calendar.schemas';
// Shape reused by the personal calendar endpoint
interface PersonalCalendarItem {
id: string;
type: 'calendar_item' | 'shift' | 'ticket' | 'poll' | 'public_event';
title: string;
date: string; // YYYY-MM-DD
startTime: string; // HH:MM
endTime: string; // HH:MM
location: string | null;
color: string | null;
itemType: string;
layerId: string;
layerName: string;
layerColor: string;
}
interface PersonalCalendarResponse {
dates: Record<string, { count: number; items: PersonalCalendarItem[] }>;
}
// Recurrence rule shape stored in JSON
interface RecurrenceRule {
frequency: 'DAILY' | 'WEEKLY' | 'BIWEEKLY' | 'MONTHLY';
daysOfWeek?: number[]; // 1=Mon...7=Sun
dayOfMonth?: number;
interval?: number;
}
const SYSTEM_LAYER_DEFS: { systemType: CalendarSystemType; name: string; color: string }[] = [
{ systemType: CalendarSystemType.SHIFTS, name: 'My Shifts', color: '#52c41a' },
{ systemType: CalendarSystemType.TICKETS, name: 'My Tickets', color: '#fa8c16' },
{ systemType: CalendarSystemType.POLLS, name: 'My Polls', color: '#722ed1' },
{ systemType: CalendarSystemType.PUBLIC_EVENTS, name: 'Public Events', color: '#1890ff' },
];
const MAX_RECURRENCE_DAYS = 92; // ~3 months
export const calendarService = {
// =========================================================================
// Layer management
// =========================================================================
async ensureSystemLayers(userId: string) {
for (const def of SYSTEM_LAYER_DEFS) {
await prisma.calendarLayer.upsert({
where: {
userId_systemType: { userId, systemType: def.systemType },
},
update: {}, // no-op if exists
create: {
userId,
name: def.name,
color: def.color,
layerType: CalendarLayerType.SYSTEM,
systemType: def.systemType,
visibility: CalendarVisibility.PRIVATE,
},
});
}
return this.getUserLayers(userId);
},
async getUserLayers(userId: string) {
return prisma.calendarLayer.findMany({
where: { userId },
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
});
},
async createLayer(userId: string, data: CreateLayerInput) {
// Get the next sort order
const maxSort = await prisma.calendarLayer.aggregate({
where: { userId },
_max: { sortOrder: true },
});
const sortOrder = (maxSort._max.sortOrder ?? -1) + 1;
return prisma.calendarLayer.create({
data: {
userId,
name: data.name,
color: data.color,
visibility: data.visibility as CalendarVisibility,
layerType: CalendarLayerType.USER,
sortOrder,
},
});
},
async updateLayer(userId: string, layerId: string, data: UpdateLayerInput) {
const layer = await prisma.calendarLayer.findFirst({
where: { id: layerId, userId },
});
if (!layer) {
throw new AppError(404, 'Layer not found', 'NOT_FOUND');
}
return prisma.calendarLayer.update({
where: { id: layerId },
data: {
...(data.name !== undefined && { name: data.name }),
...(data.color !== undefined && { color: data.color }),
...(data.visibility !== undefined && { visibility: data.visibility as CalendarVisibility }),
...(data.isEnabled !== undefined && { isEnabled: data.isEnabled }),
...(data.sortOrder !== undefined && { sortOrder: data.sortOrder }),
},
});
},
async deleteLayer(userId: string, layerId: string) {
const layer = await prisma.calendarLayer.findFirst({
where: { id: layerId, userId },
});
if (!layer) {
throw new AppError(404, 'Layer not found', 'NOT_FOUND');
}
if (layer.layerType === CalendarLayerType.SYSTEM) {
throw new AppError(400, 'Cannot delete system layers', 'CANNOT_DELETE_SYSTEM');
}
await prisma.calendarLayer.delete({ where: { id: layerId } });
},
// =========================================================================
// Item management
// =========================================================================
async getItems(userId: string, startDate: string, endDate: string, layerIds?: string[]) {
const start = new Date(startDate);
const end = new Date(endDate);
const where: Prisma.CalendarItemWhereInput = {
userId,
date: { gte: start, lte: end },
};
if (layerIds && layerIds.length > 0) {
where.layerId = { in: layerIds };
}
return prisma.calendarItem.findMany({
where,
orderBy: [{ date: 'asc' }, { startTime: 'asc' }],
include: {
layer: { select: { name: true, color: true, layerType: true, systemType: true } },
},
});
},
async createItem(userId: string, data: CreateItemInput) {
// Verify layer ownership
const layer = await prisma.calendarLayer.findFirst({
where: { id: data.layerId, userId },
});
if (!layer) {
throw new AppError(404, 'Layer not found', 'LAYER_NOT_FOUND');
}
if (layer.layerType === CalendarLayerType.SYSTEM) {
throw new AppError(400, 'Cannot add items to system layers', 'CANNOT_ADD_TO_SYSTEM');
}
const recurrenceRule = data.recurrenceRule
? (data.recurrenceRule as unknown as Prisma.InputJsonValue)
: undefined;
// Create the template item
const templateItem = await prisma.calendarItem.create({
data: {
userId,
layerId: data.layerId,
title: data.title,
description: data.description,
date: new Date(data.date),
startTime: data.startTime,
endTime: data.endTime,
isAllDay: data.isAllDay ?? false,
itemType: (data.itemType as CalendarItemType) ?? CalendarItemType.EVENT,
location: data.location,
color: data.color,
visibility: data.visibility as CalendarVisibility | undefined,
busyStatus: (data.busyStatus as CalendarBusyStatus) ?? CalendarBusyStatus.BUSY,
showDetailsTo: (data.showDetailsTo as CalendarShowDetailsTo) ?? CalendarShowDetailsTo.FRIENDS,
recurrenceRule,
recurrenceEnd: data.recurrenceEnd ? new Date(data.recurrenceEnd) : undefined,
sourceType: CalendarItemSource.MANUAL,
},
});
// If recurring, set seriesId to own id and materialize instances
if (data.recurrenceRule) {
await prisma.calendarItem.update({
where: { id: templateItem.id },
data: { seriesId: templateItem.id },
});
const startDate = new Date(data.date);
const endDate = new Date(startDate);
endDate.setDate(endDate.getDate() + MAX_RECURRENCE_DAYS);
// Limit recurrence end
const recEnd = data.recurrenceEnd ? new Date(data.recurrenceEnd) : endDate;
const effectiveEnd = recEnd < endDate ? recEnd : endDate;
await this.materializeRecurrence(
{ ...templateItem, seriesId: templateItem.id },
startDate,
effectiveEnd
);
}
return prisma.calendarItem.findUnique({
where: { id: templateItem.id },
include: {
layer: { select: { name: true, color: true, layerType: true, systemType: true } },
},
});
},
async updateItem(
userId: string,
itemId: string,
data: UpdateItemInput,
scope?: SeriesEditScope
) {
const item = await prisma.calendarItem.findFirst({
where: { id: itemId, userId },
});
if (!item) {
throw new AppError(404, 'Item not found', 'NOT_FOUND');
}
const updateData: Prisma.CalendarItemUncheckedUpdateInput = {};
if (data.title !== undefined) updateData.title = data.title;
if (data.description !== undefined) updateData.description = data.description;
if (data.date !== undefined) updateData.date = new Date(data.date);
if (data.startTime !== undefined) updateData.startTime = data.startTime;
if (data.endTime !== undefined) updateData.endTime = data.endTime;
if (data.isAllDay !== undefined) updateData.isAllDay = data.isAllDay;
if (data.itemType !== undefined) updateData.itemType = data.itemType as CalendarItemType;
if (data.location !== undefined) updateData.location = data.location;
if (data.color !== undefined) updateData.color = data.color;
if (data.visibility !== undefined) updateData.visibility = data.visibility as CalendarVisibility | null;
if (data.busyStatus !== undefined) updateData.busyStatus = data.busyStatus as CalendarBusyStatus;
if (data.showDetailsTo !== undefined) updateData.showDetailsTo = data.showDetailsTo as CalendarShowDetailsTo;
if (data.recurrenceRule !== undefined) {
updateData.recurrenceRule = data.recurrenceRule as unknown as Prisma.InputJsonValue ?? Prisma.JsonNull;
}
if (data.recurrenceEnd !== undefined) {
updateData.recurrenceEnd = data.recurrenceEnd ? new Date(data.recurrenceEnd) : null;
}
// Non-recurring or no scope: simple update
if (!item.seriesId || !scope || scope === 'THIS_ONLY') {
if (item.seriesId && scope === 'THIS_ONLY') {
// Mark as exception so future series updates skip it
updateData.isException = true;
}
return prisma.calendarItem.update({
where: { id: itemId },
data: updateData,
include: {
layer: { select: { name: true, color: true, layerType: true, systemType: true } },
},
});
}
if (scope === 'ALL') {
// Update all non-exception instances in the series
await prisma.calendarItem.updateMany({
where: { seriesId: item.seriesId, userId, isException: false },
data: updateData,
});
return prisma.calendarItem.findFirst({
where: { id: itemId },
include: {
layer: { select: { name: true, color: true, layerType: true, systemType: true } },
},
});
}
if (scope === 'THIS_AND_FUTURE') {
// Update this item and all future non-exception instances
await prisma.calendarItem.updateMany({
where: {
seriesId: item.seriesId,
userId,
isException: false,
date: { gte: item.date },
},
data: updateData,
});
return prisma.calendarItem.findFirst({
where: { id: itemId },
include: {
layer: { select: { name: true, color: true, layerType: true, systemType: true } },
},
});
}
// Fallback: simple update
return prisma.calendarItem.update({
where: { id: itemId },
data: updateData,
include: {
layer: { select: { name: true, color: true, layerType: true, systemType: true } },
},
});
},
async deleteItem(userId: string, itemId: string, scope?: SeriesEditScope) {
const item = await prisma.calendarItem.findFirst({
where: { id: itemId, userId },
});
if (!item) {
throw new AppError(404, 'Item not found', 'NOT_FOUND');
}
// Non-recurring or no scope: delete single item
if (!item.seriesId || !scope || scope === 'THIS_ONLY') {
await prisma.calendarItem.delete({ where: { id: itemId } });
return;
}
if (scope === 'ALL') {
await prisma.calendarItem.deleteMany({
where: { seriesId: item.seriesId, userId },
});
return;
}
if (scope === 'THIS_AND_FUTURE') {
await prisma.calendarItem.deleteMany({
where: {
seriesId: item.seriesId,
userId,
date: { gte: item.date },
},
});
}
},
// =========================================================================
// Recurrence materialization
// =========================================================================
async materializeRecurrence(
templateItem: {
id: string;
userId: string;
layerId: string;
seriesId: string;
title: string;
description: string | null;
startTime: string;
endTime: string;
isAllDay: boolean;
itemType: CalendarItemType;
location: string | null;
color: string | null;
visibility: CalendarVisibility | null;
busyStatus: CalendarBusyStatus;
showDetailsTo: CalendarShowDetailsTo;
recurrenceRule: unknown;
date: Date;
},
startDate: Date,
endDate: Date
) {
const rule = templateItem.recurrenceRule as RecurrenceRule | null;
if (!rule) return;
const interval = rule.interval ?? 1;
const dates = generateRecurrenceDates(rule, startDate, endDate, interval);
// Remove the template item's own date (it already exists)
const templateDateStr = templateItem.date.toISOString().split('T')[0];
const filteredDates = dates.filter(d => d.toISOString().split('T')[0] !== templateDateStr);
if (filteredDates.length === 0) return;
// Fetch existing instance dates to avoid duplicates
const existing = await prisma.calendarItem.findMany({
where: {
seriesId: templateItem.seriesId,
userId: templateItem.userId,
},
select: { date: true },
});
const existingDateSet = new Set(existing.map(e => e.date.toISOString().split('T')[0]));
const newDates = filteredDates.filter(
d => !existingDateSet.has(d.toISOString().split('T')[0])
);
if (newDates.length === 0) return;
// Batch create
await prisma.calendarItem.createMany({
data: newDates.map(date => ({
userId: templateItem.userId,
layerId: templateItem.layerId,
title: templateItem.title,
description: templateItem.description,
date,
startTime: templateItem.startTime,
endTime: templateItem.endTime,
isAllDay: templateItem.isAllDay,
itemType: templateItem.itemType,
location: templateItem.location,
color: templateItem.color,
visibility: templateItem.visibility,
busyStatus: templateItem.busyStatus,
showDetailsTo: templateItem.showDetailsTo,
seriesId: templateItem.seriesId,
sourceType: CalendarItemSource.MANUAL,
})),
});
logger.debug(`Materialized ${newDates.length} recurrence instances for series ${templateItem.seriesId}`);
},
// =========================================================================
// Personal calendar (merged view)
// =========================================================================
async getPersonalCalendar(
userId: string,
startDate: string,
endDate: string
): Promise<PersonalCalendarResponse> {
const start = new Date(startDate);
const end = new Date(endDate);
// Fetch user's layers
const layers = await prisma.calendarLayer.findMany({
where: { userId, isEnabled: true },
});
const layerMap = new Map(layers.map(l => [l.id, l]));
const systemLayersByType = new Map(
layers.filter(l => l.systemType).map(l => [l.systemType!, l])
);
const allItems: PersonalCalendarItem[] = [];
// 1. User's CalendarItems from enabled layers
const enabledLayerIds = layers.map(l => l.id);
if (enabledLayerIds.length > 0) {
const items = await prisma.calendarItem.findMany({
where: {
userId,
layerId: { in: enabledLayerIds },
date: { gte: start, lte: end },
},
orderBy: [{ date: 'asc' }, { startTime: 'asc' }],
});
for (const item of items) {
const layer = layerMap.get(item.layerId);
if (!layer) continue;
allItems.push({
id: item.id,
type: 'calendar_item',
title: item.title,
date: item.date.toISOString().split('T')[0],
startTime: item.startTime,
endTime: item.endTime,
location: item.location,
color: item.color ?? layer.color,
itemType: item.itemType,
layerId: layer.id,
layerName: layer.name,
layerColor: layer.color,
});
}
}
// 2. System layer: SHIFTS (from ShiftSignup where userId matches)
const shiftsLayer = systemLayersByType.get(CalendarSystemType.SHIFTS);
if (shiftsLayer) {
try {
const signups = await prisma.shiftSignup.findMany({
where: {
userId,
status: SignupStatus.CONFIRMED,
shift: {
status: { not: ShiftStatus.CANCELLED },
date: { gte: start, lte: end },
},
},
include: {
shift: {
select: { id: true, title: true, date: true, startTime: true, endTime: true, location: true },
},
},
});
for (const signup of signups) {
const s = signup.shift;
allItems.push({
id: `shift-${s.id}`,
type: 'shift',
title: s.title,
date: s.date.toISOString().split('T')[0],
startTime: s.startTime,
endTime: s.endTime,
location: s.location,
color: shiftsLayer.color,
itemType: 'EVENT',
layerId: shiftsLayer.id,
layerName: shiftsLayer.name,
layerColor: shiftsLayer.color,
});
}
} catch (err) {
logger.debug('Failed to fetch shifts for personal calendar:', err);
}
}
// 3. System layer: TICKETS (from Ticket where userId matches)
const ticketsLayer = systemLayersByType.get(CalendarSystemType.TICKETS);
if (ticketsLayer) {
try {
const tickets = await prisma.ticket.findMany({
where: {
userId,
status: 'VALID',
event: {
date: { gte: start, lte: end },
},
},
include: {
event: {
select: { id: true, title: true, date: true, startTime: true, endTime: true, venueName: true },
},
},
});
for (const ticket of tickets) {
const e = ticket.event;
allItems.push({
id: `ticket-${ticket.id}`,
type: 'ticket',
title: e.title,
date: e.date.toISOString().split('T')[0],
startTime: e.startTime,
endTime: e.endTime,
location: e.venueName,
color: ticketsLayer.color,
itemType: 'EVENT',
layerId: ticketsLayer.id,
layerName: ticketsLayer.name,
layerColor: ticketsLayer.color,
});
}
} catch (err) {
logger.debug('Failed to fetch tickets for personal calendar:', err);
}
}
// 4. System layer: POLLS (from SchedulingPollVote where userId matches)
const pollsLayer = systemLayersByType.get(CalendarSystemType.POLLS);
if (pollsLayer) {
try {
const votes = await prisma.schedulingPollVote.findMany({
where: {
userId,
option: {
date: { gte: start, lte: end },
},
},
include: {
poll: { select: { id: true, title: true, location: true } },
option: { select: { id: true, date: true, startTime: true, endTime: true } },
},
});
// Deduplicate by poll+option (a user may have multiple votes per poll but same option)
const seen = new Set<string>();
for (const vote of votes) {
const key = `${vote.poll.id}-${vote.option.id}`;
if (seen.has(key)) continue;
seen.add(key);
allItems.push({
id: `poll-${vote.poll.id}-${vote.option.id}`,
type: 'poll',
title: vote.poll.title,
date: vote.option.date.toISOString().split('T')[0],
startTime: vote.option.startTime,
endTime: vote.option.endTime,
location: vote.poll.location,
color: pollsLayer.color,
itemType: 'EVENT',
layerId: pollsLayer.id,
layerName: pollsLayer.name,
layerColor: pollsLayer.color,
});
}
} catch (err) {
logger.debug('Failed to fetch polls for personal calendar:', err);
}
}
// Sort all items by date then time
allItems.sort((a, b) => {
const dateCompare = a.date.localeCompare(b.date);
if (dateCompare !== 0) return dateCompare;
return a.startTime.localeCompare(b.startTime);
});
// Group by date
const dates: Record<string, { count: number; items: PersonalCalendarItem[] }> = {};
for (const item of allItems) {
if (!dates[item.date]) {
dates[item.date] = { count: 0, items: [] };
}
dates[item.date].count++;
dates[item.date].items.push(item);
}
return { dates };
},
};
// =========================================================================
// Helpers
// =========================================================================
/**
* Generate dates matching a recurrence rule within a range.
*/
function generateRecurrenceDates(
rule: RecurrenceRule,
startDate: Date,
endDate: Date,
interval: number
): Date[] {
const dates: Date[] = [];
const maxOccurrences = 200; // safety limit
const current = new Date(startDate);
let count = 0;
switch (rule.frequency) {
case 'DAILY': {
while (current <= endDate && count < maxOccurrences) {
dates.push(new Date(current));
count++;
current.setDate(current.getDate() + interval);
}
break;
}
case 'WEEKLY': {
const daysOfWeek = rule.daysOfWeek ?? [];
if (daysOfWeek.length === 0) break;
// Walk day-by-day, tracking week boundaries for interval
const weekStart = new Date(startDate);
let weekNum = 0;
while (current <= endDate && count < maxOccurrences) {
// Check if we moved to a new week (ISO: Monday=1)
const daysSinceStart = Math.floor(
(current.getTime() - weekStart.getTime()) / (1000 * 60 * 60 * 24)
);
const currentWeek = Math.floor(daysSinceStart / 7);
if (currentWeek !== weekNum) {
weekNum = currentWeek;
}
// Only include if this week matches the interval
if (weekNum % interval === 0) {
// Convert JS day (0=Sun) to ISO day (1=Mon...7=Sun)
const jsDay = current.getDay();
const isoDay = jsDay === 0 ? 7 : jsDay;
if (daysOfWeek.includes(isoDay)) {
dates.push(new Date(current));
count++;
}
}
current.setDate(current.getDate() + 1);
}
break;
}
case 'BIWEEKLY': {
const biDays = rule.daysOfWeek ?? [];
if (biDays.length === 0) break;
const biWeekStart = new Date(startDate);
let biWeekNum = 0;
while (current <= endDate && count < maxOccurrences) {
const daysSinceStart = Math.floor(
(current.getTime() - biWeekStart.getTime()) / (1000 * 60 * 60 * 24)
);
const currentWeek = Math.floor(daysSinceStart / 7);
if (currentWeek !== biWeekNum) {
biWeekNum = currentWeek;
}
// Every 2 weeks
if (biWeekNum % 2 === 0) {
const jsDay = current.getDay();
const isoDay = jsDay === 0 ? 7 : jsDay;
if (biDays.includes(isoDay)) {
dates.push(new Date(current));
count++;
}
}
current.setDate(current.getDate() + 1);
}
break;
}
case 'MONTHLY': {
const dayOfMonth = rule.dayOfMonth ?? startDate.getDate();
while (current <= endDate && count < maxOccurrences) {
// Set to the target day of month
const targetDate = new Date(current.getFullYear(), current.getMonth(), dayOfMonth);
// Handle months with fewer days (e.g., Feb 30 → clamp to last day)
if (targetDate.getMonth() !== current.getMonth()) {
// Day overflowed — use last day of month
targetDate.setDate(0); // goes to last day of previous month
targetDate.setMonth(targetDate.getMonth() + 1);
targetDate.setDate(0);
}
if (targetDate >= startDate && targetDate <= endDate) {
dates.push(targetDate);
count++;
}
current.setMonth(current.getMonth() + interval);
}
break;
}
}
return dates;
}

View File

@ -1,127 +0,0 @@
import { Router } from 'express';
import { authenticate } from '../../middleware/auth.middleware';
import { validate } from '../../middleware/validate';
import { feedService } from './feed.service';
import { createFeedSchema, updateFeedSchema, createExportTokenSchema } from './feed.schemas';
const router = Router();
// =========================================================================
// Public routes (no auth)
// =========================================================================
// GET /api/calendar/feed/:token.ics — serve ICS export feed
router.get('/feed/:token.ics', async (req, res, next) => {
try {
const token = req.params.token as string;
const icsData = await feedService.getExportFeed(token);
if (!icsData) {
res.status(404).json({ error: { message: 'Feed not found', code: 'NOT_FOUND' } });
return;
}
res.set('Content-Type', 'text/calendar; charset=utf-8');
res.set('Content-Disposition', 'inline; filename="calendar.ics"');
res.send(icsData);
} catch (error) {
next(error);
}
});
// =========================================================================
// Authenticated routes
// =========================================================================
router.use(authenticate);
// GET /api/calendar/feeds — list user's feeds
router.get('/feeds', async (req, res, next) => {
try {
const feeds = await feedService.listFeeds(req.user!.id);
res.json({ feeds });
} catch (error) {
next(error);
}
});
// POST /api/calendar/feeds — create a new ICS feed subscription
router.post('/feeds', validate(createFeedSchema), async (req, res, next) => {
try {
const feed = await feedService.createFeed(req.user!.id, req.body);
res.status(201).json({ feed });
} catch (error) {
next(error);
}
});
// PATCH /api/calendar/feeds/:id — update feed settings
router.patch('/feeds/:id', validate(updateFeedSchema), async (req, res, next) => {
try {
const feed = await feedService.updateFeed(req.user!.id, req.params.id as string, req.body);
res.json({ feed });
} catch (error) {
next(error);
}
});
// DELETE /api/calendar/feeds/:id — delete a feed subscription
router.delete('/feeds/:id', async (req, res, next) => {
try {
await feedService.deleteFeed(req.user!.id, req.params.id as string);
res.json({ success: true });
} catch (error) {
next(error);
}
});
// POST /api/calendar/feeds/:id/refresh — force refresh a feed
router.post('/feeds/:id/refresh', async (req, res, next) => {
try {
const feed = await feedService.listFeeds(req.user!.id);
const owned = feed.find((f) => f.id === (req.params.id as string));
if (!owned) {
res.status(404).json({ error: { message: 'Feed not found', code: 'NOT_FOUND' } });
return;
}
await feedService.refreshFeed(req.params.id as string);
const updated = await feedService.listFeeds(req.user!.id);
const refreshed = updated.find((f) => f.id === (req.params.id as string));
res.json({ feed: refreshed });
} catch (error) {
next(error);
}
});
// GET /api/calendar/export/tokens — list export tokens
router.get('/export/tokens', async (req, res, next) => {
try {
const tokens = await feedService.listExportTokens(req.user!.id);
res.json({ tokens });
} catch (error) {
next(error);
}
});
// POST /api/calendar/export/tokens — create an export token
router.post('/export/tokens', validate(createExportTokenSchema), async (req, res, next) => {
try {
const token = await feedService.createExportToken(req.user!.id, req.body);
res.status(201).json({ token });
} catch (error) {
next(error);
}
});
// DELETE /api/calendar/export/tokens/:id — revoke an export token
router.delete('/export/tokens/:id', async (req, res, next) => {
try {
await feedService.revokeExportToken(req.user!.id, req.params.id as string);
res.json({ success: true });
} catch (error) {
next(error);
}
});
export default router;

View File

@ -1,22 +0,0 @@
import { z } from 'zod';
export const createFeedSchema = z.object({
name: z.string().min(1).max(100),
url: z.string().url(),
refreshInterval: z.enum(['FIFTEEN_MIN', 'HOURLY', 'SIX_HOUR', 'DAILY']).default('HOURLY'),
});
export const updateFeedSchema = z.object({
name: z.string().min(1).max(100).optional(),
url: z.string().url().optional(),
refreshInterval: z.enum(['FIFTEEN_MIN', 'HOURLY', 'SIX_HOUR', 'DAILY']).optional(),
});
export const createExportTokenSchema = z.object({
includePersonal: z.boolean(),
includeLayers: z.array(z.string()).optional(),
});
export type CreateFeedInput = z.infer<typeof createFeedSchema>;
export type UpdateFeedInput = z.infer<typeof updateFeedSchema>;
export type CreateExportTokenInput = z.infer<typeof createExportTokenSchema>;

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