diff --git a/admin/src/App.tsx b/admin/src/App.tsx index 999271d7..26f41bad 100644 --- a/admin/src/App.tsx +++ b/admin/src/App.tsx @@ -7,6 +7,7 @@ import { Users } from './components/Users'; import { Notifications } from './components/Notifications'; import { Feedback } from './components/Feedback'; import { BearItems } from './components/BearItems'; +import { Spotlights } from './components/Spotlights'; import { AppBar, @@ -34,6 +35,7 @@ import { faBell, faComment, faPaw, + faMapPin, } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; @@ -107,6 +109,12 @@ const routes = [ icon: faPaw, name: 'Bear Items', }, + { + path: '/spotlights', + element: , + icon: faMapPin, + name: 'Spotlights', + }, ]; const SidebarIcon = styled.span` diff --git a/admin/src/all.dto.ts b/admin/src/all.dto.ts index 5b490b39..d4d6be4b 100644 --- a/admin/src/all.dto.ts +++ b/admin/src/all.dto.ts @@ -29,6 +29,30 @@ export enum BearSlotDto { ACCESSORY = 'ACCESSORY', } +export enum CampusEventCategoryDto { + SOCIAL = 'SOCIAL', + CULTURAL = 'CULTURAL', + ATHLETIC = 'ATHLETIC', + WELLNESS = 'WELLNESS', + ACADEMIC = 'ACADEMIC', + ARTS = 'ARTS', + CAREER = 'CAREER', + COMMUNITY = 'COMMUNITY', + OTHER = 'OTHER', +} + +export enum EventSourceDto { + API_EVENTS = 'API_EVENTS', + ADMIN_CREATED = 'ADMIN_CREATED', + COMMUNITY_SUBMITTED = 'COMMUNITY_SUBMITTED', +} + +export enum CheckInMethodDto { + LOCATION = 'LOCATION', + QR_CODE = 'QR_CODE', + EITHER = 'EITHER', +} + export enum ChallengeLocationDto { ENG_QUAD = 'ENG_QUAD', ARTS_QUAD = 'ARTS_QUAD', @@ -244,6 +268,123 @@ export interface UpdatePurchaseResultDto { itemId: string; } +export interface AdminBearItemDto { + id: string; + name?: string; + slot?: BearSlotDto; + cost?: number; + assetKey?: string; + mimeType?: string; + zIndex?: number; + isDefault?: boolean; +} + +export interface UpdateBearItemDataDto { + bearItem: AdminBearItemDto; + deleted: boolean; +} + +export interface RequestAllBearItemsDto {} + +export interface CampusEventDto { + id: string; + title: string; + description: string; + imageUrl?: string; + startTime: string; + endTime: string; + allDay: boolean; + locationName: string; + address?: string; + latitude: number; + longitude: number; + categories: CampusEventCategoryDto[]; + tags: string[]; + source: EventSourceDto; + externalUrl?: string; + organizerName?: string; + registrationUrl?: string; + checkInMethod: CheckInMethodDto; + pointsForAttendance: number; + featured: boolean; + attendanceCount: number; + rsvpCount: number; +} + +export interface RequestCampusEventsDto { + page: number; + limit: number; + dateFrom?: string; + dateTo?: string; + categories?: CampusEventCategoryDto[]; + search?: string; + featured?: boolean; +} + +export interface CampusEventListDto { + events: CampusEventDto[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +export interface RequestCampusEventDetailsDto { + eventId: string; +} + +export interface UpsertCampusEventDto { + id?: string; + title: string; + description: string; + imageUrl?: string; + startTime: string; + endTime: string; + allDay?: boolean; + locationName: string; + address?: string; + latitude: number; + longitude: number; + checkInRadius?: number; + categories: CampusEventCategoryDto[]; + tags: string[]; + source: EventSourceDto; + externalId?: string; + externalUrl?: string; + organizerName?: string; + organizerEmail?: string; + organizerId?: string; + checkInMethod?: CheckInMethodDto; + pointsForAttendance?: number; + featured?: boolean; + registrationUrl?: string; +} + +export interface DeleteCampusEventDto { + eventId: string; +} + +export interface RsvpCampusEventDto { + eventId: string; +} + +export interface UnRsvpCampusEventDto { + eventId: string; +} + +export interface UpdateCampusEventDataDto { + event: CampusEventDto; + deleted: boolean; +} + +export interface CampusEventDetailsDto { + event: CampusEventDto; +} + +export interface CampusEventListResponseDto { + list: CampusEventListDto; +} + export interface CompletedChallengeDto {} export interface ChallengeDto { @@ -598,6 +739,45 @@ export interface UpdateQuizQuestionDataDto { deleted: boolean; } +export interface SpotlightDto { + id: string; + title: string; + body: string; + latitude: number; + longitude: number; + radiusMeters: number; + cooldownDays: number; + startDate: string; + endDate: string; + startHour: number; + endHour: number; + isActive: boolean; + linkedEventId?: string; + linkedCampusEventId?: string; +} + +export interface ActiveSpotlightDto { + id: string; + latitude: number; + longitude: number; + radiusMeters: number; +} + +export interface RequestSpotlightNotificationDto { + spotlightId: string; + latitude: number; + longitude: number; +} + +export interface SpotlightNotificationResultDto { + sent: boolean; + reason?: string; +} + +export interface DeleteSpotlightDto { + id: string; +} + export interface TimerStartedDto { timerId: string; endTime: string; @@ -707,21 +887,3 @@ export interface JoinOrganizationDto { export interface CompleteOnboardingDto {} export interface ResetOnboardingDto {} - -export interface AdminBearItemDto { - id: string; - name?: string; - slot?: BearSlotDto; - cost?: number; - assetKey?: string; - mimeType?: string; - zIndex?: number | null; - isDefault?: boolean; -} - -export interface UpdateBearItemDataDto { - bearItem: AdminBearItemDto; - deleted: boolean; -} - -export interface RequestAllBearItemsDto {} diff --git a/admin/src/components/ServerApi.tsx b/admin/src/components/ServerApi.tsx index d9af8b27..f9ffa483 100644 --- a/admin/src/components/ServerApi.tsx +++ b/admin/src/components/ServerApi.tsx @@ -55,6 +55,64 @@ export class ServerApi { return this.send('equipBearItem', data) as Promise; } + requestAllBearItems(data: dto.RequestAllBearItemsDto) { + return this.send('requestAllBearItems', data) as Promise< + number | undefined + >; + } + + updateBearItemData(data: dto.UpdateBearItemDataDto) { + return this.send('updateBearItemData', data) as Promise; + } + + requestCampusEvents(data: dto.RequestCampusEventsDto) { + return this.send('requestCampusEvents', data) as Promise< + number | undefined + >; + } + + requestCampusEventDetails(data: dto.RequestCampusEventDetailsDto) { + return this.send('requestCampusEventDetails', data) as Promise< + string | undefined + >; + } + + requestAllCampusEvents(data: dto.RequestCampusEventsDto) { + return this.send('requestAllCampusEvents', data) as Promise< + number | undefined + >; + } + + createCampusEvent(data: dto.UpsertCampusEventDto) { + return this.send('createCampusEvent', data) as Promise; + } + + updateCampusEvent(data: dto.UpsertCampusEventDto) { + return this.send('updateCampusEvent', data) as Promise; + } + + deleteCampusEvent(data: dto.DeleteCampusEventDto) { + return this.send('deleteCampusEvent', data) as Promise; + } + + rsvpCampusEvent(data: dto.RsvpCampusEventDto) { + return this.send('rsvpCampusEvent', data) as Promise; + } + + unRsvpCampusEvent(data: dto.UnRsvpCampusEventDto) { + return this.send('unRsvpCampusEvent', data) as Promise; + } + + requestAvailableChallenges(data: dto.RequestAvailableChallengesDto) { + return this.send('requestAvailableChallenges', data) as Promise< + any | undefined + >; + } + + setCurrentChallenge(data: dto.SetCurrentChallengeDto) { + return this.send('setCurrentChallenge', data) as Promise; + } + requestChallengeData(data: dto.RequestChallengeDataDto) { return this.send('requestChallengeData', data) as Promise< number | undefined @@ -119,6 +177,14 @@ export class ServerApi { return this.send('updateEventData', data) as Promise; } + triggerEventSync(data: dto.TriggerEventSyncDto) { + return this.send('triggerEventSync', data) as Promise; + } + + requestEventSyncStatus() { + return this.send('requestEventSyncStatus', {}) as Promise; + } + submitFeedback(data: dto.SubmitFeedbackDto) { return this.send('submitFeedback', data) as Promise; } @@ -155,6 +221,14 @@ export class ServerApi { return this.send('updateFcmToken', data) as Promise; } + sendNotification(data: dto.SendNotificationDto) { + return this.send('sendNotification', data) as Promise; + } + + removeFcmToken() { + return this.send('removeFcmToken', {}) as Promise; + } + requestOrganizationData(data: dto.RequestOrganizationDataDto) { return this.send('requestOrganizationData', data) as Promise< number | undefined @@ -199,6 +273,32 @@ export class ServerApi { >; } + getActiveSpotlights() { + return this.send('getActiveSpotlights', {}) as Promise; + } + + requestSpotlightNotification(data: dto.RequestSpotlightNotificationDto) { + return this.send('requestSpotlightNotification', data) as Promise< + any | undefined + >; + } + + getAllSpotlights() { + return this.send('getAllSpotlights', {}) as Promise; + } + + createSpotlight(data: dto.SpotlightDto) { + return this.send('createSpotlight', data) as Promise; + } + + updateSpotlight(data: dto.SpotlightDto) { + return this.send('updateSpotlight', data) as Promise; + } + + deleteSpotlight(data: dto.DeleteSpotlightDto) { + return this.send('deleteSpotlight', data) as Promise; + } + startChallengeTimer(data: dto.StartChallengeTimerDto) { return this.send('startChallengeTimer', data) as Promise< string | undefined @@ -259,21 +359,6 @@ export class ServerApi { return this.send('closeAccount', data) as Promise; } - requestAllBearItems(data: dto.RequestAllBearItemsDto) { - return this.send('requestAllBearItems', data) as Promise< - number | undefined - >; - } - - updateBearItemData(data: dto.UpdateBearItemDataDto) { - return this.send('updateBearItemData', data) as Promise; - } - - onUpdateBearItemData(callback: (data: dto.UpdateBearItemDataDto) => void) { - this.socket.removeAllListeners('updateBearItemData'); - this.socket.on('updateBearItemData', data => callback(data)); - } - onUpdateUserData(callback: (data: dto.UpdateUserDataDto) => void) { this.socket.removeAllListeners('updateUserData'); this.socket.on('updateUserData', data => callback(data)); @@ -347,6 +432,11 @@ export class ServerApi { this.socket.on('updateBearItemsData', data => callback(data)); } + onUpdateBearItemData(callback: (data: dto.UpdateBearItemDataDto) => void) { + this.socket.removeAllListeners('updateBearItemData'); + this.socket.on('updateBearItemData', data => callback(data)); + } + onUpdateUserInventoryData( callback: (data: dto.UpdateUserInventoryDataDto) => void, ) { @@ -424,4 +514,21 @@ export class ServerApi { this.socket.removeAllListeners('updateFeedbackData'); this.socket.on('updateFeedbackData', data => callback(data)); } + + onUpdateCampusEventData( + callback: (data: dto.UpdateCampusEventDataDto) => void, + ) { + this.socket.removeAllListeners('updateCampusEventData'); + this.socket.on('updateCampusEventData', data => callback(data)); + } + + onCampusEventList(callback: (data: dto.CampusEventListResponseDto) => void) { + this.socket.removeAllListeners('campusEventList'); + this.socket.on('campusEventList', data => callback(data)); + } + + onCampusEventDetails(callback: (data: dto.CampusEventDetailsDto) => void) { + this.socket.removeAllListeners('campusEventDetails'); + this.socket.on('campusEventDetails', data => callback(data)); + } } diff --git a/admin/src/components/Spotlights.tsx b/admin/src/components/Spotlights.tsx new file mode 100644 index 00000000..2f005938 --- /dev/null +++ b/admin/src/components/Spotlights.tsx @@ -0,0 +1,552 @@ +import { useContext, useEffect, useState } from 'react'; +import styled from 'styled-components'; +import { ServerConnectionContext } from './ServerConnection'; + +const PageContainer = styled.div` + max-width: 800px; +`; + +const Title = styled.h2` + margin-bottom: 8px; + color: #333; +`; + +const Subtitle = styled.p` + color: #666; + margin-bottom: 24px; +`; + +const FormGroup = styled.div` + margin-bottom: 16px; +`; + +const Label = styled.label` + display: block; + font-weight: 500; + margin-bottom: 6px; + color: #333; +`; + +const Input = styled.input` + width: 100%; + padding: 10px 12px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 14px; + box-sizing: border-box; + + &:focus { + outline: none; + border-color: #b31b1b; + } +`; + +const TextArea = styled.textarea` + width: 100%; + padding: 10px 12px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 14px; + min-height: 80px; + resize: vertical; + box-sizing: border-box; + font-family: inherit; + + &:focus { + outline: none; + border-color: #b31b1b; + } +`; + +const Select = styled.select` + width: 100%; + padding: 10px 12px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 14px; + background: white; + box-sizing: border-box; + + &:focus { + outline: none; + border-color: #b31b1b; + } +`; + +const Button = styled.button<{ disabled?: boolean; variant?: string }>` + background-color: ${props => + props.disabled + ? '#ccc' + : props.variant === 'danger' + ? '#d32f2f' + : props.variant === 'secondary' + ? '#666' + : '#b31b1b'}; + color: white; + border: none; + padding: 10px 20px; + border-radius: 4px; + font-size: 14px; + font-weight: 500; + cursor: ${props => (props.disabled ? 'not-allowed' : 'pointer')}; + margin-right: 8px; + transition: background-color 0.2s; + + &:hover { + opacity: ${props => (props.disabled ? 1 : 0.85)}; + } +`; + +const Table = styled.table` + width: 100%; + border-collapse: collapse; + margin-bottom: 24px; +`; + +const Th = styled.th` + text-align: left; + padding: 10px 12px; + border-bottom: 2px solid #ddd; + color: #333; + font-size: 14px; +`; + +const Td = styled.td` + padding: 10px 12px; + border-bottom: 1px solid #eee; + font-size: 14px; +`; + +const StatusBadge = styled.span<{ active: boolean }>` + display: inline-block; + padding: 2px 8px; + border-radius: 12px; + font-size: 12px; + font-weight: 500; + background-color: ${props => (props.active ? '#e8f5e9' : '#ffebee')}; + color: ${props => (props.active ? '#2e7d32' : '#c62828')}; +`; + +const Row = styled.div` + display: flex; + gap: 16px; +`; + +const HalfWidth = styled.div` + flex: 1; +`; + +const InfoBox = styled.div` + margin-top: 16px; + padding: 12px 16px; + border-radius: 4px; + background-color: #e3f2fd; + color: #1565c0; + border: 1px solid #90caf9; + font-size: 14px; +`; + +interface SpotlightData { + id: string; + title: string; + body: string; + latitude: number; + longitude: number; + radiusMeters: number; + cooldownDays: number; + startDate: string; + endDate: string; + startHour: number; + endHour: number; + isActive: boolean; + linkedEventId?: string; + linkedCampusEventId?: string; +} + +const emptySpotlight: Omit = { + title: '', + body: '', + latitude: 42.4534, + longitude: -76.4735, + radiusMeters: 200, + cooldownDays: 7, + startDate: new Date().toISOString().split('T')[0], + endDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) + .toISOString() + .split('T')[0], + startHour: 9, + endHour: 21, + isActive: true, +}; + +export function Spotlights() { + const connection = useContext(ServerConnectionContext); + const [spotlights, setSpotlights] = useState([]); + const [editing, setEditing] = useState(null); + const [creating, setCreating] = useState(false); + const [form, setForm] = useState>(emptySpotlight); + const [saving, setSaving] = useState(false); + + const loadSpotlights = async () => { + try { + const result = await connection.connection + ?.timeout(10000) + .emitWithAck('getAllSpotlights'); + if (result) { + setSpotlights(result as SpotlightData[]); + } + } catch (err) { + console.error('Failed to load spotlights:', err); + } + }; + + useEffect(() => { + loadSpotlights(); + }, [connection.connection]); + + const handleSave = async () => { + if (!form.title.trim() || !form.body.trim()) return; + setSaving(true); + + try { + const payload = { + ...form, + startDate: new Date(form.startDate).toISOString(), + endDate: new Date(form.endDate).toISOString(), + }; + + if (editing) { + await connection.connection + ?.timeout(10000) + .emitWithAck('updateSpotlight', { ...payload, id: editing.id }); + } else { + await connection.connection + ?.timeout(10000) + .emitWithAck('createSpotlight', payload); + } + + setEditing(null); + setCreating(false); + setForm(emptySpotlight); + await loadSpotlights(); + } catch (err) { + console.error('Failed to save spotlight:', err); + } finally { + setSaving(false); + } + }; + + const handleDelete = async (id: string) => { + if (!window.confirm('Delete this spotlight?')) return; + try { + await connection.connection + ?.timeout(10000) + .emitWithAck('deleteSpotlight', { id }); + await loadSpotlights(); + } catch (err) { + console.error('Failed to delete spotlight:', err); + } + }; + + const startEdit = (s: SpotlightData) => { + setEditing(s); + setCreating(false); + setForm({ + title: s.title, + body: s.body, + latitude: s.latitude, + longitude: s.longitude, + radiusMeters: s.radiusMeters, + cooldownDays: s.cooldownDays, + startDate: s.startDate.split('T')[0], + endDate: s.endDate.split('T')[0], + startHour: s.startHour, + endHour: s.endHour, + isActive: s.isActive, + linkedEventId: s.linkedEventId, + linkedCampusEventId: s.linkedCampusEventId, + }); + }; + + const startCreate = () => { + setEditing(null); + setCreating(true); + setForm(emptySpotlight); + }; + + const cancel = () => { + setEditing(null); + setCreating(false); + setForm(emptySpotlight); + }; + + const isFormOpen = editing || creating; + + const getStatus = (s: SpotlightData) => { + if (!s.isActive) return false; + const now = new Date(); + return new Date(s.startDate) <= now && new Date(s.endDate) >= now; + }; + + const hourOptions = Array.from({ length: 24 }, (_, i) => ({ + value: i, + label: `${i === 0 ? '12' : i > 12 ? i - 12 : i}:00 ${i < 12 ? 'AM' : 'PM'}`, + })); + + return ( + + Location Spotlights + + Configure location-based notification zones. Users near these locations + will receive a push notification (with smart anti-spam controls). + + + {!isFormOpen && ( + <> + + + + + + + + + + + + + + + {spotlights.map(s => ( + + + + + + + + + ))} + {spotlights.length === 0 && ( + + + + )} + +
TitleStatusRadiusCooldownDate RangeActions
{s.title} + + {getStatus(s) ? 'Active' : 'Inactive'} + + {s.radiusMeters}m{s.cooldownDays} days + {new Date(s.startDate).toLocaleDateString()} –{' '} + {new Date(s.endDate).toLocaleDateString()} + + + +
+ No spotlights configured yet. +
+ + )} + + {isFormOpen && ( + <> +

{editing ? 'Edit Spotlight' : 'Create Spotlight'}

+ + + + setForm({ ...form, title: e.target.value })} + placeholder="e.g., Challenge nearby!" + maxLength={100} + /> + + + + +