Compare commits
No commits in common. "eba645398159a41ffde2d8fe14f5d9d2917f28f2" and "e7890b0be19b7acf44a6cb4b26306a32aa60b82c" have entirely different histories.
eba6453981
...
e7890b0be1
@ -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
3
.gitignore
vendored
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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
|
|
||||||
6
admin/package-lock.json
generated
6
admin/package-lock.json
generated
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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>
|
|
||||||
@ -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={
|
||||||
|
|||||||
@ -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 && (
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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]);
|
||||||
|
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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})`;
|
|
||||||
}
|
|
||||||
@ -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})`;
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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;">❤️</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} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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, '') },
|
|
||||||
{ 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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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" />;
|
|
||||||
}
|
|
||||||
@ -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 {
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -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 + '/'));
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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 });
|
||||||
|
|||||||
@ -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}>
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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']}
|
||||||
|
|||||||
@ -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
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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' }}>
|
||||||
|
|||||||
@ -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 */}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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}
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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}'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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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 && (
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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',
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
15
admin/src/vite-env.d.ts
vendored
15
admin/src/vite-env.d.ts
vendored
@ -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
98
api/package-lock.json
generated
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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;
|
|
||||||
@ -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;
|
|
||||||
@ -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;
|
|
||||||
@ -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;
|
|
||||||
@ -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';
|
|
||||||
@ -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")
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
|
||||||
@ -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>;
|
|
||||||
@ -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,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@ -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;
|
|
||||||
@ -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>;
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -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;
|
|
||||||
@ -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
Loading…
x
Reference in New Issue
Block a user